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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user