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:
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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())}`,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user