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
@@ -233,36 +233,13 @@ export class NextStepsService {
|
||||
);
|
||||
return {
|
||||
nextStep: this.mostUrgent(colonies, today),
|
||||
inspectionDue: sortedApiaries.flatMap((season) => {
|
||||
const due = [...colonies]
|
||||
.filter(
|
||||
([, c]) =>
|
||||
c.apiaryId === season.apiaryId &&
|
||||
c.view.inspectionDueOn !== null &&
|
||||
c.view.inspectionDueOn <= today,
|
||||
)
|
||||
.map(([id, c]) => ({
|
||||
id,
|
||||
code: c.code,
|
||||
dueOn: c.view.inspectionDueOn ?? today,
|
||||
overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today),
|
||||
}))
|
||||
.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b));
|
||||
return due.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
apiaryId: season.apiaryId,
|
||||
apiaryName: nameOf(season.apiaryId),
|
||||
segment: season.segment,
|
||||
intervalDays:
|
||||
season.segment === 'WINTERRUHE'
|
||||
? null
|
||||
: intervalDays[season.segment],
|
||||
colonies: due,
|
||||
},
|
||||
];
|
||||
}),
|
||||
inspectionDue: this.dueInspections(
|
||||
colonies,
|
||||
sortedApiaries,
|
||||
intervalDays,
|
||||
nameOf,
|
||||
today,
|
||||
),
|
||||
suggestionsOpen,
|
||||
seasons: sortedApiaries.map((season) => ({
|
||||
apiaryId: season.apiaryId,
|
||||
@@ -275,6 +252,75 @@ export class NextStepsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the due inspections of all living colonies, whatever the reader
|
||||
* may see (the caller filters by right): the digest's input.
|
||||
*/
|
||||
async inspectionDueAll(
|
||||
today: string = berlinToday(),
|
||||
): Promise<AutopilotToday['inspectionDue']> {
|
||||
const living = await this.prisma.colony.findMany({
|
||||
where: LIVING_COLONY_WHERE,
|
||||
select: { id: true },
|
||||
});
|
||||
const { colonies, seasons, intervalDays } = await this.compute(
|
||||
living.map((colony) => colony.id),
|
||||
new Set(['inspections:read']),
|
||||
today,
|
||||
);
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: { id: { in: [...seasons.keys()] } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
|
||||
const nameOf = (apiaryId: string): string => names.get(apiaryId) ?? '';
|
||||
const sorted = [...seasons.values()].sort((a, b) =>
|
||||
nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', {
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
return this.dueInspections(colonies, sorted, intervalDays, nameOf, today);
|
||||
}
|
||||
|
||||
private dueInspections(
|
||||
colonies: ReadonlyMap<string, ColonyComputation>,
|
||||
sortedSeasons: readonly SeasonState[],
|
||||
intervalDays: Computation['intervalDays'],
|
||||
nameOf: (apiaryId: string) => string,
|
||||
today: string,
|
||||
): AutopilotToday['inspectionDue'] {
|
||||
return sortedSeasons.flatMap((season) => {
|
||||
const due = [...colonies]
|
||||
.filter(
|
||||
([, c]) =>
|
||||
c.apiaryId === season.apiaryId &&
|
||||
c.view.inspectionDueOn !== null &&
|
||||
c.view.inspectionDueOn <= today,
|
||||
)
|
||||
.map(([id, c]) => ({
|
||||
id,
|
||||
code: c.code,
|
||||
dueOn: c.view.inspectionDueOn ?? today,
|
||||
overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today),
|
||||
}))
|
||||
.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b));
|
||||
return due.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
apiaryId: season.apiaryId,
|
||||
apiaryName: nameOf(season.apiaryId),
|
||||
segment: season.segment,
|
||||
intervalDays:
|
||||
season.segment === 'WINTERRUHE'
|
||||
? null
|
||||
: intervalDays[season.segment],
|
||||
colonies: due,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private mostUrgent(
|
||||
colonies: ReadonlyMap<string, ColonyComputation>,
|
||||
today: string,
|
||||
|
||||
Reference in new issue
Block a user