c2e4467dd8
- 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>
374 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|