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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user