My-Weekly-ToDo-List/src/components/CalendarSyncRulesPanel.tsx
mARTin 620118e6d5 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
2026-03-29 10:58:07 +02:00

537 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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