feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen
- API: Aendern (409 wenn faellig), Spaeter erinnern (409 wenn nicht faellig, setzt E-Mail-Spur zurueck), Loeschen; fremde Kennungen 404 - Kachel: faellige Zeilen hervorgehoben mit Erledigt und drei Spaeter-Optionen, kuenftige mit Bearbeiten und Loeschen - Zeit-Hilfen (morgen zur gleichen Uhrzeit), Zugriffsklassifikation nachgemessen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,31 +2,49 @@
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { browserPermissionState } from '@/lib/reminder-notify';
|
||||
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
||||
import {
|
||||
REMINDERS_CHANGED_EVENT,
|
||||
type Reminder,
|
||||
ReminderRequestError,
|
||||
deleteReminder,
|
||||
listReminders,
|
||||
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, dazu „Neue Erinnerung“. 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.
|
||||
* 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 [modalOpen, setModalOpen] = 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);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
@@ -39,11 +57,13 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
const timer = setInterval(() => void refetch(), REFRESH_MS);
|
||||
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(timer);
|
||||
clearInterval(refresh);
|
||||
clearInterval(tick);
|
||||
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||
};
|
||||
}, [refetch]);
|
||||
@@ -56,16 +76,61 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
[reminders],
|
||||
);
|
||||
|
||||
const formatter = useMemo(
|
||||
const dateTime = useMemo(
|
||||
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
|
||||
[locale],
|
||||
);
|
||||
const timeOnly = useMemo(() => new Intl.DateTimeFormat(locale, { timeStyle: 'short' }), [locale]);
|
||||
|
||||
const handleSaved = () => {
|
||||
/** 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');
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -74,34 +139,179 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
{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) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid="reminder-row"
|
||||
className="rounded-md border border-border bg-background/60 px-3 py-2"
|
||||
>
|
||||
<p className="truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatter.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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{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 justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
{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' &&
|
||||
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
|
||||
</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={() => setModalOpen(true)}
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setModal({ reminder: null });
|
||||
}}
|
||||
className="btn btn-secondary shrink-0 self-start"
|
||||
>
|
||||
{t('reminder.add')}
|
||||
</button>
|
||||
{modalOpen && (
|
||||
<ReminderFormModal onClose={() => setModalOpen(false)} onSaved={handleSaved} />
|
||||
{modal && (
|
||||
<ReminderFormModal
|
||||
reminder={modal.reminder}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={afterMutation}
|
||||
onStale={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user