feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste
- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage - MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung - Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
||||
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||
import {
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
updateReminder,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
interface ReminderFormModalProps {
|
||||
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
|
||||
reminder: Reminder | null;
|
||||
/** Ergebnis von `GET /reminders/email-status`; `null` = unbekannt oder fehlgeschlagen (gilt als nicht moeglich). */
|
||||
emailStatus: ReminderEmailStatus | null;
|
||||
onClose: () => void;
|
||||
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
|
||||
onSaved: () => void;
|
||||
@@ -42,7 +45,13 @@ const DESCRIPTION_MAX = 2000;
|
||||
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
|
||||
* Laden der Seite.
|
||||
*/
|
||||
export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: ReminderFormModalProps) {
|
||||
export function ReminderFormModal({
|
||||
reminder,
|
||||
emailStatus,
|
||||
onClose,
|
||||
onSaved,
|
||||
onStale,
|
||||
}: ReminderFormModalProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const initial = useRef(
|
||||
reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()),
|
||||
@@ -51,6 +60,7 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||
const [description, setDescription] = useState(reminder?.description ?? '');
|
||||
const [emailEnabled, setEmailEnabled] = useState(reminder?.emailEnabled ?? false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
@@ -84,7 +94,16 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = { title: trimmedTitle, description: description.trim(), dueAt };
|
||||
// `emailEnabled` nur senden, wenn es sich aendert oder beim Anlegen `true`
|
||||
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
|
||||
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
|
||||
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
|
||||
const input = {
|
||||
title: trimmedTitle,
|
||||
description: description.trim(),
|
||||
dueAt,
|
||||
...(emailChanged ? { emailEnabled } : {}),
|
||||
};
|
||||
if (reminder) {
|
||||
await updateReminder(reminder.id, input);
|
||||
} else {
|
||||
@@ -105,6 +124,17 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
}
|
||||
};
|
||||
|
||||
// E-Mail moeglich: Versand eingerichtet UND Adresse im Konto. Unbekannt gilt
|
||||
// als nicht moeglich. Ein bereits angehaktes Feld bleibt bedienbar, damit man
|
||||
// es wieder abwaehlen kann.
|
||||
const emailAvailable = emailStatus?.smtpConfigured === true && emailStatus.hasEmail;
|
||||
const emailHint =
|
||||
emailStatus === null || emailAvailable
|
||||
? null
|
||||
: !emailStatus.smtpConfigured
|
||||
? t('reminder.emailNoSmtp')
|
||||
: t('reminder.emailNoAddress');
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -175,6 +205,24 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailEnabled}
|
||||
disabled={!emailAvailable && !emailEnabled}
|
||||
onChange={(e) => setEmailEnabled(e.target.checked)}
|
||||
aria-describedby={emailHint ? 'reminder-email-hint' : undefined}
|
||||
/>
|
||||
{t('reminder.emailLabel')}
|
||||
</label>
|
||||
{emailHint && (
|
||||
<p id="reminder-email-hint" className="text-xs text-muted-foreground">
|
||||
{emailHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock('@/lib/reminders-api', async () => {
|
||||
updateReminder: vi.fn(),
|
||||
snoozeReminder: vi.fn(),
|
||||
deleteReminder: vi.fn(),
|
||||
getReminderEmailStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
@@ -34,6 +36,7 @@ const mockCreate = createReminder as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
|
||||
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteReminder as ReturnType<typeof vi.fn>;
|
||||
const mockEmailStatus = getReminderEmailStatus as ReturnType<typeof vi.fn>;
|
||||
|
||||
function reminder(id: string, title: string, dueAt: string, description = '') {
|
||||
return {
|
||||
@@ -58,6 +61,8 @@ beforeEach(() => {
|
||||
mockUpdate.mockReset();
|
||||
mockSnooze.mockReset();
|
||||
mockDelete.mockReset();
|
||||
mockEmailStatus.mockReset();
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: true });
|
||||
requestPermission = vi.fn(async () => 'granted');
|
||||
const ctor = vi.fn();
|
||||
Object.assign(ctor, { permission: 'default', requestPermission });
|
||||
@@ -307,3 +312,87 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
||||
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
async function openFormAndWaitForStatus() {
|
||||
await openForm();
|
||||
// Der Status wird beim Einhaengen der Kachel geladen
|
||||
await waitFor(() => expect(mockEmailStatus).toHaveBeenCalledTimes(1));
|
||||
return screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
|
||||
}
|
||||
|
||||
it('ist bedienbar und wird als emailEnabled gesendet, wenn Versand und Adresse da sind', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
await waitFor(() => expect(box.disabled).toBe(false));
|
||||
fireEvent.click(box);
|
||||
fillAndSubmit('Mit Mail');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0].emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Haken wird emailEnabled gar nicht gesendet', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
await openFormAndWaitForStatus();
|
||||
fillAndSubmit('Ohne Mail');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('emailEnabled');
|
||||
});
|
||||
|
||||
it('ist ausgegraut mit Erklaerung, wenn kein E-Mail-Versand eingerichtet ist', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(await screen.findByText('reminder.emailNoSmtp')).toBeTruthy();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ist ausgegraut mit eigener Erklaerung, wenn das Konto keine Adresse hat', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: false });
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(await screen.findByText('reminder.emailNoAddress')).toBeTruthy();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('gilt als nicht moeglich, solange der Status fehlt oder fehlschlaegt', async () => {
|
||||
mockEmailStatus.mockRejectedValue(new Error('offline'));
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('zeigt bei Zeilen mit emailEnabled ein Mail-Symbol, sonst nicht', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
{ ...reminder('a', 'Mit', '2099-01-01T10:00:00.000Z'), emailEnabled: true },
|
||||
reminder('b', 'Ohne', '2099-01-02T10:00:00.000Z'),
|
||||
]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const rows = await screen.findAllByTestId('reminder-row');
|
||||
expect(within(rows[0]).getByTestId('reminder-email-icon')).toBeTruthy();
|
||||
expect(within(rows[1]).queryByTestId('reminder-email-icon')).toBeNull();
|
||||
});
|
||||
|
||||
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||
mockList.mockResolvedValue([
|
||||
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true },
|
||||
]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
const box = screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
expect(box.disabled).toBe(false); // angehakt bleibt bedienbar, zum Abwaehlen
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('emailEnabled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
||||
import {
|
||||
REMINDERS_CHANGED_EVENT,
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
@@ -45,6 +47,8 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
// Einmal je Einhaengen der Kachel geladen; unbekannt/fehlgeschlagen = E-Mail nicht moeglich.
|
||||
const [emailStatus, setEmailStatus] = useState<ReminderEmailStatus | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
@@ -55,6 +59,18 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getReminderEmailStatus()
|
||||
.then((status) => {
|
||||
if (!cancelled) setEmailStatus(status);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
const refresh = setInterval(() => void refetch(), REFRESH_MS);
|
||||
@@ -160,8 +176,33 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
: 'rounded-md border border-border bg-background/60 px-3 py-2'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
{r.emailEnabled && (
|
||||
<span
|
||||
className="shrink-0 text-muted-foreground"
|
||||
role="img"
|
||||
aria-label={t('reminder.emailOn')}
|
||||
title={t('reminder.emailOn')}
|
||||
data-testid="reminder-email-icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{due && (
|
||||
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
|
||||
{t('reminder.due')}
|
||||
@@ -308,6 +349,7 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
{modal && (
|
||||
<ReminderFormModal
|
||||
reminder={modal.reminder}
|
||||
emailStatus={emailStatus}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={afterMutation}
|
||||
onStale={() => void refetch()}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
@@ -68,6 +69,14 @@ describe('reminders-api', () => {
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('getReminderEmailStatus ruft GET /reminders/email-status', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ smtpConfigured: true, hasEmail: false }));
|
||||
await expect(getReminderEmailStatus()).resolves.toEqual({ smtpConfigured: true, hasEmail: false });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/email-status$/);
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('bei 404 und 409 tragen die Fehler den Status', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
|
||||
await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 });
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ReminderInput {
|
||||
description?: string;
|
||||
/** ISO-8601-Zeitpunkt. */
|
||||
dueAt: string;
|
||||
/** Zusaetzlich per E-Mail erinnern; nur senden, wenn es sich aendert oder `true` ist. */
|
||||
emailEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function createReminder(input: ReminderInput): Promise<Reminder> {
|
||||
@@ -79,3 +81,13 @@ export function snoozeReminder(id: string, dueAt: string): Promise<Reminder> {
|
||||
export function deleteReminder(id: string): Promise<{ deleted: true }> {
|
||||
return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** Ob die E-Mail-Erinnerung fuer diesen Benutzer moeglich ist (Versand eingerichtet, Adresse vorhanden). */
|
||||
export interface ReminderEmailStatus {
|
||||
smtpConfigured: boolean;
|
||||
hasEmail: boolean;
|
||||
}
|
||||
|
||||
export function getReminderEmailStatus(): Promise<ReminderEmailStatus> {
|
||||
return request<ReminderEmailStatus>('/reminders/email-status');
|
||||
}
|
||||
|
||||
@@ -508,6 +508,10 @@
|
||||
"alreadyDue": "Die Erinnerung ist inzwischen fällig und lässt sich nicht mehr bearbeiten.",
|
||||
"notDue": "Die Erinnerung ist noch nicht fällig.",
|
||||
"permissionDenied": "Ihr Browser blockiert Benachrichtigungen. Fällige Erinnerungen erscheinen dann nur hier in der Kachel.",
|
||||
"emailLabel": "Zusätzlich per E-Mail erinnern",
|
||||
"emailNoSmtp": "E-Mail-Erinnerungen sind nicht möglich, weil kein E-Mail-Versand eingerichtet ist. Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"emailNoAddress": "In Ihrem Konto ist keine E-Mail-Adresse hinterlegt.",
|
||||
"emailOn": "Zusätzlich per E-Mail",
|
||||
"notificationTitle": "Erinnerung: {title}"
|
||||
},
|
||||
"editShort": "Bearbeiten",
|
||||
|
||||
@@ -508,6 +508,10 @@
|
||||
"alreadyDue": "The reminder is now due and can no longer be edited.",
|
||||
"notDue": "The reminder is not due yet.",
|
||||
"permissionDenied": "Your browser blocks notifications. Due reminders will then only appear here in the tile.",
|
||||
"emailLabel": "Also remind me by e-mail",
|
||||
"emailNoSmtp": "E-mail reminders are not possible because no e-mail delivery is set up. Please contact your administrator.",
|
||||
"emailNoAddress": "Your account has no e-mail address on file.",
|
||||
"emailOn": "Also by e-mail",
|
||||
"notificationTitle": "Reminder: {title}"
|
||||
},
|
||||
"editShort": "Edit",
|
||||
|
||||
Reference in New Issue
Block a user