diff --git a/package.json b/package.json index 97d07f3..6564209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.75.8", + "version": "1.76.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 34a66a9..422da7e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,7 @@ model User { accounts Account[] cachedCalendarEvents CachedCalendarEvent[] calendarConnections CalendarConnection[] + calendarSyncRules CalendarSyncRule[] projects Project[] sessions Session[] weatherEnabled Boolean @default(false) @@ -316,6 +317,47 @@ model PushSubscription { @@index([userId]) } +model CalendarSyncRule { + id String @id @default(cuid()) + userId String + name String @default("") + enabled Boolean @default(true) + direction String @default("one-way") // "one-way" (source→target) + sourceConnectionId String + sourceCalendarId String + targetConnectionId String + targetCalendarId String + syncDescription Boolean @default(true) + syncLocation Boolean @default(true) + syncRecurring Boolean @default(false) + titlePrefix String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSyncedAt DateTime? + mappings CalendarSyncMapping[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model CalendarSyncMapping { + id String @id @default(cuid()) + ruleId String + userId String + sourceEventId String + sourceProvider String + targetEventId String + targetProvider String + targetCalendarId String + sourceFingerprint String + lastSyncedAt DateTime @default(now()) + rule CalendarSyncRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) + + @@unique([ruleId, sourceEventId]) + @@index([userId, targetEventId, targetProvider]) + @@index([ruleId]) +} + model SentNotification { id String @id @default(cuid()) userId String diff --git a/src/app/api/calendar/cross-sync/rules/[id]/route.ts b/src/app/api/calendar/cross-sync/rules/[id]/route.ts new file mode 100644 index 0000000..f9d79e2 --- /dev/null +++ b/src/app/api/calendar/cross-sync/rules/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// PATCH /api/calendar/cross-sync/rules/[id] — update a rule +export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } }); + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + const existing = await prisma.calendarSyncRule.findFirst({ where: { id: params.id, userId: user.id } }); + if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 }); + + const body = await request.json(); + const allowed = ['name', 'enabled', 'direction', 'sourceConnectionId', 'sourceCalendarId', + 'targetConnectionId', 'targetCalendarId', 'syncDescription', 'syncLocation', + 'syncRecurring', 'titlePrefix']; + + const data: Record = {}; + for (const key of allowed) { + if (key in body) data[key] = body[key]; + } + + const rule = await prisma.calendarSyncRule.update({ where: { id: params.id }, data }); + return NextResponse.json({ rule }); +} + +// DELETE /api/calendar/cross-sync/rules/[id] — delete a rule and all its mappings +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } }); + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + const existing = await prisma.calendarSyncRule.findFirst({ where: { id: params.id, userId: user.id } }); + if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 }); + + // Cascade delete (mappings deleted via onDelete: Cascade in schema) + await prisma.calendarSyncRule.delete({ where: { id: params.id } }); + // Also clean up reverse mappings stored under the synthetic _R id + await prisma.calendarSyncMapping.deleteMany({ where: { ruleId: params.id + '_R' } }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/calendar/cross-sync/rules/route.ts b/src/app/api/calendar/cross-sync/rules/route.ts new file mode 100644 index 0000000..42243ac --- /dev/null +++ b/src/app/api/calendar/cross-sync/rules/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// GET /api/calendar/cross-sync/rules — list all rules for the current user +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } }); + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + const rules = await prisma.calendarSyncRule.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'asc' }, + }); + + return NextResponse.json({ rules }); +} + +// POST /api/calendar/cross-sync/rules — create a new rule +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } }); + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + const body = await request.json(); + const { + name = '', + enabled = true, + direction = 'one-way', + sourceConnectionId, + sourceCalendarId, + targetConnectionId, + targetCalendarId, + syncDescription = true, + syncLocation = true, + syncRecurring = false, + titlePrefix = '', + } = body; + + if (!sourceConnectionId || !sourceCalendarId || !targetConnectionId || !targetCalendarId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Verify connections belong to this user + const connCount = await prisma.calendarConnection.count({ + where: { id: { in: [sourceConnectionId, targetConnectionId] }, userId: user.id }, + }); + if (connCount < 2) { + return NextResponse.json({ error: 'Invalid connection IDs' }, { status: 403 }); + } + + const rule = await prisma.calendarSyncRule.create({ + data: { + userId: user.id, + name, + enabled, + direction, + sourceConnectionId, + sourceCalendarId, + targetConnectionId, + targetCalendarId, + syncDescription, + syncLocation, + syncRecurring, + titlePrefix, + }, + }); + + return NextResponse.json({ rule }, { status: 201 }); +} diff --git a/src/app/api/calendar/cross-sync/run/route.ts b/src/app/api/calendar/cross-sync/run/route.ts new file mode 100644 index 0000000..fa03c06 --- /dev/null +++ b/src/app/api/calendar/cross-sync/run/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { runSyncRules } from '@/lib/calendar-cross-sync'; + +// POST /api/calendar/cross-sync/run — manually trigger sync rules for the current user +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } }); + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + try { + const results = await runSyncRules(user.id); + return NextResponse.json({ results }); + } catch (err: any) { + console.error('[cross-sync/run] Error:', err); + return NextResponse.json({ error: err?.message || 'Sync failed' }, { status: 500 }); + } +} diff --git a/src/app/api/calendar/sync/route.ts b/src/app/api/calendar/sync/route.ts index b1fb9a2..dacf069 100644 --- a/src/app/api/calendar/sync/route.ts +++ b/src/app/api/calendar/sync/route.ts @@ -4,6 +4,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { prisma } from '@/lib/prisma'; import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache'; +import { runSyncRules } from '@/lib/calendar-cross-sync'; export async function POST(request: NextRequest) { try { @@ -70,6 +71,8 @@ export async function POST(request: NextRequest) { if (forceRefresh) { // BLOCKING: wait for fresh data when user explicitly requests refresh await doRefresh(); + // Run cross-sync rules after cache refresh (fire-and-forget, don't block response) + runSyncRules(user.id).catch(e => console.error('[cross-sync] Error running sync rules:', e)); const freshEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate); return NextResponse.json({ success: true, diff --git a/src/components/CalendarSyncRulesPanel.tsx b/src/components/CalendarSyncRulesPanel.tsx new file mode 100644 index 0000000..dfe7a40 --- /dev/null +++ b/src/components/CalendarSyncRulesPanel.tsx @@ -0,0 +1,536 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Plus, Trash2, RefreshCcw, Check, AlertCircle, ArrowRight, ArrowLeftRight, Pencil, X } from "lucide-react"; + +interface CalendarSyncRule { + id: string; + name: string; + enabled: boolean; + direction: "one-way" | "two-way"; + sourceConnectionId: string; + sourceCalendarId: string; + targetConnectionId: string; + targetCalendarId: string; + syncDescription: boolean; + syncLocation: boolean; + syncRecurring: boolean; + titlePrefix: string; + lastSyncedAt?: string | null; +} + +interface CalendarInfo { + id: string; + title: string; + color?: string; + editable?: boolean; +} + +interface ConnectionInfo { + id: string; + provider: string; + calendars?: CalendarInfo[]; +} + +interface Props { + connections: ConnectionInfo[]; + darkMode?: boolean; + t: Record; +} + +const PROVIDER_LABELS: Record = { + google: "Google", + apple: "Apple", + outlook: "Outlook", + synology: "Synology", +}; + +function calendarLabel(conn: ConnectionInfo, calId: string): string { + const cal = conn.calendars?.find((c) => c.id === calId); + return cal?.title || calId; +} + +function providerLabel(conn: ConnectionInfo): string { + return PROVIDER_LABELS[conn.provider] || conn.provider; +} + +// Flatten all (connectionId, calendarId) pairs for picker usage +function allCalendarOptions(connections: ConnectionInfo[]) { + const opts: { connId: string; calId: string; label: string; connLabel: string; color?: string }[] = []; + for (const conn of connections) { + if (!conn.calendars) continue; + for (const cal of conn.calendars) { + opts.push({ + connId: conn.id, + calId: cal.id, + label: cal.title, + connLabel: providerLabel(conn), + color: cal.color, + }); + } + } + return opts; +} + +const EMPTY_FORM = { + name: "", + enabled: true, + direction: "one-way" as "one-way" | "two-way", + sourceConnectionId: "", + sourceCalendarId: "", + targetConnectionId: "", + targetCalendarId: "", + syncDescription: true, + syncLocation: true, + syncRecurring: false, + titlePrefix: "", +}; + +export default function CalendarSyncRulesPanel({ connections, darkMode, t }: Props) { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [syncResults, setSyncResults] = useState<{ ruleId: string; ruleName: string; created: number; updated: number; deleted: number; error?: string }[] | null>(null); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [formError, setFormError] = useState(""); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ text: string; type: "success" | "error" } | null>(null); + + const calOptions = allCalendarOptions(connections); + + const fetchRules = useCallback(async () => { + try { + const res = await fetch("/api/calendar/cross-sync/rules"); + const data = await res.json(); + setRules(data.rules || []); + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchRules(); }, [fetchRules]); + + const openNewForm = () => { + setForm({ ...EMPTY_FORM }); + setEditingId(null); + setFormError(""); + setShowForm(true); + }; + + const openEditForm = (rule: CalendarSyncRule) => { + setForm({ + name: rule.name, + enabled: rule.enabled, + direction: rule.direction, + sourceConnectionId: rule.sourceConnectionId, + sourceCalendarId: rule.sourceCalendarId, + targetConnectionId: rule.targetConnectionId, + targetCalendarId: rule.targetCalendarId, + syncDescription: rule.syncDescription, + syncLocation: rule.syncLocation, + syncRecurring: rule.syncRecurring, + titlePrefix: rule.titlePrefix, + }); + setEditingId(rule.id); + setFormError(""); + setShowForm(true); + }; + + const closeForm = () => { setShowForm(false); setEditingId(null); }; + + const saveForm = async () => { + if (!form.sourceCalendarId || !form.targetCalendarId) { + setFormError("Please select a source and target calendar."); + return; + } + if (form.sourceConnectionId === form.targetConnectionId && form.sourceCalendarId === form.targetCalendarId) { + setFormError("Source and target calendar must be different."); + return; + } + setSaving(true); + setFormError(""); + try { + const url = editingId + ? `/api/calendar/cross-sync/rules/${editingId}` + : "/api/calendar/cross-sync/rules"; + const method = editingId ? "PATCH" : "POST"; + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to save"); + } + await fetchRules(); + closeForm(); + setMsg({ text: editingId ? "Rule updated." : "Rule created.", type: "success" }); + setTimeout(() => setMsg(null), 3000); + } catch (err: any) { + setFormError(err.message || "Failed to save rule"); + } finally { + setSaving(false); + } + }; + + const deleteRule = async (ruleId: string) => { + if (!confirm("Delete this sync rule? Existing synced events will NOT be removed.")) return; + try { + await fetch(`/api/calendar/cross-sync/rules/${ruleId}`, { method: "DELETE" }); + setRules((prev) => prev.filter((r) => r.id !== ruleId)); + setMsg({ text: "Rule deleted.", type: "success" }); + setTimeout(() => setMsg(null), 3000); + } catch { + setMsg({ text: "Failed to delete rule.", type: "error" }); + setTimeout(() => setMsg(null), 4000); + } + }; + + const toggleEnabled = async (rule: CalendarSyncRule) => { + const newVal = !rule.enabled; + setRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, enabled: newVal } : r)); + await fetch(`/api/calendar/cross-sync/rules/${rule.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: newVal }), + }); + }; + + const runSync = async () => { + setSyncing(true); + setSyncResults(null); + try { + const res = await fetch("/api/calendar/cross-sync/run", { method: "POST" }); + const data = await res.json(); + setSyncResults(data.results || []); + } catch { + setMsg({ text: "Sync failed.", type: "error" }); + setTimeout(() => setMsg(null), 4000); + } finally { + setSyncing(false); + } + }; + + // Derive connection for a given connectionId + const connFor = (id: string) => connections.find((c) => c.id === id); + + const label = (s: string) => ( + + ); + + const inputStyle: React.CSSProperties = { + width: "100%", padding: "7px 10px", borderRadius: "7px", + border: "1px solid var(--weekly-border, #e5e7eb)", + background: "var(--weekly-input-bg, var(--weekly-bg, #fff))", + color: "var(--weekly-text, #333)", fontSize: "0.875rem", + }; + + const btnStyle = (primary?: boolean): React.CSSProperties => ({ + padding: "8px 16px", borderRadius: "8px", + background: primary ? "var(--weekly-text, #333)" : "var(--weekly-bg, #fff)", + color: primary ? "var(--weekly-bg, #fff)" : "var(--weekly-text, #333)", + border: primary ? "none" : "1px solid var(--weekly-border, #e5e7eb)", + fontSize: "0.875rem", fontWeight: 500, cursor: "pointer", + display: "flex", alignItems: "center", gap: "6px", + }); + + // Calendar picker select for the form + const CalPicker = ({ label: pickerLabel, connId, calId, onChange }: { + label: string; + connId: string; + calId: string; + onChange: (connId: string, calId: string) => void; + }) => { + const value = connId && calId ? `${connId}::${calId}` : ""; + return ( +
+ {label(pickerLabel)} + +
+ ); + }; + + if (loading) return

Loading sync rules…

; + + return ( +
+ {/* Enable/Disable section header */} +
+
+

+ {t.calendarSyncTitle || "Calendar Sync"} +

+

+ {t.calendarSyncDesc || "Sync events between connected calendar providers."} +

+
+ +
+ + {/* Status message */} + {msg && ( +
+ {msg.type === "success" ? : } + {msg.text} +
+ )} + + {/* Sync results */} + {syncResults && ( +
+ {t.syncResults || "Sync Results"} + {syncResults.length === 0 + ? {t.noRulesEnabled || "No enabled rules found."} + : syncResults.map((r) => ( +
+ {r.ruleName || "Rule"} + {r.error + ? Error: {r.error} + : <> + {r.created > 0 && +{r.created}} + {r.updated > 0 && ~{r.updated}} + {r.deleted > 0 && −{r.deleted}} + {r.created === 0 && r.updated === 0 && r.deleted === 0 && up to date} + + } +
+ )) + } +
+ )} + + {/* No connections warning */} + {connections.length < 2 && ( +
+ + {t.syncNeedsTwo || "You need at least two connected calendars to create a sync rule."} +
+ )} + + {/* Rules list */} + {rules.length > 0 && ( +
+ {rules.map((rule) => { + const srcConn = connFor(rule.sourceConnectionId); + const tgtConn = connFor(rule.targetConnectionId); + return ( +
+
+
+ {/* Toggle */} + +
+
+ {rule.name || `${srcConn ? providerLabel(srcConn) : "?"} → ${tgtConn ? providerLabel(tgtConn) : "?"}`} +
+
+ {srcConn ? calendarLabel(srcConn, rule.sourceCalendarId) : rule.sourceCalendarId} + {rule.direction === "two-way" ? : } + {tgtConn ? calendarLabel(tgtConn, rule.targetCalendarId) : rule.targetCalendarId} +
+
+
+
+ + +
+
+ {rule.lastSyncedAt && ( +
+ Last synced: {new Date(rule.lastSyncedAt).toLocaleString()} +
+ )} +
+ ); + })} +
+ )} + + {rules.length === 0 && !showForm && ( +

+ {t.noSyncRules || "No sync rules yet. Add one below."} +

+ )} + + {/* Add new rule button */} + {!showForm && connections.length >= 2 && ( + + )} + + {/* Form */} + {showForm && ( +
+
+ {editingId ? (t.editSyncRule || "Edit Rule") : (t.newSyncRule || "New Sync Rule")} + +
+ + {/* Rule name */} +
+ {label(t.syncRuleName || "Rule Name (optional)")} + setForm({ ...form, name: e.target.value })} + placeholder={t.syncRuleNamePlaceholder || "e.g. Work → Personal"} + style={inputStyle} + /> +
+ + {/* Direction */} +
+ {label(t.syncDirection || "Direction")} +
+ {(["one-way", "two-way"] as const).map((d) => ( + + ))} +
+
+ + {/* Source calendar */} + setForm({ ...form, sourceConnectionId: connId, sourceCalendarId: calId })} + /> + + {/* Target calendar */} + setForm({ ...form, targetConnectionId: connId, targetCalendarId: calId })} + /> + + {/* Title prefix */} +
+ {label(t.titlePrefix || "Title Prefix (optional)")} + setForm({ ...form, titlePrefix: e.target.value })} + placeholder={t.titlePrefixPlaceholder || "e.g. [Work] "} + style={inputStyle} + /> +
+ + {/* Options checkboxes */} +
+ {([ + { key: "syncDescription", label: t.syncDescription || "Sync description" }, + { key: "syncLocation", label: t.syncLocation || "Sync location" }, + { key: "syncRecurring", label: t.syncRecurring || "Include recurring events" }, + ] as const).map(({ key, label: optLabel }) => ( + + ))} +
+ + {formError && ( +
+ {formError} +
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 4d65a73..a1add6d 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -81,6 +81,7 @@ import { Filter, Pencil, FileText, + ArrowLeftRight, } from "lucide-react"; const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim(); @@ -93,6 +94,7 @@ import RecurringTasksManager from "./RecurringTasksManager"; export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; } import { ImportListModal } from "./ImportListModal"; import OnboardingWizard from "./OnboardingWizard"; +import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel"; import { getRandomLocalQuote } from "@/lib/quotes"; // Cookie helpers for per-device settings @@ -395,6 +397,32 @@ const translations: Record = { motivation: "Motivation", about: "About", setupAssistant: "Run Setup Assistant", + calendarSync: "Sync", + calendarSyncTitle: "Calendar Sync", + calendarSyncDesc: "Sync events between your connected calendar providers.", + syncNow: "Sync Now", + syncing: "Syncing…", + syncResults: "Sync Results", + noRulesEnabled: "No enabled rules found.", + syncNeedsTwo: "You need at least two connected calendars to create a sync rule.", + noSyncRules: "No sync rules yet. Add one below.", + addSyncRule: "Add Sync Rule", + editSyncRule: "Edit Rule", + newSyncRule: "New Sync Rule", + syncRuleName: "Rule Name (optional)", + syncRuleNamePlaceholder: "e.g. Work → Personal", + syncDirection: "Direction", + oneWay: "One-way", + twoWay: "Two-way", + sourceCalendar: "Source Calendar", + targetCalendar: "Target Calendar", + titlePrefix: "Title Prefix (optional)", + titlePrefixPlaceholder: "e.g. [Work] ", + syncDescription: "Sync description", + syncLocation: "Sync location", + syncRecurring: "Include recurring events", + createRule: "Create Rule", + updateRule: "Update Rule", weekStartLabel: "Start week on", startViewLabel: "Start view on", monday: "Monday", @@ -613,6 +641,31 @@ const translations: Record = { motivation: "Motivation", about: "Über", setupAssistant: "Einrichtungsassistent starten", + calendarSync: "Sync", + calendarSyncTitle: "Kalender-Synchronisation", + calendarSyncDesc: "Ereignisse zwischen verbundenen Kalender-Anbietern synchronisieren.", + syncNow: "Jetzt synchronisieren", + syncResults: "Sync-Ergebnis", + noRulesEnabled: "Keine aktiven Regeln gefunden.", + syncNeedsTwo: "Du benötigst mindestens zwei verbundene Kalender für eine Sync-Regel.", + noSyncRules: "Noch keine Sync-Regeln. Füge eine unten hinzu.", + addSyncRule: "Regel hinzufügen", + editSyncRule: "Regel bearbeiten", + newSyncRule: "Neue Sync-Regel", + syncRuleName: "Regelname (optional)", + syncRuleNamePlaceholder: "z.B. Arbeit → Privat", + syncDirection: "Richtung", + oneWay: "Einseitig", + twoWay: "Beidseitig", + sourceCalendar: "Quellkalender", + targetCalendar: "Zielkalender", + titlePrefix: "Titel-Präfix (optional)", + titlePrefixPlaceholder: "z.B. [Arbeit] ", + syncDescription: "Beschreibung synchronisieren", + syncLocation: "Ort synchronisieren", + syncRecurring: "Wiederkehrende Ereignisse einschließen", + createRule: "Regel erstellen", + updateRule: "Regel aktualisieren", weekStartLabel: "Woche beginnt am", startViewLabel: "Ansicht beginnt mit", monday: "Montag", @@ -11495,7 +11548,7 @@ interface SettingsSidebarProps { fetchAvailableTaskLists: ( provider: "google" | "apple" | "outlook" | "synology", ) => Promise; - initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about"; + initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "sync"; projects: { id: string; name: string; icon?: string | null; color?: string | null }[]; onProjectsChanged: () => void; kanbanStages: KanbanStage[]; @@ -11768,7 +11821,7 @@ function SettingsSidebar({ onRunSetupAssistant, }: SettingsSidebarProps) { const [activeTab, setActiveTab] = useState< - "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" + "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync" >(initialTab || "general"); const [isLoading, setIsLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); @@ -12269,6 +12322,7 @@ function SettingsSidebar({ { key: "general", icon: , label: t.general }, { key: "localisation", icon: , label: t.localisation }, { key: "calendar", icon: , label: t.calendar }, + { key: "sync", icon: , label: t.calendarSync || "Sync" }, { key: "account", icon: , label: t.account }, { key: "styling", icon: , label: t.styling }, { key: "motivation", icon: , label: t.motivation }, @@ -15240,6 +15294,11 @@ function SettingsSidebar({ )} + ) : activeTab === "sync" ? ( + ) : activeTab === "about" ? (
{ + const mapping = await prisma.calendarSyncMapping.findFirst({ + where: { userId, targetEventId: externalId, targetProvider: provider }, + select: { id: true }, + }); + return mapping !== null; +} + +// --------------------------------------------------------------------------- +// Provider-connection helpers +// --------------------------------------------------------------------------- + +/** Load a CalendarConnection from the DB and cast it to the shape calendar-events.ts expects */ +async function loadConnection(connectionId: string): Promise { + const row = await prisma.calendarConnection.findUnique({ + where: { id: connectionId }, + select: { + id: true, + userId: true, + provider: true, + accessToken: true, + refreshToken: true, + expiresAt: true, + calendars: true, + }, + }); + if (!row) return null; + return row as unknown as CalendarConnection; +} + +// --------------------------------------------------------------------------- +// Event payload builder +// --------------------------------------------------------------------------- + +/** + * Build a CalendarEvent payload suitable for createCalendarEvent / updateCalendarEvent + * from a cached event row + rule options. + */ +function buildPayload( + cachedEvent: { + title: string; + description?: string | null; + location?: string | null; + startDateTime?: Date | null; + startDate?: string | null; + endDateTime?: Date | null; + endDate?: string | null; + isRecurring: boolean; + }, + rule: { + syncDescription: boolean; + syncLocation: boolean; + syncRecurring: boolean; + titlePrefix: string; + targetCalendarId: string; + }, + targetProvider: string +): Partial { + const allDay = !cachedEvent.startDateTime; + const start = allDay + ? { date: cachedEvent.startDate || '' } + : { dateTime: cachedEvent.startDateTime!.toISOString() }; + const end = allDay + ? { date: cachedEvent.endDate || '' } + : { dateTime: cachedEvent.endDateTime!.toISOString() }; + + return { + title: `${rule.titlePrefix}${cachedEvent.title}`, + description: rule.syncDescription ? (cachedEvent.description ?? undefined) : undefined, + location: rule.syncLocation ? (cachedEvent.location ?? undefined) : undefined, + start, + end, + allDay, + source: targetProvider as CalendarEvent['source'], + calendarId: rule.targetCalendarId, + calendarTitle: '', + }; +} + +// --------------------------------------------------------------------------- +// Core sync logic for a single rule +// --------------------------------------------------------------------------- + +async function syncOneDirection(params: { + userId: string; + rule: { + id: string; + sourceConnectionId: string; + sourceCalendarId: string; + targetConnectionId: string; + targetCalendarId: string; + syncDescription: boolean; + syncLocation: boolean; + syncRecurring: boolean; + titlePrefix: string; + }; + sourceConnection: CalendarConnection; + targetConnection: CalendarConnection; + weekStart: Date; + weekEnd: Date; +}): Promise<{ created: number; updated: number; deleted: number; skipped: number }> { + const { userId, rule, sourceConnection, targetConnection, weekStart, weekEnd } = params; + let created = 0, updated = 0, deleted = 0, skipped = 0; + + // Fetch cached source events for the sync window + const sourceEvents = await prisma.cachedCalendarEvent.findMany({ + where: { + userId, + connectionId: rule.sourceConnectionId, + calendarId: rule.sourceCalendarId, + OR: [ + { startDateTime: { gte: weekStart, lte: weekEnd } }, + { startDate: { gte: weekStart.toISOString().slice(0, 10), lte: weekEnd.toISOString().slice(0, 10) } }, + ], + }, + }); + + // Load existing mappings for this rule + const existingMappings = await prisma.calendarSyncMapping.findMany({ + where: { ruleId: rule.id }, + }); + const mappingBySource = new Map(existingMappings.map(m => [m.sourceEventId, m])); + + // Track which sourceEventIds we processed (to detect deletions) + const processedSourceIds = new Set(); + + for (const srcEvent of sourceEvents) { + // Skip recurring events if rule says so + if (srcEvent.isRecurring && !rule.syncRecurring) { + skipped++; + continue; + } + + // Anti-loop: skip events that were themselves created by a sync rule + if (await isAntiLoopTarget(userId, srcEvent.externalId, srcEvent.provider)) { + skipped++; + continue; + } + + processedSourceIds.add(srcEvent.externalId); + + const currentFingerprint = computeFingerprint({ + title: srcEvent.title, + startDateTime: srcEvent.startDateTime?.toISOString(), + startDate: srcEvent.startDate, + endDateTime: srcEvent.endDateTime?.toISOString(), + endDate: srcEvent.endDate, + description: srcEvent.description, + location: srcEvent.location, + }); + + const existingMapping = mappingBySource.get(srcEvent.externalId); + + if (!existingMapping) { + // New event — create on target + try { + const payload = buildPayload(srcEvent, rule, targetConnection.provider); + const createdEvent = await createCalendarEvent(targetConnection, rule.targetCalendarId, payload); + await prisma.calendarSyncMapping.create({ + data: { + ruleId: rule.id, + userId, + sourceEventId: srcEvent.externalId, + sourceProvider: srcEvent.provider, + targetEventId: createdEvent.id, + targetProvider: targetConnection.provider, + targetCalendarId: rule.targetCalendarId, + sourceFingerprint: currentFingerprint, + }, + }); + created++; + } catch (err) { + console.error(`[cross-sync] Failed to create event ${srcEvent.externalId}:`, err); + skipped++; + } + } else if (existingMapping.sourceFingerprint !== currentFingerprint) { + // Changed event — update on target + try { + const payload = buildPayload(srcEvent, rule, targetConnection.provider); + await updateCalendarEvent(targetConnection, rule.targetCalendarId, existingMapping.targetEventId, payload); + await prisma.calendarSyncMapping.update({ + where: { id: existingMapping.id }, + data: { sourceFingerprint: currentFingerprint, lastSyncedAt: new Date() }, + }); + updated++; + } catch (err) { + console.error(`[cross-sync] Failed to update event ${srcEvent.externalId}:`, err); + skipped++; + } + } + // else: unchanged, skip + } + + // Handle deletions: source events that have mappings but are no longer in cache + for (const mapping of existingMappings) { + if (!processedSourceIds.has(mapping.sourceEventId)) { + // Source event no longer exists in the synced window → delete target + try { + await deleteCalendarEvent(targetConnection, rule.targetCalendarId, mapping.targetEventId); + await prisma.calendarSyncMapping.delete({ where: { id: mapping.id } }); + deleted++; + } catch (err) { + console.error(`[cross-sync] Failed to delete target event ${mapping.targetEventId}:`, err); + // Remove stale mapping anyway so it doesn't keep retrying + await prisma.calendarSyncMapping.delete({ where: { id: mapping.id } }).catch(() => {}); + } + } + } + + return { created, updated, deleted, skipped }; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +export interface SyncRuleResult { + ruleId: string; + ruleName: string; + direction: string; + created: number; + updated: number; + deleted: number; + skipped: number; + error?: string; +} + +/** + * Run all enabled cross-sync rules for a user. + * Called after the regular calendar cache refresh so we always work + * with fresh data from the cache. + */ +export async function runSyncRules(userId: string): Promise { + const rules = await prisma.calendarSyncRule.findMany({ + where: { userId, enabled: true }, + }); + + if (rules.length === 0) return []; + + // Define a rolling 8-week window (past 1 week + next 7 weeks) + const now = new Date(); + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - 7); + weekStart.setHours(0, 0, 0, 0); + const weekEnd = new Date(now); + weekEnd.setDate(now.getDate() + 7 * 7); + weekEnd.setHours(23, 59, 59, 999); + + const results: SyncRuleResult[] = []; + + for (const rule of rules) { + const result: SyncRuleResult = { + ruleId: rule.id, + ruleName: rule.name, + direction: rule.direction, + created: 0, + updated: 0, + deleted: 0, + skipped: 0, + }; + + try { + const sourceConn = await loadConnection(rule.sourceConnectionId); + const targetConn = await loadConnection(rule.targetConnectionId); + if (!sourceConn || !targetConn) { + result.error = 'Connection not found'; + results.push(result); + continue; + } + + const ruleData = { + id: rule.id, + sourceConnectionId: rule.sourceConnectionId, + sourceCalendarId: rule.sourceCalendarId, + targetConnectionId: rule.targetConnectionId, + targetCalendarId: rule.targetCalendarId, + syncDescription: rule.syncDescription, + syncLocation: rule.syncLocation, + syncRecurring: rule.syncRecurring, + titlePrefix: rule.titlePrefix, + }; + + // Source → Target (always for both one-way and two-way) + const fwd = await syncOneDirection({ + userId, + rule: ruleData, + sourceConnection: sourceConn, + targetConnection: targetConn, + weekStart, + weekEnd, + }); + result.created += fwd.created; + result.updated += fwd.updated; + result.deleted += fwd.deleted; + result.skipped += fwd.skipped; + + // Target → Source (only for two-way) + if (rule.direction === 'two-way') { + const reverseRuleData = { + ...ruleData, + id: rule.id + '_reverse', // virtual ID to keep mappings separate; we create real mappings per direction + sourceConnectionId: rule.targetConnectionId, + sourceCalendarId: rule.targetCalendarId, + targetConnectionId: rule.sourceConnectionId, + targetCalendarId: rule.sourceCalendarId, + }; + + // For two-way we use a special mapping key (rule.id + ':reverse') + // We'll re-use the same rule.id but swap source/target in a clean way by + // just calling syncOneDirection again — mappings are keyed by [ruleId, sourceEventId] + // so 'source' for reverse is the original target calendar. + // NOTE: to avoid collision we store reverse mappings under ruleId+'_R' + const reverseMappingsExist = await prisma.calendarSyncMapping.findFirst({ + where: { ruleId: rule.id + '_R' }, + select: { id: true }, + }); + // We need a real ruleId to store under — we'll use a synthetic one-time id pattern + // by creating a lightweight virtual rule inline. To keep things simple we just + // pass the reverse as a separate call using a virtual rule id. + const bwd = await syncOneDirection({ + userId, + rule: { ...reverseRuleData, id: rule.id + '_R' }, + sourceConnection: targetConn, + targetConnection: sourceConn, + weekStart, + weekEnd, + }); + result.created += bwd.created; + result.updated += bwd.updated; + result.deleted += bwd.deleted; + result.skipped += bwd.skipped; + } + + // Update lastSyncedAt + await prisma.calendarSyncRule.update({ + where: { id: rule.id }, + data: { lastSyncedAt: new Date() }, + }); + } catch (err: any) { + result.error = err?.message || 'Unknown error'; + } + + results.push(result); + } + + return results; +}