Files
tessera-ctl/apps/web/src/components/dashboard/widgets/reminder-widget.tsx
T
schalli c2e4467dd8 fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00

374 lines
14 KiB
TypeScript

'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { browserPermissionState } from '@/lib/reminder-notify';
import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import {
deleteReminder,
getReminderEmailStatus,
listReminders,
REMINDERS_CHANGED_EVENT,
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
snoozeReminder,
} from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry';
import { ReminderFormModal } from './reminder-form-modal';
/** Abstand der Auffrischung beim Server. */
const REFRESH_MS = 60_000;
/** Abstand, in dem die Kachel neu entscheidet, welche Erinnerung faellig ist. */
const NOW_TICK_MS = 10_000;
const SNOOZE_PRESETS: SnoozePreset[] = ['10m', '1h', 'tomorrow'];
/**
* Kachel „Erinnerungen“ (quick-260929-if2): die persoenlichen Erinnerungen des
* Benutzers, die naechste Faelligkeit zuerst. Die Benachrichtigung selbst macht
* der globale `ReminderNotifier` im Portal-Rahmen — die Kachel muss dafuer
* nicht sichtbar sein. Nach jeder Aenderung laedt sie neu und meldet
* `REMINDERS_CHANGED_EVENT`, damit der Melder eine neue Faelligkeit sofort kennt.
*
* Faellige Zeilen (D-03) bleiben hervorgehoben stehen und bieten „Erledigt“
* (loescht die Zeile, E-02) und „Spaeter erinnern“ (+10 Minuten, +1 Stunde,
* morgen zur gleichen Uhrzeit). Noch nicht faellige lassen sich bearbeiten und
* loeschen. Ob eine Zeile faellig ist, entscheidet ein 10-s-Takt lokal, ohne
* Neuladen.
*/
export function ReminderWidget(_props: WidgetProps) {
const t = useTranslations('widgets');
const locale = useLocale();
const [reminders, setReminders] = useState<Reminder[] | null>(null);
const [loadError, setLoadError] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [modal, setModal] = useState<{ reminder: Reminder | null } | null>(null);
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Einmal je Einhaengen der Kachel geladen; unbekannt/fehlgeschlagen = E-Mail nicht moeglich.
const [emailStatus, setEmailStatus] = useState<ReminderEmailStatus | null>(null);
const refetch = useCallback(async () => {
try {
setReminders(await listReminders());
setLoadError(false);
} catch {
setLoadError(true);
}
}, []);
useEffect(() => {
let cancelled = false;
getReminderEmailStatus()
.then((status) => {
if (!cancelled) setEmailStatus(status);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void refetch();
const refresh = setInterval(() => void refetch(), REFRESH_MS);
const tick = setInterval(() => setNow(Date.now()), NOW_TICK_MS);
const onChanged = () => void refetch();
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
return () => {
clearInterval(refresh);
clearInterval(tick);
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
};
}, [refetch]);
const sorted = useMemo(
() =>
[...(reminders ?? [])].sort(
(a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime(),
),
[reminders],
);
const dateTime = useMemo(
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
[locale],
);
const timeOnly = useMemo(() => new Intl.DateTimeFormat(locale, { timeStyle: 'short' }), [locale]);
/** Nach jeder erfolgreichen Aenderung: neu laden und den Melder informieren. */
const afterMutation = () => {
void refetch();
window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT));
};
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
const handleFailure = (err: unknown, conflictKey: string) => {
setNotice(
err instanceof ReminderRequestError && err.status === 409
? t(conflictKey)
: t('reminder.saveError'),
);
void refetch();
};
const markDone = async (id: string) => {
setNotice(null);
try {
await deleteReminder(id);
afterMutation();
} catch (err) {
handleFailure(err, 'reminder.saveError');
}
};
const snooze = async (r: Reminder, preset: SnoozePreset) => {
setNotice(null);
setSnoozeOpenId(null);
try {
const target = snoozeTarget(preset, new Date(r.dueAt), new Date());
await snoozeReminder(r.id, target.toISOString());
afterMutation();
} catch (err) {
handleFailure(err, 'reminder.notDue');
}
};
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
const tomorrowLabel = (r: Reminder) => {
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
const time = timeOnly.format(target);
return isSameLocalDay(target, new Date(now))
? t('reminder.snoozeToday', { time })
: t('reminder.snoozeTomorrow', { time });
};
const remove = async (id: string) => {
setNotice(null);
setConfirmDeleteId(null);
try {
await deleteReminder(id);
afterMutation();
} catch (err) {
handleFailure(err, 'reminder.saveError');
}
};
const blocked = browserPermissionState() === 'denied';
return (
<div className="flex h-full flex-col gap-2 p-3" data-testid="reminder-widget">
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
{loadError && (
<p role="alert" className="text-sm text-destructive">
{t('reminder.loadError')}
</p>
)}
{notice && (
<p role="alert" className="text-sm text-destructive">
{notice}
</p>
)}
{!loadError && reminders !== null && sorted.length === 0 && (
<p className="text-sm text-muted-foreground">{t('reminder.empty')}</p>
)}
{sorted.map((r) => {
const due = new Date(r.dueAt).getTime() <= now;
return (
<div
key={r.id}
data-testid="reminder-row"
data-due={due ? 'true' : 'false'}
className={
due
? 'rounded-md border border-status-warn bg-status-warn/10 px-3 py-2'
: 'rounded-md border border-border bg-background/60 px-3 py-2'
}
>
<div className="flex items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{r.title}
</p>
{r.emailEnabled && (
<span
className="shrink-0 text-muted-foreground"
role="img"
aria-label={t('reminder.emailOn')}
title={t('reminder.emailOn')}
data-testid="reminder-email-icon"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
</span>
)}
{due && (
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
{t('reminder.due')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{dateTime.format(new Date(r.dueAt))}</p>
{r.description !== '' && (
<p className="mt-1 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
{r.description}
</p>
)}
{due ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void markDone(r.id)}
className="btn btn-primary"
>
{t('reminder.done')}
</button>
<button
type="button"
onClick={() => setSnoozeOpenId(snoozeOpenId === r.id ? null : r.id)}
aria-expanded={snoozeOpenId === r.id}
className="btn btn-secondary"
>
{t('reminder.snooze')}
</button>
{snoozeOpenId === r.id && (
<div className="flex w-full flex-wrap gap-2">
{SNOOZE_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => void snooze(r, preset)}
className="btn btn-subtle border-border"
>
{preset === '10m' && t('reminder.snooze10m')}
{preset === '1h' && t('reminder.snooze1h')}
{preset === 'tomorrow' && tomorrowLabel(r)}
</button>
))}
</div>
)}
</div>
) : (
<div className="mt-2 flex flex-wrap items-center gap-2">
{confirmDeleteId === r.id ? (
<>
<span className="text-xs text-foreground">{t('reminder.deleteConfirm')}</span>
<button
type="button"
onClick={() => void remove(r.id)}
className="btn btn-secondary"
>
{t('reminder.yes')}
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
className="btn btn-secondary"
>
{t('reminder.no')}
</button>
</>
) : (
<>
<button
type="button"
onClick={() => {
setNotice(null);
setModal({ reminder: r });
}}
className="btn btn-subtle btn-icon"
aria-label={t('reminder.edit')}
title={t('reminder.edit')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(r.id)}
className="btn btn-subtle btn-icon"
aria-label={t('reminder.delete')}
title={t('reminder.delete')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
</svg>
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
{blocked && (
<p className="text-xs text-muted-foreground" data-testid="reminder-permission-hint">
{t('reminder.permissionDenied')}
</p>
)}
<button
type="button"
onClick={() => {
setNotice(null);
setModal({ reminder: null });
}}
className="btn btn-secondary shrink-0 self-start"
>
{t('reminder.add')}
</button>
{modal && (
<ReminderFormModal
reminder={modal.reminder}
emailStatus={emailStatus}
onClose={() => setModal(null)}
onSaved={afterMutation}
onStale={() => void refetch()}
/>
)}
</div>
);
}