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
+2
View File
@@ -20,6 +20,7 @@ import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
@@ -39,6 +40,7 @@ registerWidget('favorites', FavoritesWidget);
registerWidget('picture-frame', PictureFrameWidget);
registerWidget('xframe', XframeWidget);
registerWidget('proxmox', ProxmoxWidget);
registerWidget('reminder', ReminderWidget);
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
interface ActiveModule {
@@ -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
@@ -6,6 +6,7 @@ import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
export function AppShell({ children }: { children: React.ReactNode }) {
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
@@ -41,6 +42,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
<ReleaseNoticeHost />
{/* Erinnerungen (quick-260929-if2): bewusst GLOBAL im Rahmen, damit die
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
<ReminderNotifier />
</div>
);
}
@@ -0,0 +1,139 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) =>
values?.title ? `${key}:${values.title}` : 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(),
};
});
vi.mock('@/lib/reminder-notify', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
});
import { showReminderNotification } from '@/lib/reminder-notify';
import { ReminderRequestError, listReminders } from '@/lib/reminders-api';
import { ReminderNotifier } from './reminder-notifier';
const mockList = listReminders as ReturnType<typeof vi.fn>;
const mockShow = showReminderNotification as ReturnType<typeof vi.fn>;
const NOW = new Date('2026-09-29T12:00:00.000Z');
function reminder(over: Partial<{ id: string; dueAt: string; description: string }> = {}) {
return {
id: 'r1',
title: 'Zahnarzt',
description: '',
dueAt: new Date(NOW.getTime() + 30_000).toISOString(),
emailEnabled: false,
createdAt: NOW.toISOString(),
updatedAt: NOW.toISOString(),
...over,
};
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
window.localStorage.clear();
mockList.mockReset();
mockShow.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
describe('ReminderNotifier', () => {
it('meldet eine faellige Erinnerung genau einmal ueber mehrere Takte', async () => {
mockList.mockResolvedValue([reminder()]);
render(<ReminderNotifier />);
await advance(0);
expect(mockShow).not.toHaveBeenCalled(); // noch nicht faellig (D-02)
await advance(40_000); // ueber dueAt (+30 s) hinweg
await advance(30_000);
expect(mockShow).toHaveBeenCalledTimes(1);
expect(mockShow.mock.calls[0][0]).toMatchObject({
title: 'reminder.notificationTitle:Zahnarzt',
tag: `reminder-r1-${reminder().dueAt}`,
});
});
it('meldet nichts, solange nichts faellig ist', async () => {
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]);
render(<ReminderNotifier />);
await advance(30_000);
expect(mockShow).not.toHaveBeenCalled();
});
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }),
]);
render(<ReminderNotifier />);
await advance(0);
expect(mockShow.mock.calls[0][0].body).toBe('x'.repeat(200));
});
it('meldet nach geaendertem dueAt (Verschieben) erneut', async () => {
const first = reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString() });
mockList.mockResolvedValue([first]);
render(<ReminderNotifier />);
await advance(0);
expect(mockShow).toHaveBeenCalledTimes(1);
// Verschoben auf +10 s (in der Zukunft), dann faellig
mockList.mockResolvedValue([{ ...first, dueAt: new Date(Date.now() + 10_000).toISOString() }]);
await act(async () => {
window.dispatchEvent(new Event('tessera:reminders-changed'));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockShow).toHaveBeenCalledTimes(1);
await advance(20_000);
expect(mockShow).toHaveBeenCalledTimes(2);
});
it('das Aenderungsereignis laedt die Liste neu', async () => {
mockList.mockResolvedValue([]);
render(<ReminderNotifier />);
await advance(0);
const before = mockList.mock.calls.length;
await act(async () => {
window.dispatchEvent(new Event('tessera:reminders-changed'));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockList.mock.calls.length).toBe(before + 1);
});
it('pausiert bei 401 bis zum naechsten Fokus', async () => {
mockList.mockRejectedValue(new ReminderRequestError(401));
render(<ReminderNotifier />);
await advance(0);
const after401 = mockList.mock.calls.length;
await advance(180_000);
expect(mockList.mock.calls.length).toBe(after401);
mockList.mockResolvedValue([]);
await act(async () => {
window.dispatchEvent(new Event('focus'));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockList.mock.calls.length).toBe(after401 + 1);
});
});
@@ -0,0 +1,112 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef } from 'react';
import {
claimNotification,
remindersToNotify,
showReminderNotification,
withNotifyLock,
} from '@/lib/reminder-notify';
import {
REMINDERS_CHANGED_EVENT,
type Reminder,
ReminderRequestError,
listReminders,
} from '@/lib/reminders-api';
/** Abstand der Abfrage beim Server. */
const POLL_MS = 60_000;
/** Abstand der lokalen Faelligkeitspruefung gegen die geladene Liste (ohne Netz). */
const TICK_MS = 10_000;
const BODY_MAX = 200;
/**
* Globaler Melder fuer Erinnerungen (quick-260929-if2). Er haengt im
* Portal-Rahmen (`AppShell`), NICHT in der Kachel: die Benachrichtigung muss
* auf jeder Portalseite ausloesen, auch wenn das Dashboard gerade nicht
* sichtbar ist. Rendert nichts.
*
* Ablauf: die Liste wird beim Einhaengen, alle 60 s, nach einer Aenderung
* (`REMINDERS_CHANGED_EVENT`), bei Rueckkehr in den Vordergrund und bei
* Fenster-Fokus geladen. Ein lokaler 10-s-Takt prueft die geladene Liste auf
* Faelligkeit und meldet jede (id, dueAt) hoechstens einmal je Client —
* mehrere Tabs teilen die lokale Merkliste und eine Web-Sperre (E-01, D-02).
* Bei 401 (abgemeldet) ruht die Abfrage bis zum naechsten Fokus.
*/
export function ReminderNotifier() {
const t = useTranslations('widgets');
const locale = useLocale();
const remindersRef = useRef<Reminder[]>([]);
const pausedRef = useRef(false);
// Uebersetzung und Sprache ueber Refs, damit die Effekte unten nicht bei
// jedem Render neu aufgebaut werden.
const textRef = useRef({ t, locale });
textRef.current = { t, locale };
const notifyDue = useCallback(async () => {
if (pausedRef.current) return;
const now = Date.now();
const due = remindersToNotify(remindersRef.current, now);
if (due.length === 0) return;
await withNotifyLock(async () => {
for (const r of due) {
if (!claimNotification(`${r.id}|${r.dueAt}`, Date.now())) continue;
const { t: tr, locale: loc } = textRef.current;
const body =
r.description.trim() !== ''
? r.description.trim().slice(0, BODY_MAX)
: new Intl.DateTimeFormat(loc, { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(r.dueAt),
);
await showReminderNotification({
title: tr('reminder.notificationTitle', { title: r.title }),
body,
tag: `reminder-${r.id}-${r.dueAt}`,
});
}
});
}, []);
const load = useCallback(async () => {
if (pausedRef.current) return;
try {
remindersRef.current = await listReminders();
} catch (err) {
if (err instanceof ReminderRequestError && err.status === 401) {
pausedRef.current = true;
}
// Andere Fehler: still ignorieren, der naechste Takt versucht es erneut.
return;
}
await notifyDue();
}, [notifyDue]);
useEffect(() => {
void load();
const poll = setInterval(() => void load(), POLL_MS);
const tick = setInterval(() => void notifyDue(), TICK_MS);
const resume = () => {
pausedRef.current = false;
void load();
};
const onVisible = () => {
if (document.visibilityState === 'visible') resume();
};
const onChanged = () => void load();
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', resume);
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
return () => {
clearInterval(poll);
clearInterval(tick);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', resume);
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
};
}, [load, notifyDue]);
return null;
}
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
browserPermissionState,
claimNotification,
isTauriWebview,
remindersToNotify,
requestBrowserPermissionOnce,
showReminderNotification,
withNotifyLock,
} from './reminder-notify';
import type { Reminder } from './reminders-api';
const NOW = Date.parse('2026-09-29T12:00:00.000Z');
function reminder(id: string, dueAt: string): Reminder {
return {
id,
title: id,
description: '',
dueAt,
emailEnabled: false,
createdAt: dueAt,
updatedAt: dueAt,
};
}
function installNotification(permission: NotificationPermission) {
const ctor = vi.fn();
const requestPermission = vi.fn(async () => 'granted' as NotificationPermission);
Object.assign(ctor, { permission, requestPermission });
vi.stubGlobal('Notification', ctor);
return { ctor, requestPermission };
}
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
vi.restoreAllMocks();
});
function installTauri() {
const invoke = vi.fn(async () => undefined);
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = { invoke };
return invoke;
}
describe('claimNotification', () => {
it('gibt beim ersten Mal true, danach false (gleicher Schluessel)', () => {
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 1000)).toBe(false);
});
it('nach einem Verschieben (neues dueAt) ist es ein neuer Schluessel', () => {
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
});
it('entfernt Eintraege aelter als 7 Tage', () => {
claimNotification('alt', NOW - 8 * 24 * 3600_000);
claimNotification('neu', NOW);
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
expect(Object.keys(stored)).toEqual(['neu']);
});
});
describe('remindersToNotify', () => {
it('nimmt nur Faellige der letzten 24 Stunden (nie vorher, D-02; E-03)', () => {
const list = [
reminder('zukunft', new Date(NOW + 1000).toISOString()),
reminder('genau', new Date(NOW).toISOString()),
reminder('vor-1h', new Date(NOW - 3600_000).toISOString()),
reminder('vor-25h', new Date(NOW - 25 * 3600_000).toISOString()),
];
expect(remindersToNotify(list, NOW).map((r) => r.id)).toEqual(['genau', 'vor-1h']);
});
});
describe('showReminderNotification', () => {
it('in Tauri: invoke plugin:notification|notify mit genau diesem Koerper', async () => {
const invoke = installTauri();
expect(isTauriWebview()).toBe(true);
await showReminderNotification({ title: 'T', body: 'B', tag: 'x' });
expect(invoke).toHaveBeenCalledWith('plugin:notification|notify', {
options: { title: 'T', body: 'B' },
});
});
it('in Tauri: ein Fehler des Plugins wird nur als Warnung ausgegeben', async () => {
const invoke = installTauri();
invoke.mockRejectedValue(new Error('nope'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
});
it('im Browser: nur mit Erlaubnis granted', async () => {
const { ctor } = installNotification('granted');
await showReminderNotification({ title: 'T', body: 'B', tag: 'tag1' });
expect(ctor).toHaveBeenCalledWith('T', { body: 'B', tag: 'tag1' });
});
it.each(['default', 'denied'] as const)('im Browser: bei %s keine Meldung', async (p) => {
const { ctor } = installNotification(p);
await showReminderNotification({ title: 'T', body: 'B', tag: 't' });
expect(ctor).not.toHaveBeenCalled();
});
});
describe('requestBrowserPermissionOnce', () => {
it('fragt hoechstens einmal (D-04)', () => {
const { requestPermission } = installNotification('default');
requestBrowserPermissionOnce();
requestBrowserPermissionOnce();
expect(requestPermission).toHaveBeenCalledTimes(1);
});
it('fragt nie in Tauri', () => {
installTauri();
const { requestPermission } = installNotification('default');
requestBrowserPermissionOnce();
expect(requestPermission).not.toHaveBeenCalled();
});
it('fragt nicht, wenn die Entscheidung schon getroffen ist', () => {
const { requestPermission } = installNotification('denied');
requestBrowserPermissionOnce();
expect(requestPermission).not.toHaveBeenCalled();
});
it('ohne Web-Benachrichtigungen passiert nichts', () => {
vi.stubGlobal('Notification', undefined);
expect(() => requestBrowserPermissionOnce()).not.toThrow();
expect(browserPermissionState()).toBe('unsupported');
});
});
describe('browserPermissionState', () => {
it('meldet desktop in Tauri, sonst die Browser-Erlaubnis', () => {
installNotification('denied');
expect(browserPermissionState()).toBe('denied');
installTauri();
expect(browserPermissionState()).toBe('desktop');
});
});
describe('withNotifyLock', () => {
it('ruft fn direkt, wenn es keine Web-Sperren gibt', async () => {
const fn = vi.fn(() => 42);
await expect(withNotifyLock(fn)).resolves.toBe(42);
});
it('nutzt navigator.locks, wenn vorhanden', async () => {
const request = vi.fn(async (_name: string, cb: () => unknown) => cb());
vi.stubGlobal('navigator', { ...navigator, locks: { request } });
await expect(withNotifyLock(() => 'x')).resolves.toBe('x');
expect(request).toHaveBeenCalledWith('tessera-reminder-notify', expect.any(Function));
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* Benachrichtigungen fuer Erinnerungen (quick-260929-if2) — reine Funktionen
* ohne React, damit Melder und Kachel sie gemeinsam nutzen und testen koennen.
*
* Zwei Wege, je nach Umgebung:
* - Desktop-App (Tauri-Webview): der Aufruf geht DIREKT an das Benachrichtigungs-
* Plugin (`plugin:notification|notify`). Die Rust-Seite erteilt dafuer zur
* Laufzeit genau diese eine Berechtigung fuer die gespeicherte Serveradresse
* (E-01, `grant_server_notifications` in apps/desktop/src-tauri/src/lib.rs).
* - Browser: die Web-Benachrichtigung (`new Notification(...)`), nur mit
* erteilter Erlaubnis.
*
* WARUM DER TAURI-WEG NIE `Notification.permission` LIEST (E-01): das Plugin
* ersetzt `window.Notification` in der Webview durch einen Ersatz, der
* denselben IPC-Aufruf macht. Dieser Ersatz meldet unter Windows bei JEDEM
* Seitenaufruf `permission = "denied"`, bis einmal `requestPermission()`
* gelaufen ist. Wer darauf hoeren wuerde, unterdrueckte im Desktop-Client
* jede Meldung. Deshalb ruft der Tauri-Zweig `invoke` direkt und fragt keine
* Erlaubnis ab.
*/
import type { Reminder } from './reminders-api';
const PERMISSION_ASKED_KEY = 'tessera.reminders.permissionAsked';
const NOTIFIED_KEY = 'tessera.reminders.notified';
const LOCK_NAME = 'tessera-reminder-notify';
/** E-03: so lange nach der Faelligkeit wird noch nachgemeldet (ein Client, der spaet oeffnet). */
export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export type BrowserPermissionState =
| 'desktop'
| 'granted'
| 'default'
| 'denied'
| 'unsupported';
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
/** Die `invoke`-Funktion der Tauri-Webview, sonst `null` (Browser). */
function tauriInvoke(): TauriInvoke | null {
if (typeof window === 'undefined') return null;
const internals: unknown = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
if (typeof internals !== 'object' || internals === null) return null;
const invoke: unknown = (internals as Record<string, unknown>).invoke;
return typeof invoke === 'function' ? (invoke as TauriInvoke) : null;
}
/** `true` in der Desktop-App (Tauri-Webview), `false` im normalen Browser. */
export function isTauriWebview(): boolean {
return tauriInvoke() !== null;
}
function hasWebNotifications(): boolean {
return typeof Notification !== 'undefined';
}
/** Zustand der Benachrichtigungs-Erlaubnis; in der Desktop-App immer `desktop`. */
export function browserPermissionState(): BrowserPermissionState {
if (isTauriWebview()) return 'desktop';
if (!hasWebNotifications()) return 'unsupported';
return Notification.permission;
}
function readFlag(key: string): boolean {
try {
return window.localStorage.getItem(key) !== null;
} catch {
return false;
}
}
/**
* Fragt die Browser-Erlaubnis HOECHSTENS EINMAL (D-04). Aufzurufen aus einer
* Nutzerhandlung (erstes Anlegen im Widget), nie beim Laden der Seite. Nichts
* passiert in der Desktop-App, ohne Web-Benachrichtigungen, bei bereits
* getroffener Entscheidung oder wenn die Frage schon einmal gestellt wurde.
*/
export function requestBrowserPermissionOnce(): void {
if (isTauriWebview() || !hasWebNotifications()) return;
if (Notification.permission !== 'default') return;
if (readFlag(PERMISSION_ASKED_KEY)) return;
try {
window.localStorage.setItem(PERMISSION_ASKED_KEY, '1');
} catch {
/* ohne Speicher lieber fragen als nie */
}
try {
void Notification.requestPermission();
} catch {
/* aeltere Browser: kein Versprechen, keine Folge */
}
}
export interface ReminderNotification {
title: string;
body: string;
tag: string;
}
/** Zeigt eine Benachrichtigung — Desktop ueber das Plugin, Browser nur mit Erlaubnis. */
export async function showReminderNotification(n: ReminderNotification): Promise<void> {
const invoke = tauriInvoke();
if (invoke) {
try {
await invoke('plugin:notification|notify', { options: { title: n.title, body: n.body } });
} catch (err) {
console.warn('[reminders] Desktop-Benachrichtigung fehlgeschlagen', err);
}
return;
}
if (!hasWebNotifications() || Notification.permission !== 'granted') return;
try {
new Notification(n.title, { body: n.body, tag: n.tag });
} catch (err) {
console.warn('[reminders] Browser-Benachrichtigung fehlgeschlagen', err);
}
}
function readNotified(): Record<string, number> {
try {
const raw = window.localStorage.getItem(NOTIFIED_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return {};
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'number') out[k] = v;
}
return out;
} catch {
return {};
}
}
/**
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
* Tabs desselben Browsers hinweg (gemeinsamer localStorage). Nach einem
* Verschieben aendert sich `dueAt` und damit der Schluessel. Eintraege
* aelter als 7 Tage werden dabei entfernt.
*/
export function claimNotification(key: string, nowMs: number): boolean {
const record = readNotified();
for (const [k, at] of Object.entries(record)) {
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
}
const first = !(key in record);
if (first) record[key] = nowMs;
try {
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
} catch {
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */
}
return first;
}
/** Fuehrt `fn` unter einer Web-Sperre aus (mehrere Tabs nacheinander), sonst direkt. */
export async function withNotifyLock<T>(fn: () => T | Promise<T>): Promise<T> {
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
if (locks && typeof locks.request === 'function') {
return locks.request(LOCK_NAME, async () => fn());
}
return fn();
}
/**
* Erinnerungen, die JETZT gemeldet werden sollen: faellig (`dueAt <= now`,
* D-02: nie vorher) und nicht aelter als 24 Stunden (E-03).
*/
export function remindersToNotify(reminders: Reminder[], nowMs: number): Reminder[] {
return reminders.filter((r) => {
const due = new Date(r.dueAt).getTime();
return due <= nowMs && due > nowMs - CATCH_UP_WINDOW_MS;
});
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Zeit-Hilfen der Erinnerungen (quick-260929-if2) — alles in Ortszeit des
* Benutzers, reine Funktionen ohne React.
*/
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const TIME_RE = /^\d{2}:\d{2}$/;
const pad = (n: number) => String(n).padStart(2, '0');
/**
* Datums- und Zeitfeld (Ortszeit, `type="date"`/`type="time"`) als ISO-Zeitpunkt.
* `null` bei unvollstaendiger oder ungueltiger Eingabe.
*/
export function localInputsToIso(date: string, time: string): string | null {
if (!DATE_RE.test(date) || !TIME_RE.test(time)) return null;
const d = new Date(`${date}T${time}`);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
/** Vorbelegung fuer eine neue Erinnerung: heute, die naechste volle Stunde. */
export function defaultNewReminderInputs(now: Date): { date: string; time: string } {
const next = new Date(now);
next.setHours(next.getHours() + 1, 0, 0, 0);
return {
date: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`,
time: `${pad(next.getHours())}:${pad(next.getMinutes())}`,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReminderRequestError, createReminder, listReminders } from './reminders-api';
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
fetchMock.mockReset();
vi.unstubAllGlobals();
});
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
describe('reminders-api', () => {
it('listReminders ruft GET /reminders mit Cookie-Anmeldung', async () => {
fetchMock.mockResolvedValue(ok([]));
await expect(listReminders()).resolves.toEqual([]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toMatch(/\/reminders$/);
expect(init.credentials).toBe('include');
});
it('createReminder schickt POST mit JSON-Koerper', async () => {
fetchMock.mockResolvedValue(ok({ id: 'r1' }));
await createReminder({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toMatch(/\/reminders$/);
expect(init.method).toBe('POST');
expect(init.credentials).toBe('include');
expect(JSON.parse(init.body)).toEqual({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
});
it('ein Status ausserhalb 2xx wirft ReminderRequestError mit diesem Status', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) });
await expect(listReminders()).rejects.toMatchObject({ status: 401 });
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) });
const err = await createReminder({ title: 'a', dueAt: 'x' }).catch((e) => e);
expect(err).toBeInstanceOf(ReminderRequestError);
expect(err.status).toBe(409);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Erinnerungen — API-Klient (quick-260929-if2). Spiegelt den
* `RemindersController` der API. Alle Aufrufe mit `credentials: 'include'`
* (Cookie-Anmeldung), Muster `favorites-api.ts`.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/** Ereignis nach jeder erfolgreichen Aenderung — der globale Melder laedt daraufhin neu. */
export const REMINDERS_CHANGED_EVENT = 'tessera:reminders-changed';
/** Eine Erinnerung; Zeitangaben als ISO-Zeichenketten (so liefert sie die API). */
export interface Reminder {
id: string;
title: string;
description: string;
dueAt: string;
emailEnabled: boolean;
createdAt: string;
updatedAt: string;
}
/** Ein abgewiesener Aufruf; `status` ist der HTTP-Status (401, 404, 409 ...). */
export class ReminderRequestError extends Error {
public readonly status: number;
constructor(status: number) {
super(`Reminder request failed (${status})`);
this.name = 'ReminderRequestError';
this.status = status;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${path}`, { credentials: 'include', ...init });
if (!res.ok) throw new ReminderRequestError(res.status);
return res.json() as Promise<T>;
}
function jsonInit(method: string, body: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
export function listReminders(): Promise<Reminder[]> {
return request<Reminder[]>('/reminders');
}
export interface ReminderInput {
title: string;
description?: string;
/** ISO-8601-Zeitpunkt. */
dueAt: string;
}
export function createReminder(input: ReminderInput): Promise<Reminder> {
return request<Reminder>('/reminders', jsonInit('POST', input));
}
+19
View File
@@ -476,6 +476,25 @@
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
"chooseServers": "Server auswählen"
},
"reminder": {
"name": "Erinnerungen",
"description": "Termine und Aufgaben mit Benachrichtigung zur gewünschten Zeit",
"add": "Neue Erinnerung",
"empty": "Keine Erinnerungen. Legen Sie eine neue Erinnerung an.",
"formTitle": "Neue Erinnerung",
"dateLabel": "Datum",
"timeLabel": "Uhrzeit",
"titleLabel": "Titel",
"descriptionLabel": "Beschreibung (optional)",
"save": "Speichern",
"cancel": "Abbrechen",
"titleRequired": "Bitte geben Sie einen Titel ein.",
"pastError": "Der gewählte Zeitpunkt liegt in der Vergangenheit.",
"saveError": "Die Erinnerung konnte nicht gespeichert werden.",
"loadError": "Erinnerungen konnten nicht geladen werden.",
"limitReached": "Es sind höchstens 100 Erinnerungen möglich.",
"notificationTitle": "Erinnerung: {title}"
},
"editShort": "Bearbeiten",
"done": "Fertig",
"toolbarLabel": "Dashboard-Aktionen",
+19
View File
@@ -476,6 +476,25 @@
"serversHint": "With nothing selected, the tile shows all servers.",
"chooseServers": "Choose servers"
},
"reminder": {
"name": "Reminders",
"description": "Appointments and tasks with a notification at the time you choose",
"add": "New reminder",
"empty": "No reminders. Create a new reminder.",
"formTitle": "New reminder",
"dateLabel": "Date",
"timeLabel": "Time",
"titleLabel": "Title",
"descriptionLabel": "Description (optional)",
"save": "Save",
"cancel": "Cancel",
"titleRequired": "Please enter a title.",
"pastError": "The chosen time is in the past.",
"saveError": "The reminder could not be saved.",
"loadError": "Reminders could not be loaded.",
"limitReached": "You can have at most 100 reminders.",
"notificationTitle": "Reminder: {title}"
},
"editShort": "Edit",
"done": "Done",
"toolbarLabel": "Dashboard actions",