feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)

- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen)
- Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal)
- Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse
- Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:54:07 +02:00
parent cd1f8f6cda
commit 325c5ddbf2
31 changed files with 1969 additions and 11 deletions
@@ -151,11 +151,11 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)',
).map((c) => c.getAttribute('data-widget-type'));
}
it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => {
it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => {
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
expect(catalogTypes().at(-1)).toBe('proxmox');
expect(catalogTypes().at(-1)).toBe('reminder');
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
});
@@ -31,6 +31,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
'xframe',
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
'proxmox',
// Erinnerungen (quick-260929-if2)
'reminder',
];
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
@@ -66,7 +68,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
});
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
@@ -82,6 +84,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
});
let counted = 0;
@@ -91,7 +94,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
counted++;
}
}
expect(counted).toBe(40);
expect(counted).toBe(44);
});
});
@@ -107,7 +110,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
});
it('die zehn erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
it('die elf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
});
@@ -19,6 +19,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
* picture-frame: Bilderrahmen (quick-260921-pi9).
* xframe: Webseite als Rahmen (quick-260921-qd3).
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
* reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2).
*/
export type { WidgetType };
@@ -89,6 +90,12 @@ export const WIDGET_CONSTRAINTS: Record<
// zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte
// und Namen.
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
// quick-260929-if2: Liste plus Knopfzeile „Neue Erinnerung“. Im 48er-Raster
// sind 8 Spalten = rund 230 px die kleinste Breite, in der Titel, Zeitangabe
// und die umbrechenden Knoepfe noch benutzbar sind (wie Notiz und Favoriten);
// 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 =
// rund 340x270 px: drei bis vier Erinnerungen auf einmal.
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
};
/**
@@ -358,6 +365,27 @@ function ProxmoxIcon({ className }: { className?: string }) {
);
}
function ReminderIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
);
}
/**
* Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints.
@@ -455,6 +483,15 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
moduleSlug: WIDGET_MODULE_SLUGS.proxmox,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
reminder: {
type: 'reminder',
nameKey: 'reminder.name',
descriptionKey: 'reminder.description',
icon: ReminderIcon,
...WIDGET_CONSTRAINTS.reminder,
moduleSlug: WIDGET_MODULE_SLUGS.reminder,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
};
/**
@@ -0,0 +1,179 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, localInputsToIso } from '@/lib/reminder-time';
import { ReminderRequestError, createReminder } from '@/lib/reminders-api';
interface ReminderFormModalProps {
onClose: () => void;
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
onSaved: () => void;
}
const TITLE_MAX = 200;
const DESCRIPTION_MAX = 2000;
/**
* Formular „Neue Erinnerung“ (quick-260929-if2). Datum und Uhrzeit sind
* Ortszeit; erst hier wird daraus ein ISO-Zeitpunkt.
*
* Gerendert per `createPortal` in `document.body`: die Kachel liegt in einem
* `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr
* macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild
* `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`.
*
* ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft als ERSTES im
* Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
* Laden der Seite.
*/
export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) {
const t = useTranslations('widgets');
const initial = useRef(defaultNewReminderInputs(new Date())).current;
const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04).
requestBrowserPermissionOnce();
setError(null);
const trimmedTitle = title.trim();
if (trimmedTitle === '') {
setError(t('reminder.titleRequired'));
return;
}
const dueAt = localInputsToIso(date, time);
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) {
setError(t('reminder.pastError'));
return;
}
setSaving(true);
try {
await createReminder({ title: trimmedTitle, description: description.trim(), dueAt });
onSaved();
onClose();
} catch (err) {
setError(
err instanceof ReminderRequestError && err.status === 409
? t('reminder.limitReached')
: t('reminder.saveError'),
);
} finally {
setSaving(false);
}
};
if (typeof document === 'undefined') return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="reminder-form-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="reminder-form-title" className="mb-4 text-lg font-semibold text-foreground">
{t('reminder.formTitle')}
</h2>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="flex gap-3">
<div className="flex-1 space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium text-foreground">
{t('reminder.dateLabel')}
</label>
<input
id="reminder-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium text-foreground">
{t('reminder.timeLabel')}
</label>
<input
id="reminder-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="reminder-title" className="text-sm font-medium text-foreground">
{t('reminder.titleLabel')}
</label>
<input
id="reminder-title"
ref={titleRef}
type="text"
maxLength={TITLE_MAX}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label htmlFor="reminder-description" className="text-sm font-medium text-foreground">
{t('reminder.descriptionLabel')}
</label>
<textarea
id="reminder-description"
rows={3}
maxLength={DESCRIPTION_MAX}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="btn btn-secondary">
{t('reminder.cancel')}
</button>
<button type="submit" disabled={saving} className="btn btn-primary">
{t('reminder.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,147 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'de',
}));
vi.mock('@/lib/reminders-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminders-api')>('@/lib/reminders-api');
return {
REMINDERS_CHANGED_EVENT: actual.REMINDERS_CHANGED_EVENT,
ReminderRequestError: actual.ReminderRequestError,
listReminders: vi.fn(),
createReminder: vi.fn(),
};
});
import { createReminder, listReminders } from '@/lib/reminders-api';
import { ReminderWidget } from './reminder-widget';
const mockList = listReminders as ReturnType<typeof vi.fn>;
const mockCreate = createReminder as ReturnType<typeof vi.fn>;
function reminder(id: string, title: string, dueAt: string, description = '') {
return {
id,
title,
description,
dueAt,
emailEnabled: false,
createdAt: dueAt,
updatedAt: dueAt,
};
}
const props = { instanceId: 'w1', config: {}, isEditMode: false };
let requestPermission: ReturnType<typeof vi.fn>;
beforeEach(() => {
window.localStorage.clear();
mockList.mockReset();
mockCreate.mockReset();
requestPermission = vi.fn(async () => 'granted');
const ctor = vi.fn();
Object.assign(ctor, { permission: 'default', requestPermission });
vi.stubGlobal('Notification', ctor);
});
afterEach(() => {
vi.unstubAllGlobals();
});
async function openForm() {
fireEvent.click(await screen.findByText('reminder.add'));
}
function fillAndSubmit(title: string) {
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } });
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
fireEvent.click(screen.getByText('reminder.save'));
}
describe('ReminderWidget', () => {
it('listet die Erinnerungen nach Faelligkeit sortiert', async () => {
mockList.mockResolvedValue([
reminder('b', 'Spaeter', '2099-01-02T10:00:00.000Z'),
reminder('a', 'Frueh', '2099-01-01T10:00:00.000Z', 'Beschreibung'),
]);
render(<ReminderWidget {...props} />);
const rows = await screen.findAllByTestId('reminder-row');
expect(rows[0].textContent).toContain('Frueh');
expect(rows[0].textContent).toContain('Beschreibung');
expect(rows[1].textContent).toContain('Spaeter');
});
it('zeigt den Leerzustand', async () => {
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
expect(await screen.findByText('reminder.empty')).toBeTruthy();
});
it('das Rendern fragt keine Benachrichtigungs-Erlaubnis (D-04)', async () => {
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
await screen.findByText('reminder.empty');
expect(requestPermission).not.toHaveBeenCalled();
});
it('Anlegen sendet die aus den Ortszeit-Eingaben gebildete ISO-Zeit', async () => {
mockList.mockResolvedValue([]);
mockCreate.mockResolvedValue(reminder('n', 'Zahnarzt', '2099-03-04T13:30:00.000Z'));
render(<ReminderWidget {...props} />);
await openForm();
fillAndSubmit('Zahnarzt');
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
const arg = mockCreate.mock.calls[0][0];
expect(arg.title).toBe('Zahnarzt');
expect(arg.dueAt).toBe(new Date('2099-03-04T14:30').toISOString());
});
it('die erste Anlage fragt die Erlaubnis einmal, die zweite nicht mehr', async () => {
mockList.mockResolvedValue([]);
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
render(<ReminderWidget {...props} />);
await openForm();
fillAndSubmit('Erste');
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
expect(requestPermission).toHaveBeenCalledTimes(1);
// Dialog ist nach dem Speichern zu; neu oeffnen
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
(Notification as unknown as { permission: string }).permission = 'default';
await openForm();
fillAndSubmit('Zweite');
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(2));
expect(requestPermission).toHaveBeenCalledTimes(1);
});
it('lehnt eine Vergangenheit im Formular ab, ohne zu senden', async () => {
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
await openForm();
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } });
fireEvent.click(screen.getByText('reminder.save'));
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
expect(mockCreate).not.toHaveBeenCalled();
});
it('meldet REMINDERS_CHANGED nach dem Anlegen', async () => {
mockList.mockResolvedValue([]);
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
const handler = vi.fn();
window.addEventListener('tessera:reminders-changed', handler);
render(<ReminderWidget {...props} />);
await openForm();
await act(async () => {
fillAndSubmit('Meldung');
});
await waitFor(() => expect(handler).toHaveBeenCalled());
window.removeEventListener('tessera:reminders-changed', handler);
});
});
@@ -0,0 +1,108 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
REMINDERS_CHANGED_EVENT,
type Reminder,
listReminders,
} 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;
/**
* 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.
*/
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 refetch = useCallback(async () => {
try {
setReminders(await listReminders());
setLoadError(false);
} catch {
setLoadError(true);
}
}, []);
useEffect(() => {
void refetch();
const timer = setInterval(() => void refetch(), REFRESH_MS);
const onChanged = () => void refetch();
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
return () => {
clearInterval(timer);
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 formatter = useMemo(
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
[locale],
);
const handleSaved = () => {
void refetch();
window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT));
};
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>
)}
{!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>
))}
</div>
<button
type="button"
onClick={() => setModalOpen(true)}
className="btn btn-secondary shrink-0 self-start"
>
{t('reminder.add')}
</button>
{modalOpen && (
<ReminderFormModal onClose={() => setModalOpen(false)} onSaved={handleSaved} />
)}
</div>
);
}
@@ -67,6 +67,12 @@ const PATHS: Record<string, ReactNode> = {
<path d="M6 6h.01M6 18h.01" />
</>
),
reminder: (
<>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</>
),
};
export function WidgetIcon({
@@ -8,12 +8,13 @@ import { WidgetIcon } from './widget-icon';
/**
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2). Notiz,
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2; Erinnerungen seit
* quick-260929-if2). Notiz,
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
* ein Foto braucht keine Ueberschrift.
*/
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch', 'reminder']);
/**
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den