feat: add cross-provider calendar sync tool

Users can now create sync rules to automatically sync events between
any two connected calendar providers (Google, Apple, Outlook, Synology).

- New Prisma models: CalendarSyncRule + CalendarSyncMapping
- Sync engine in src/lib/calendar-cross-sync.ts: fingerprint-based
  change detection, anti-loop protection, one-way and two-way support
- API routes: GET/POST rules, PATCH/DELETE rule by id, POST run
- CalendarSyncRulesPanel component with rule list, add/edit form,
  toggle enable/disable, Sync Now button with result summary
- New "Sync" tab in settings sidebar (between Connections and Account)
- Sync rules run automatically after each forced calendar cache refresh

v1.76.0
This commit is contained in:
mARTin 2026-03-29 10:58:07 +02:00
parent d8c30cdb1f
commit 620118e6d5
9 changed files with 1190 additions and 3 deletions

View File

@ -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": {

View File

@ -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

View File

@ -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<string, any> = {};
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 });
}

View File

@ -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 });
}

View File

@ -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 });
}
}

View File

@ -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,

View File

@ -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<string, string>;
}
const PROVIDER_LABELS: Record<string, string> = {
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<CalendarSyncRule[]>([]);
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<string | null>(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) => (
<label style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", display: "block", marginBottom: "4px" }}>
{s}
</label>
);
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 (
<div style={{ marginBottom: "12px" }}>
{label(pickerLabel)}
<select
value={value}
onChange={(e) => {
const [cid, calid] = e.target.value.split("::");
onChange(cid, calid);
}}
style={inputStyle}
>
<option value=""> Select calendar </option>
{calOptions.map((opt) => (
<option key={`${opt.connId}::${opt.calId}`} value={`${opt.connId}::${opt.calId}`}>
[{opt.connLabel}] {opt.label}
</option>
))}
</select>
</div>
);
};
if (loading) return <p style={{ opacity: 0.5, fontSize: "0.875rem" }}>Loading sync rules</p>;
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{/* Enable/Disable section header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: "12px", borderBottom: "1px solid var(--weekly-border, #e5e7eb)" }}>
<div>
<h3 style={{ fontSize: "1rem", fontWeight: 700, marginBottom: "2px" }}>
{t.calendarSyncTitle || "Calendar Sync"}
</h3>
<p style={{ fontSize: "0.8rem", color: "var(--weekly-text-light, #888)", margin: 0 }}>
{t.calendarSyncDesc || "Sync events between connected calendar providers."}
</p>
</div>
<button
onClick={runSync}
disabled={syncing || rules.filter((r) => r.enabled).length === 0}
style={{
...btnStyle(),
opacity: syncing || rules.filter((r) => r.enabled).length === 0 ? 0.4 : 1,
}}
title={t.syncNow || "Sync Now"}
>
<RefreshCcw size={15} style={syncing ? { animation: "spin 1s linear infinite" } : {}} />
{syncing ? (t.syncing || "Syncing…") : (t.syncNow || "Sync Now")}
</button>
</div>
{/* Status message */}
{msg && (
<div style={{
padding: "8px 12px", borderRadius: "7px", fontSize: "0.85rem",
background: msg.type === "success" ? "rgba(16,185,129,0.1)" : "rgba(239,68,68,0.1)",
color: msg.type === "success" ? "#059669" : "#dc2626",
border: `1px solid ${msg.type === "success" ? "#10b981" : "#ef4444"}`,
display: "flex", alignItems: "center", gap: "6px",
}}>
{msg.type === "success" ? <Check size={14} /> : <AlertCircle size={14} />}
{msg.text}
</div>
)}
{/* Sync results */}
{syncResults && (
<div style={{ padding: "10px 12px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-hover, #f9fafb)", fontSize: "0.8rem" }}>
<strong style={{ display: "block", marginBottom: "6px" }}>{t.syncResults || "Sync Results"}</strong>
{syncResults.length === 0
? <span style={{ opacity: 0.5 }}>{t.noRulesEnabled || "No enabled rules found."}</span>
: syncResults.map((r) => (
<div key={r.ruleId} style={{ marginBottom: "4px", display: "flex", gap: "8px", flexWrap: "wrap" }}>
<span style={{ fontWeight: 600 }}>{r.ruleName || "Rule"}</span>
{r.error
? <span style={{ color: "#dc2626" }}>Error: {r.error}</span>
: <>
{r.created > 0 && <span style={{ color: "#059669" }}>+{r.created}</span>}
{r.updated > 0 && <span style={{ color: "#0ea5e9" }}>~{r.updated}</span>}
{r.deleted > 0 && <span style={{ color: "#f59e0b" }}>{r.deleted}</span>}
{r.created === 0 && r.updated === 0 && r.deleted === 0 && <span style={{ opacity: 0.5 }}>up to date</span>}
</>
}
</div>
))
}
</div>
)}
{/* No connections warning */}
{connections.length < 2 && (
<div style={{ padding: "10px 12px", borderRadius: "8px", background: "rgba(245,158,11,0.08)", border: "1px solid #f59e0b", fontSize: "0.85rem", color: "#b45309" }}>
<AlertCircle size={14} style={{ display: "inline", marginRight: "6px" }} />
{t.syncNeedsTwo || "You need at least two connected calendars to create a sync rule."}
</div>
)}
{/* Rules list */}
{rules.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{rules.map((rule) => {
const srcConn = connFor(rule.sourceConnectionId);
const tgtConn = connFor(rule.targetConnectionId);
return (
<div key={rule.id} style={{
padding: "12px 14px", borderRadius: "10px",
border: "1px solid var(--weekly-border, #e5e7eb)",
background: "var(--weekly-bg, #fff)",
display: "flex", flexDirection: "column", gap: "6px",
}}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1, minWidth: 0 }}>
{/* Toggle */}
<button
onClick={() => toggleEnabled(rule)}
title={rule.enabled ? "Disable" : "Enable"}
style={{
width: "34px", height: "20px", borderRadius: "10px", border: "none",
background: rule.enabled ? "#22c55e" : "#d1d5db",
position: "relative", cursor: "pointer", flexShrink: 0, transition: "background 0.2s",
}}
>
<span style={{
position: "absolute", top: "3px",
left: rule.enabled ? "17px" : "3px",
width: "14px", height: "14px", borderRadius: "50%",
background: "#fff", transition: "left 0.2s",
}} />
</button>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: "0.875rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{rule.name || `${srcConn ? providerLabel(srcConn) : "?"}${tgtConn ? providerLabel(tgtConn) : "?"}`}
</div>
<div style={{ fontSize: "0.75rem", color: "var(--weekly-text-light, #888)", display: "flex", alignItems: "center", gap: "4px", marginTop: "2px" }}>
<span>{srcConn ? calendarLabel(srcConn, rule.sourceCalendarId) : rule.sourceCalendarId}</span>
{rule.direction === "two-way" ? <ArrowLeftRight size={11} /> : <ArrowRight size={11} />}
<span>{tgtConn ? calendarLabel(tgtConn, rule.targetCalendarId) : rule.targetCalendarId}</span>
</div>
</div>
</div>
<div style={{ display: "flex", gap: "4px", flexShrink: 0 }}>
<button onClick={() => openEditForm(rule)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--weekly-text-light, #888)", padding: "4px" }} title="Edit">
<Pencil size={14} />
</button>
<button onClick={() => deleteRule(rule.id)} style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "4px" }} title="Delete">
<Trash2 size={14} />
</button>
</div>
</div>
{rule.lastSyncedAt && (
<div style={{ fontSize: "0.7rem", color: "var(--weekly-text-light, #aaa)" }}>
Last synced: {new Date(rule.lastSyncedAt).toLocaleString()}
</div>
)}
</div>
);
})}
</div>
)}
{rules.length === 0 && !showForm && (
<p style={{ opacity: 0.5, fontSize: "0.875rem", textAlign: "center", padding: "16px 0" }}>
{t.noSyncRules || "No sync rules yet. Add one below."}
</p>
)}
{/* Add new rule button */}
{!showForm && connections.length >= 2 && (
<button onClick={openNewForm} style={{ ...btnStyle(), justifyContent: "center", padding: "10px 16px" }}>
<Plus size={15} />
{t.addSyncRule || "Add Sync Rule"}
</button>
)}
{/* Form */}
{showForm && (
<div style={{
padding: "16px", borderRadius: "12px",
border: "1px solid var(--weekly-border, #e5e7eb)",
background: "var(--weekly-hover, #f9fafb)",
display: "flex", flexDirection: "column", gap: "8px",
}}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "4px" }}>
<strong style={{ fontSize: "0.9rem" }}>{editingId ? (t.editSyncRule || "Edit Rule") : (t.newSyncRule || "New Sync Rule")}</strong>
<button onClick={closeForm} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--weekly-text-light, #888)" }}>
<X size={16} />
</button>
</div>
{/* Rule name */}
<div>
{label(t.syncRuleName || "Rule Name (optional)")}
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={t.syncRuleNamePlaceholder || "e.g. Work → Personal"}
style={inputStyle}
/>
</div>
{/* Direction */}
<div style={{ marginBottom: "4px" }}>
{label(t.syncDirection || "Direction")}
<div style={{ display: "flex", gap: "8px" }}>
{(["one-way", "two-way"] as const).map((d) => (
<button
key={d}
onClick={() => setForm({ ...form, direction: d })}
style={{
...btnStyle(form.direction === d),
flex: 1, justifyContent: "center",
}}
>
{d === "one-way" ? <><ArrowRight size={14} /> {t.oneWay || "One-way"}</> : <><ArrowLeftRight size={14} /> {t.twoWay || "Two-way"}</>}
</button>
))}
</div>
</div>
{/* Source calendar */}
<CalPicker
label={t.sourceCalendar || "Source Calendar"}
connId={form.sourceConnectionId}
calId={form.sourceCalendarId}
onChange={(connId, calId) => setForm({ ...form, sourceConnectionId: connId, sourceCalendarId: calId })}
/>
{/* Target calendar */}
<CalPicker
label={t.targetCalendar || "Target Calendar"}
connId={form.targetConnectionId}
calId={form.targetCalendarId}
onChange={(connId, calId) => setForm({ ...form, targetConnectionId: connId, targetCalendarId: calId })}
/>
{/* Title prefix */}
<div>
{label(t.titlePrefix || "Title Prefix (optional)")}
<input
type="text"
value={form.titlePrefix}
onChange={(e) => setForm({ ...form, titlePrefix: e.target.value })}
placeholder={t.titlePrefixPlaceholder || "e.g. [Work] "}
style={inputStyle}
/>
</div>
{/* Options checkboxes */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "4px" }}>
{([
{ 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 }) => (
<label key={key} style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.875rem", cursor: "pointer" }}>
<input
type="checkbox"
checked={form[key]}
onChange={(e) => setForm({ ...form, [key]: e.target.checked })}
style={{ width: "15px", height: "15px" }}
/>
{optLabel}
</label>
))}
</div>
{formError && (
<div style={{ fontSize: "0.8rem", color: "#dc2626", display: "flex", alignItems: "center", gap: "6px" }}>
<AlertCircle size={13} /> {formError}
</div>
)}
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
<button onClick={saveForm} disabled={saving} style={{ ...btnStyle(true), flex: 1, justifyContent: "center", opacity: saving ? 0.6 : 1 }}>
{saving ? (t.saving || "Saving…") : (editingId ? (t.updateRule || "Update Rule") : (t.createRule || "Create Rule"))}
</button>
<button onClick={closeForm} style={{ ...btnStyle(), flexShrink: 0 }}>
{t.cancel || "Cancel"}
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -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<string, any> = {
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<string, any> = {
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<void>;
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: <Settings size={18} />, label: t.general },
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
{ key: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" },
{ key: "account", icon: <User size={18} />, label: t.account },
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
@ -15240,6 +15294,11 @@ function SettingsSidebar({
)}
</div>
</div>
) : activeTab === "sync" ? (
<CalendarSyncRulesPanel
connections={connections}
t={t}
/>
) : activeTab === "about" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: "20px" }}

View File

@ -0,0 +1,402 @@
/**
* Calendar Cross-Sync Engine
*
* Syncs events between calendar providers according to user-defined rules.
* Supports one-way and two-way sync with fingerprint-based change detection
* and anti-loop protection.
*/
import { PrismaClient } from '@prisma/client';
import { createHash } from 'crypto';
import { CalendarEvent, CalendarConnection, createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './calendar-events';
const prisma = new PrismaClient();
// ---------------------------------------------------------------------------
// Fingerprinting
// ---------------------------------------------------------------------------
/**
* Compute a stable fingerprint for a calendar event so we can detect changes.
* Only title + start + end + description + location are included we skip
* provider-specific metadata that legitimately differs between copies.
*/
export function computeFingerprint(event: {
title: string;
startDateTime?: string | null;
startDate?: string | null;
endDateTime?: string | null;
endDate?: string | null;
description?: string | null;
location?: string | null;
}): string {
const raw = [
(event.title || '').trim(),
event.startDateTime || event.startDate || '',
event.endDateTime || event.endDate || '',
(event.description || '').trim(),
(event.location || '').trim(),
].join('|');
return createHash('sha1').update(raw).digest('hex');
}
// ---------------------------------------------------------------------------
// Anti-loop protection
// ---------------------------------------------------------------------------
/**
* Returns true if this externalId was written by our sync engine
* (i.e. it is a target event in some mapping). Prevents syncing events
* that were themselves created by a sync rule, which would cause a loop.
*/
async function isAntiLoopTarget(
userId: string,
externalId: string,
provider: string
): Promise<boolean> {
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<CalendarConnection | null> {
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<CalendarEvent> {
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<string>();
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<SyncRuleResult[]> {
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;
}