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
@@ -1,4 +1,5 @@
|
||||
import type { ApiaryStatus } from "./types";
|
||||
import type { NextStep, SeasonSegment } from "@/lib/autopilot/types";
|
||||
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
|
||||
import type { Inspection } from "@/lib/inspections/types";
|
||||
import type { Queen } from "@/lib/queens/types";
|
||||
@@ -12,14 +13,24 @@ export interface ColonyOverviewRow {
|
||||
code: string;
|
||||
status: ColonyStatus;
|
||||
strength: number | null;
|
||||
occupiedFrames: number | null;
|
||||
broodStatus: ColonyCondition | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
healthStatus: ColonyCondition | null;
|
||||
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||
lastInspectionDate: string | null;
|
||||
/** Unread WARNUNG notifications visible to the caller. */
|
||||
/**
|
||||
* The day the next inspection is due (YYYY-MM-DD); null in the winter
|
||||
* rest, when the interval rule is off and without `inspections:read`.
|
||||
*/
|
||||
inspectionDueOn: string | null;
|
||||
/** The season segment of the apiary today; null when it is unknown. */
|
||||
segment: SeasonSegment | null;
|
||||
/** Title of the first next step; null without one or without `tasks:read`. */
|
||||
nextStepTitle: string | null;
|
||||
/** Open suggestions of priority HIGH the caller may see. */
|
||||
openWarnings: number;
|
||||
/** Unread KRITISCH notifications visible to the caller. */
|
||||
/** Open suggestions of priority URGENT the caller may see. */
|
||||
openCritical: number;
|
||||
}
|
||||
|
||||
@@ -54,6 +65,7 @@ export interface OfflinePackV1 {
|
||||
| "colonyType"
|
||||
| "hiveType"
|
||||
| "strength"
|
||||
| "occupiedFrames"
|
||||
| "broodStatus"
|
||||
| "feedStatus"
|
||||
| "healthStatus"
|
||||
@@ -80,4 +92,9 @@ export interface OfflinePackV1 {
|
||||
| "relatedId"
|
||||
>[]
|
||||
| null;
|
||||
/**
|
||||
* The next steps per colony id (what `GET /colonies/:id/next-steps`
|
||||
* returns as `steps`). Absent in packs cached before T4.
|
||||
*/
|
||||
nextSteps?: Record<string, NextStep[]>;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function packColony(id: string, code: string, overrides: Partial<PackColo
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: "Zander",
|
||||
strength: 8,
|
||||
occupiedFrames: 9,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
@@ -35,10 +36,14 @@ export function overviewRow(colony: PackColony, overrides: Partial<ColonyOvervie
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
strength: colony.strength,
|
||||
occupiedFrames: colony.occupiedFrames,
|
||||
broodStatus: colony.broodStatus,
|
||||
feedStatus: colony.feedStatus,
|
||||
healthStatus: colony.healthStatus,
|
||||
lastInspectionDate: new Date().toISOString(),
|
||||
inspectionDueOn: null,
|
||||
segment: null,
|
||||
nextStepTitle: null,
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import type { AutopilotToday, ApplyCleanupInput, CleanupResult, ColonyNextSteps } from "./types";
|
||||
|
||||
export function colonyNextStepsKey(colonyId: string) {
|
||||
return ["colonies", colonyId, "next-steps"] as const;
|
||||
}
|
||||
|
||||
export const autopilotTodayKey = ["autopilot", "today"] as const;
|
||||
|
||||
export function fetchColonyNextSteps(colonyId: string): Promise<ColonyNextSteps> {
|
||||
return apiFetch<ColonyNextSteps>(`/colonies/${encodeURIComponent(colonyId)}/next-steps`);
|
||||
}
|
||||
|
||||
export function fetchAutopilotToday(): Promise<AutopilotToday> {
|
||||
return apiFetch<AutopilotToday>("/autopilot/today");
|
||||
}
|
||||
|
||||
/** Ends the leftovers of an ended colony; only ids from the suggestion's evidence. */
|
||||
export function applyCleanup(
|
||||
suggestionId: string,
|
||||
input: ApplyCleanupInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<CleanupResult> {
|
||||
return apiFetch<CleanupResult>(`/suggestions/${encodeURIComponent(suggestionId)}/apply-cleanup`, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { daysBetween, dueChipProps, formatDayMonth, inspectionDueText } from "./format";
|
||||
|
||||
describe("autopilot format helpers", () => {
|
||||
it("formats a day as TT.MM.", () => {
|
||||
expect(formatDayMonth("2026-05-05")).toBe("05.05.");
|
||||
});
|
||||
|
||||
it("counts whole days across a month and a DST change", () => {
|
||||
expect(daysBetween("2026-03-28", "2026-04-02")).toBe(5);
|
||||
expect(daysBetween("2026-10-14", "2026-10-12")).toBe(-2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["2026-10-16", "Durchsicht fällig in 2 Tagen"],
|
||||
["2026-10-15", "Durchsicht fällig in 1 Tag"],
|
||||
["2026-10-14", "Durchsicht heute fällig"],
|
||||
["2026-10-13", "Durchsicht seit 1 Tag fällig"],
|
||||
["2026-10-11", "Durchsicht seit 3 Tagen fällig"],
|
||||
])("says when the inspection due on %s is due", (dueOn, text) => {
|
||||
expect(inspectionDueText(dueOn, "2026-10-14")).toBe(text);
|
||||
});
|
||||
|
||||
it("maps a due day to the state the DueChip knows", () => {
|
||||
expect(dueChipProps(null, "2026-10-14")).toEqual({ state: "none", dueDate: null });
|
||||
expect(dueChipProps("2026-10-13", "2026-10-14").state).toBe("overdue");
|
||||
expect(dueChipProps("2026-10-14", "2026-10-14").state).toBe("today");
|
||||
expect(dueChipProps("2026-10-15", "2026-10-14")).toEqual({
|
||||
state: "upcoming",
|
||||
dueDate: "2026-10-15T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DueState } from "@/lib/tasks/today";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const CALENDAR_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/** "2026-05-05" as „05.05.“ */
|
||||
export function formatDayMonth(day: string): string {
|
||||
const match = CALENDAR_DAY.exec(day);
|
||||
return match ? `${match[3]}.${match[2]}.` : day;
|
||||
}
|
||||
|
||||
/** Whole days from `from` to `to` (both YYYY-MM-DD); negative when `to` is earlier. */
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS);
|
||||
}
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** „Durchsicht fällig in 2 Tagen“, „Durchsicht heute fällig“, „Durchsicht seit 3 Tagen fällig“. */
|
||||
export function inspectionDueText(dueOn: string, today: string): string {
|
||||
const days = daysBetween(today, dueOn);
|
||||
if (days === 0) return "Durchsicht heute fällig";
|
||||
if (days > 0) return `Durchsicht fällig in ${plural(days, "Tag", "Tagen")}`;
|
||||
return `Durchsicht seit ${plural(-days, "Tag", "Tagen")} fällig`;
|
||||
}
|
||||
|
||||
/** What `DueChip` takes for a plain day (YYYY-MM-DD) and today (YYYY-MM-DD). */
|
||||
export function dueChipProps(
|
||||
dueOn: string | null,
|
||||
today: string,
|
||||
): { state: DueState; dueDate: string | null } {
|
||||
if (!dueOn) return { state: "none", dueDate: null };
|
||||
const days = daysBetween(today, dueOn);
|
||||
const state: DueState = days < 0 ? "overdue" : days === 0 ? "today" : "upcoming";
|
||||
return { state, dueDate: `${dueOn}T00:00:00.000Z` };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Test data for next steps - shared by the specs, never imported by app code.
|
||||
import type { AutopilotToday, ColonyNextSteps, NextStep } from "./types";
|
||||
|
||||
export function stepFixture(overrides: Partial<NextStep> = {}): NextStep {
|
||||
return {
|
||||
key: "suggestion:s1",
|
||||
kind: "SUGGESTION",
|
||||
intent: "INSPECT",
|
||||
title: "Durchsicht",
|
||||
reasons: ["Letzte Durchsicht vor 8 Tagen.", "In der Schwarmzeit alle 7 Tage."],
|
||||
dueOn: "2026-05-12",
|
||||
urgency: "TODAY",
|
||||
suggestionIds: ["s1"],
|
||||
action: { type: "CAPTURE", form: "inspection", href: "/inspections/new?colonyId=c1" },
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function colonyStepsFixture(overrides: Partial<ColonyNextSteps> = {}): ColonyNextSteps {
|
||||
return {
|
||||
colonyId: "c1",
|
||||
segment: "SCHWARMZEIT",
|
||||
inspectionDueOn: "2026-05-12",
|
||||
withdrawalUntil: null,
|
||||
steps: [stepFixture()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function todayFixture(overrides: Partial<AutopilotToday> = {}): AutopilotToday {
|
||||
return {
|
||||
nextStep: null,
|
||||
inspectionDue: [],
|
||||
suggestionsOpen: 0,
|
||||
seasons: [],
|
||||
tasksVisible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { QueenStatus } from "@/lib/queens/types";
|
||||
import type { SuggestionIntent } from "@/lib/suggestions/types";
|
||||
|
||||
// Mirrors ApiCore-Backend/src/autopilot (next-step-ranking.ts, next-steps.service.ts,
|
||||
// dto/next-steps-response.dto.ts) - keep both in step.
|
||||
|
||||
export const SEASON_SEGMENTS = [
|
||||
"WINTERRUHE",
|
||||
"FRUEHJAHR",
|
||||
"SCHWARMZEIT",
|
||||
"SOMMER",
|
||||
"SPAETSOMMER",
|
||||
"HERBST",
|
||||
] as const;
|
||||
export type SeasonSegment = (typeof SEASON_SEGMENTS)[number];
|
||||
|
||||
export type NextStepForm = "inspection" | "varroa" | "feeding" | "treatment" | "winter-check";
|
||||
|
||||
export type NextStepAction =
|
||||
| { type: "CAPTURE"; form: NextStepForm; href: string }
|
||||
| { type: "ACCEPT"; suggestionId: string }
|
||||
| { type: "CLEANUP"; suggestionId: string }
|
||||
| { type: "OPEN_TASK"; taskId: string; href: string };
|
||||
|
||||
export type NextStepUrgency = "OVERDUE" | "TODAY" | "SOON" | "LATER" | "NONE";
|
||||
|
||||
export interface NextStep {
|
||||
/** 'task:<id>' | 'suggestion:<id>' */
|
||||
key: string;
|
||||
kind: "TASK" | "SUGGESTION";
|
||||
intent: SuggestionIntent | null;
|
||||
title: string;
|
||||
/** At most 2 short sentences. */
|
||||
reasons: string[];
|
||||
/** YYYY-MM-DD */
|
||||
dueOn: string | null;
|
||||
urgency: NextStepUrgency;
|
||||
/** The suggestions behind the step (all members of a merged step); [] for a task. */
|
||||
suggestionIds: string[];
|
||||
action: NextStepAction;
|
||||
/** True if any member suggestion may be dismissed. */
|
||||
dismissible: boolean;
|
||||
}
|
||||
|
||||
export interface ColonyNextSteps {
|
||||
colonyId: string;
|
||||
segment: SeasonSegment;
|
||||
inspectionDueOn: string | null;
|
||||
/** YYYY-MM-DD; the last day of a withdrawal period (a hint, not a step). */
|
||||
withdrawalUntil: string | null;
|
||||
/** At most 3. */
|
||||
steps: NextStep[];
|
||||
}
|
||||
|
||||
export interface TodayNextStep extends NextStep {
|
||||
colonyId: string;
|
||||
colonyCode: string;
|
||||
}
|
||||
|
||||
export interface DueColony {
|
||||
id: string;
|
||||
code: string;
|
||||
dueOn: string;
|
||||
overdueDays: number;
|
||||
}
|
||||
|
||||
export interface InspectionDueApiary {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegment;
|
||||
intervalDays: number | null;
|
||||
colonies: DueColony[];
|
||||
}
|
||||
|
||||
export interface SeasonOfApiary {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegment;
|
||||
}
|
||||
|
||||
export interface AutopilotToday {
|
||||
nextStep: TodayNextStep | null;
|
||||
inspectionDue: InspectionDueApiary[];
|
||||
suggestionsOpen: number;
|
||||
seasons: SeasonOfApiary[];
|
||||
tasksVisible: boolean;
|
||||
}
|
||||
|
||||
export type CleanupOutcome = "CANCEL" | "DONE" | "KEEP";
|
||||
|
||||
export interface ApplyCleanupInput {
|
||||
tasks: { id: string; outcome: CleanupOutcome }[];
|
||||
queen?: { id: string; status: QueenStatus } | null;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
cancelled: number;
|
||||
completed: number;
|
||||
kept: number;
|
||||
queenStatus: QueenStatus | null;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface BottlingBatch {
|
||||
import type { WithWarnings } from "@/lib/warnings/types";
|
||||
|
||||
export interface BottlingBatch extends WithWarnings {
|
||||
id: string;
|
||||
honeyBatchId: string;
|
||||
productId: string | null;
|
||||
|
||||
@@ -4,12 +4,15 @@ export type CalendarEventType =
|
||||
| "TREATMENT"
|
||||
| "FEEDING"
|
||||
| "HARVEST"
|
||||
| "QUEEN_ASSIGNMENT";
|
||||
| "QUEEN_ASSIGNMENT"
|
||||
| "WITHDRAWAL";
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
type: CalendarEventType;
|
||||
date: string;
|
||||
/** WITHDRAWAL only: the last day of the span, inclusive. */
|
||||
endDate?: string;
|
||||
title: string;
|
||||
relatedType: string;
|
||||
relatedId: string;
|
||||
|
||||
@@ -10,10 +10,14 @@ function row(overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
|
||||
code: "V-1",
|
||||
status: "ACTIVE",
|
||||
strength: 6,
|
||||
occupiedFrames: 9,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
lastInspectionDate: "2026-10-10T00:00:00.000Z",
|
||||
inspectionDueOn: "2026-10-17",
|
||||
segment: "HERBST",
|
||||
nextStepTitle: null,
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
@@ -32,17 +36,24 @@ describe("colonyStatusLight", () => {
|
||||
["a weak colony", row({ status: "WEAK" }), "yellow", ["Volk schwach"]],
|
||||
["a conspicuous colony", row({ status: "CONSPICUOUS" }), "yellow", ["Volk auffällig"]],
|
||||
["weak feed", row({ feedStatus: "WEAK" }), "yellow", ["Futter schwach"]],
|
||||
["15 days without inspection", row({ lastInspectionDate: "2026-09-28T00:00:00.000Z" }), "yellow", ["15 Tage ohne Durchsicht"]],
|
||||
["14 days without inspection", row({ lastInspectionDate: "2026-09-29T00:00:00.000Z" }), "green", []],
|
||||
["never inspected", row({ lastInspectionDate: null }), "yellow", ["Noch keine Durchsicht"]],
|
||||
["an inspection due today", row({ inspectionDueOn: TODAY }), "green", []],
|
||||
["an inspection due tomorrow", row({ inspectionDueOn: "2026-10-14" }), "green", []],
|
||||
["an inspection one day overdue", row({ inspectionDueOn: "2026-10-12" }), "yellow", ["Durchsicht seit 1 Tag fällig"]],
|
||||
["an inspection three days overdue", row({ inspectionDueOn: "2026-10-10" }), "yellow", ["Durchsicht seit 3 Tagen fällig"]],
|
||||
["no due day (winter rest)", row({ inspectionDueOn: null, lastInspectionDate: null }), "green", []],
|
||||
["one warning", row({ openWarnings: 1 }), "yellow", ["1 Warnung"]],
|
||||
["two warnings", row({ openWarnings: 2 }), "yellow", ["2 Warnungen"]],
|
||||
] as const)("%s", (_name, input, light, reasons) => {
|
||||
expect(colonyStatusLight(input, visible)).toEqual({ light, reasons });
|
||||
});
|
||||
|
||||
it("ignores the inspection age when the caller may not read inspections", () => {
|
||||
const result = colonyStatusLight(row({ lastInspectionDate: null }), {
|
||||
it("status light turns yellow once inspectionDueOn has passed", () => {
|
||||
expect(colonyStatusLight(row({ inspectionDueOn: "2026-10-12" }), visible).light).toBe("yellow");
|
||||
expect(colonyStatusLight(row({ inspectionDueOn: TODAY }), visible).light).toBe("green");
|
||||
});
|
||||
|
||||
it("ignores the due day when the caller may not read inspections", () => {
|
||||
const result = colonyStatusLight(row({ inspectionDueOn: "2026-10-01" }), {
|
||||
today: TODAY,
|
||||
inspectionsVisible: false,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ColonyOverviewRow } from "@/lib/apiaries/field-types";
|
||||
import { daysSince } from "@/lib/inspections/takeover";
|
||||
import { daysBetween, inspectionDueText } from "@/lib/autopilot/format";
|
||||
|
||||
export type StatusLight = "green" | "yellow" | "red";
|
||||
|
||||
export const INSPECTION_STALE_DAYS = 14;
|
||||
|
||||
export interface StatusLightResult {
|
||||
light: StatusLight;
|
||||
reasons: string[];
|
||||
@@ -38,22 +36,21 @@ function conditionFindings(row: ColonyOverviewRow): Finding[] {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yellow from the day after `inspectionDueOn`. A pack cached before T4 has no
|
||||
* due day - then nothing is said instead of a false alarm.
|
||||
*/
|
||||
function inspectionFinding(row: ColonyOverviewRow, today: string): Finding | null {
|
||||
if (row.lastInspectionDate === null) {
|
||||
return { light: "yellow", reason: "Noch keine Durchsicht" };
|
||||
}
|
||||
const days = daysSince(row.lastInspectionDate, today);
|
||||
if (days > INSPECTION_STALE_DAYS) {
|
||||
return { light: "yellow", reason: plural(days, "Tag ohne Durchsicht", "Tage ohne Durchsicht") };
|
||||
}
|
||||
return null;
|
||||
const dueOn = row.inspectionDueOn;
|
||||
if (!dueOn || daysBetween(dueOn, today) <= 0) return null;
|
||||
return { light: "yellow", reason: inspectionDueText(dueOn, today) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The traffic light of one living colony: the worst finding wins. Reasons are
|
||||
* plain text (the badge also shows icon and word, never colour alone), critical
|
||||
* ones first. The inspection age counts only when the caller may read
|
||||
* inspections - otherwise "never inspected" would be a false alarm.
|
||||
* ones first. The due inspection counts only when the caller may read
|
||||
* inspections (the due day is derived from the last inspection).
|
||||
*/
|
||||
export function colonyStatusLight(
|
||||
row: ColonyOverviewRow,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { WithWarnings } from "@/lib/warnings/types";
|
||||
|
||||
export interface HarvestColonyEntry {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
@@ -7,7 +9,7 @@ export interface HarvestColonyEntry {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Harvest {
|
||||
export interface Harvest extends WithWarnings {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
userId: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,3 +8,8 @@ export function fetchNotifications(unreadOnly = false): Promise<Notification[]>
|
||||
export function markNotificationRead(id: string): Promise<Notification> {
|
||||
return apiFetch<Notification>(`/notifications/${encodeURIComponent(id)}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** Marks all of the user's visible unread notifications as read. */
|
||||
export function markAllNotificationsRead(): Promise<{ updated: number }> {
|
||||
return apiFetch<{ updated: number }>("/notifications/read-all", { method: "POST" });
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
export type NotificationPriority = "INFO" | "HINWEIS" | "WARNUNG" | "KRITISCH";
|
||||
|
||||
export type NotificationCategory =
|
||||
| "TASK_DUE"
|
||||
| "DIGEST"
|
||||
| "SUGGESTION"
|
||||
| "WITHDRAWAL"
|
||||
| "SEASON"
|
||||
| "SYSTEM";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
priority: NotificationPriority;
|
||||
category: NotificationCategory;
|
||||
message: string;
|
||||
relatedType: string | null;
|
||||
relatedId: string | null;
|
||||
/** Relative app path (starts with "/") or null; opened as an internal link. */
|
||||
link: string | null;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -18,8 +18,14 @@ describe("suggestion labels", () => {
|
||||
expect(captureHref("HONEY_SUPER", "c1")).toBe("/inspections/new?colonyId=c1");
|
||||
});
|
||||
|
||||
it("opens the winter page for a winter check and omits a missing colony", () => {
|
||||
expect(captureHref("WINTER_CHECK", "c1")).toBe("/winter");
|
||||
it("opens the winter page of that colony with the season, and omits a missing colony", () => {
|
||||
expect(captureHref("WINTER_CHECK", "c1", new Date(2026, 9, 14))).toBe(
|
||||
"/winter?colonyId=c1&season=AUTUMN",
|
||||
);
|
||||
expect(captureHref("WINTER_CHECK", "c1", new Date(2027, 2, 14))).toBe(
|
||||
"/winter?colonyId=c1&season=SPRING",
|
||||
);
|
||||
expect(captureHref("WINTER_CHECK", null)).toBe("/winter");
|
||||
expect(captureHref("INSPECT", null)).toBe("/inspections/new");
|
||||
});
|
||||
});
|
||||
@@ -68,13 +68,23 @@ const CAPTURE_PATHS: Partial<Record<SuggestionIntent, string>> = {
|
||||
WINTER_CHECK: "/winter",
|
||||
};
|
||||
|
||||
/** Winter checks are entered from the winter page, which has no colony parameter. */
|
||||
const PATHS_WITHOUT_COLONY = new Set(["/winter"]);
|
||||
|
||||
const DEFAULT_CAPTURE_PATH = "/inspections/new";
|
||||
|
||||
export function captureHref(intent: SuggestionIntent, colonyId: string | null): string {
|
||||
/** From July on the winter check is the autumn one (as the autopilot's own link says). */
|
||||
const FIRST_AUTUMN_MONTH = 7;
|
||||
|
||||
export function captureHref(
|
||||
intent: SuggestionIntent,
|
||||
colonyId: string | null,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const path = CAPTURE_PATHS[intent] ?? DEFAULT_CAPTURE_PATH;
|
||||
if (!colonyId || PATHS_WITHOUT_COLONY.has(path)) return path;
|
||||
return `${path}?colonyId=${encodeURIComponent(colonyId)}`;
|
||||
if (!colonyId) return path;
|
||||
const query = `colonyId=${encodeURIComponent(colonyId)}`;
|
||||
if (path === "/winter") {
|
||||
// The winter page opens the dialog of that colony for the season.
|
||||
const season = now.getMonth() + 1 >= FIRST_AUTUMN_MONTH ? "AUTUMN" : "SPRING";
|
||||
return `${path}?${query}&season=${season}`;
|
||||
}
|
||||
return `${path}?${query}`;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateTaskInput, Task, TaskFilter, UpdateTaskInput } from "./types";
|
||||
|
||||
export function fetchTask(id: string): Promise<Task> {
|
||||
return apiFetch<Task>(`/tasks/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function fetchTasks(filter: TaskFilter): Promise<Task[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.status) params.set("status", filter.status);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { toastWarnings } from "./toast-warnings";
|
||||
import type { PlausibilityWarning } from "./types";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { warning: vi.fn() } }));
|
||||
|
||||
const withdrawal: PlausibilityWarning = {
|
||||
code: "WITHDRAWAL_ACTIVE",
|
||||
severity: "WARNUNG",
|
||||
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
|
||||
refs: [],
|
||||
};
|
||||
|
||||
describe("toastWarnings", () => {
|
||||
beforeEach(() => vi.mocked(toast.warning).mockReset());
|
||||
|
||||
it("says Gespeichert with the count and names the texts", () => {
|
||||
toastWarnings([withdrawal]);
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
"Gespeichert · 1 Warnung",
|
||||
expect.objectContaining({
|
||||
description: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a WARNUNG toast until dismissed, one line per warning, with a 44 px close action", () => {
|
||||
toastWarnings([withdrawal, { ...withdrawal, message: "Zweiter Text." }]);
|
||||
const options = vi.mocked(toast.warning).mock.calls[0][1] as unknown as {
|
||||
duration: number;
|
||||
closeButton: boolean;
|
||||
description: string;
|
||||
classNames: Record<string, string>;
|
||||
action: { label: string };
|
||||
};
|
||||
expect(options.duration).toBe(Infinity);
|
||||
expect(options.closeButton).toBe(true);
|
||||
expect(options.description).toBe(`${withdrawal.message}\nZweiter Text.`);
|
||||
expect(options.classNames.description).toContain("whitespace-pre-line");
|
||||
expect(options.action.label).toBe("Verstanden");
|
||||
expect(options.classNames.actionButton).toContain("min-h-11");
|
||||
});
|
||||
|
||||
it("keeps a BESTAETIGEN toast too but lets a plain HINWEIS time out", () => {
|
||||
toastWarnings([{ ...withdrawal, severity: "BESTAETIGEN" }]);
|
||||
expect(
|
||||
(vi.mocked(toast.warning).mock.calls[0][1] as { duration?: number })
|
||||
.duration,
|
||||
).toBe(Infinity);
|
||||
vi.mocked(toast.warning).mockReset();
|
||||
toastWarnings([{ ...withdrawal, severity: "HINWEIS" }]);
|
||||
expect(
|
||||
(vi.mocked(toast.warning).mock.calls[0][1] as { duration?: number })
|
||||
.duration,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts the title with the given saved label", () => {
|
||||
toastWarnings([withdrawal], "Abfüllung angelegt");
|
||||
expect(vi.mocked(toast.warning).mock.calls[0][0]).toBe(
|
||||
"Abfüllung angelegt · 1 Warnung",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the plural for several warnings", () => {
|
||||
toastWarnings([withdrawal, { ...withdrawal, message: "Zweiter Text." }]);
|
||||
expect(vi.mocked(toast.warning).mock.calls[0][0]).toBe(
|
||||
"Gespeichert · 2 Warnungen",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays silent without warnings", () => {
|
||||
toastWarnings([]);
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { toast } from "sonner";
|
||||
import type { PlausibilityWarning } from "./types";
|
||||
|
||||
/** Warnings about food safety must not time out: they stay until dismissed. */
|
||||
function mustStay(warnings: readonly PlausibilityWarning[]): boolean {
|
||||
return warnings.some(
|
||||
(warning) =>
|
||||
warning.severity === "WARNUNG" || warning.severity === "BESTAETIGEN",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful save: "<saved> · 1 Warnung" plus one line per text. The
|
||||
* record is saved either way; warnings only point at something to check.
|
||||
* A WARNUNG or BESTAETIGEN stays until the person dismisses it. Does nothing
|
||||
* for an empty list - the caller shows its own success then.
|
||||
*/
|
||||
export function toastWarnings(
|
||||
warnings: readonly PlausibilityWarning[],
|
||||
savedLabel = "Gespeichert",
|
||||
): void {
|
||||
if (warnings.length === 0) return;
|
||||
const count = warnings.length;
|
||||
const title = `${savedLabel} \u00b7 ${count} ${count === 1 ? "Warnung" : "Warnungen"}`;
|
||||
const description = warnings.map((warning) => warning.message).join("\n");
|
||||
if (!mustStay(warnings)) {
|
||||
toast.warning(title, {
|
||||
description,
|
||||
classNames: { description: "whitespace-pre-line" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.warning(title, {
|
||||
description,
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
action: { label: "Verstanden", onClick: () => undefined },
|
||||
classNames: {
|
||||
description: "whitespace-pre-line",
|
||||
actionButton: "min-h-11 min-w-11",
|
||||
closeButton: "size-11",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type WarningSeverity = "HINWEIS" | "WARNUNG" | "BESTAETIGEN";
|
||||
|
||||
/** One format for every plausibility finding on honey records (spec 10.3). */
|
||||
export interface PlausibilityWarning {
|
||||
code: string;
|
||||
severity: WarningSeverity;
|
||||
message: string;
|
||||
refs: { type: string; id: string }[];
|
||||
}
|
||||
|
||||
/** A save response may carry warnings; they never block the save. */
|
||||
export interface WithWarnings {
|
||||
warnings?: PlausibilityWarning[];
|
||||
}
|
||||
Reference in new issue
Block a user