feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI

- In-app reminders: task due (skips rescheduled tasks, catch-up wording)
  and a per-user morning digest step in the daily job
- Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard,
  inspection-due card, season chip, apiary overview fields, cleanup
  sheet for ended colonies
- Frontend: plausibility warnings that stay until dismissed, withdrawal
  spans in the calendar, notification bell with categories and links,
  notification settings in the profile
- ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler
  table and daily job step order

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 17:05:32 +02:00
1 parent ef33e5549b
commit 32c61c8e14
77 files changed
+5309 -142

No files matched your search

@@ -0,0 +1,18 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { NotificationSettings } from "./types";
const PATH = "/profile/notification-settings";
export function fetchNotificationSettings(): Promise<NotificationSettings> {
return apiFetch<NotificationSettings>(PATH);
}
/** Send only what changed; an omitted field stays as it is. */
export function updateNotificationSettings(
patch: Partial<NotificationSettings>,
): Promise<NotificationSettings> {
return apiFetch<NotificationSettings>(PATH, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
@@ -0,0 +1,14 @@
import type { NotificationCategory } from "@/lib/notifications/types";
/** The signed-in user's own settings (GET/PATCH /profile/notification-settings). */
export interface NotificationSettings {
taskDueEnabled: boolean;
digestEnabled: boolean;
/** HH:MM, 05:00 to 12:00 (Europe/Berlin). */
digestTime: string;
/** HH:MM or null; quiet hours are set or cleared only as a pair. */
quietFrom: string | null;
quietUntil: string | null;
telegramCategories: NotificationCategory[];
telegramDetail: boolean;
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { settingsPatch, validateDigestTime, validateQuietHours } from "./validation";
import type { NotificationSettings } from "./types";
const base: NotificationSettings = {
taskDueEnabled: true,
digestEnabled: true,
digestTime: "06:30",
quietFrom: "21:00",
quietUntil: "06:30",
telegramCategories: ["TASK_DUE", "DIGEST"],
telegramDetail: true,
};
describe("validateDigestTime", () => {
it("rejects 04:30 with the German message", () => {
expect(validateDigestTime("04:30")).toBe(
"Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.",
);
});
it("rejects 12:01, empty and malformed values", () => {
expect(validateDigestTime("12:01")).not.toBeNull();
expect(validateDigestTime("")).not.toBeNull();
expect(validateDigestTime("abc")).not.toBeNull();
});
it("accepts the bounds", () => {
expect(validateDigestTime("05:00")).toBeNull();
expect(validateDigestTime("12:00")).toBeNull();
});
});
describe("validateQuietHours", () => {
it("clears only as a pair", () => {
expect(validateQuietHours("", "")).toBeNull();
expect(validateQuietHours("21:00", "06:30")).toBeNull();
expect(validateQuietHours("21:00", "")).toBe(
"Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren.",
);
expect(validateQuietHours("", "06:30")).not.toBeNull();
});
});
describe("settingsPatch", () => {
it("contains only changed fields", () => {
expect(settingsPatch(base, { ...base, digestTime: "07:00" })).toEqual({
digestTime: "07:00",
});
});
it("is empty when nothing changed", () => {
expect(settingsPatch(base, { ...base })).toEqual({});
});
it("sends null for both quiet fields when cleared", () => {
expect(settingsPatch(base, { ...base, quietFrom: null, quietUntil: null })).toEqual({
quietFrom: null,
quietUntil: null,
});
});
});
@@ -0,0 +1,35 @@
import type { NotificationSettings } from "./types";
export const DIGEST_TIME_MIN = "05:00";
export const DIGEST_TIME_MAX = "12:00";
export const DIGEST_TIME_MESSAGE = "Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.";
export const QUIET_PAIR_MESSAGE = "Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren.";
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
/** HH:MM strings of one day compare correctly as text. */
export function validateDigestTime(value: string): string | null {
if (!TIME_PATTERN.test(value) || value < DIGEST_TIME_MIN || value > DIGEST_TIME_MAX) {
return DIGEST_TIME_MESSAGE;
}
return null;
}
/** Quiet hours are both set or both empty. */
export function validateQuietHours(from: string, until: string): string | null {
return Boolean(from) === Boolean(until) ? null : QUIET_PAIR_MESSAGE;
}
/** Only the fields that differ from what the server last sent. */
export function settingsPatch(
original: NotificationSettings,
draft: NotificationSettings,
): Partial<NotificationSettings> {
const patch: Partial<NotificationSettings> = {};
if (draft.taskDueEnabled !== original.taskDueEnabled) patch.taskDueEnabled = draft.taskDueEnabled;
if (draft.digestEnabled !== original.digestEnabled) patch.digestEnabled = draft.digestEnabled;
if (draft.digestTime !== original.digestTime) patch.digestTime = draft.digestTime;
if (draft.quietFrom !== original.quietFrom) patch.quietFrom = draft.quietFrom;
if (draft.quietUntil !== original.quietUntil) patch.quietUntil = draft.quietUntil;
return patch;
}