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

@@ -37,7 +37,7 @@ describe('runDailyEvaluationSteps', () => {
const order: string[] = [];
const digest = step('digest', () => {
order.push('digest');
return Promise.resolve({ digests: 3 });
return Promise.resolve({ digestsWritten: 3 });
});
const timeEvents = step('time-events', () => {
order.push('time-events');
@@ -52,7 +52,7 @@ describe('runDailyEvaluationSteps', () => {
expect(order).toEqual(['time-events', 'digest']);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ TaskDue: 1, digests: 3 });
expect(result).toEqual({ TaskDue: 1, digestsWritten: 3 });
expect(failed).toEqual([]);
});
@@ -68,7 +68,7 @@ describe('runDailyEvaluationSteps', () => {
const autopilot = step('autopilot', () =>
Promise.reject(new Error('connection reset')),
);
const digest = step('digest', () => Promise.resolve({ digests: 4 }));
const digest = step('digest', () => Promise.resolve({ digestsWritten: 4 }));
const { result, failed, failures } = await runDailyEvaluationSteps(
[phenology, timeEvents, autopilot, digest],
@@ -83,7 +83,7 @@ describe('runDailyEvaluationSteps', () => {
phenologyApiaries: 2,
WithdrawalEnded: 1,
TaskDue: 0,
digests: 4,
digestsWritten: 4,
});
expect(errorSpy).toHaveBeenCalledWith(
'Step "autopilot" failed: connection reset',
@@ -3,6 +3,7 @@ import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
import type { AutopilotStep } from '../autopilot/autopilot.step';
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import type { DigestStep } from '../reminders/digest.step';
import type { TimeEventsStep } from './steps/time-events.step';
describe('DailyEvaluationJob', () => {
@@ -21,6 +22,7 @@ describe('DailyEvaluationJob', () => {
let timeEvents: { name: 'time-events'; run: jest.Mock };
let autopilot: { name: 'autopilot'; run: jest.Mock };
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
let digest: { name: 'digest'; run: jest.Mock };
let job: DailyEvaluationJob;
beforeEach(() => {
@@ -31,10 +33,13 @@ describe('DailyEvaluationJob', () => {
autopilot.run.mockResolvedValue(AUTOPILOT_COUNTS);
expiry = { name: 'suggestions-expiry', run: jest.fn() };
expiry.run.mockResolvedValue({ suggestionsExpired: 3 });
digest = { name: 'digest', run: jest.fn() };
digest.run.mockResolvedValue({ digestsWritten: 5 });
job = new DailyEvaluationJob(
timeEvents as unknown as TimeEventsStep,
autopilot as unknown as AutopilotStep,
expiry as unknown as SuggestionsExpiryStep,
digest as unknown as DigestStep,
);
});
@@ -52,13 +57,15 @@ describe('DailyEvaluationJob', () => {
...COUNTS,
...AUTOPILOT_COUNTS,
suggestionsExpired: 3,
digestsWritten: 5,
});
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
expect(digest.run).toHaveBeenCalledWith('2026-06-10');
});
it('runs time-events, autopilot and suggestions-expiry in that order', async () => {
it('runs time-events, autopilot, suggestions-expiry and digest in that order', async () => {
await job.run();
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
@@ -67,6 +74,25 @@ describe('DailyEvaluationJob', () => {
expect(autopilot.run.mock.invocationCallOrder[0]).toBeLessThan(
expiry.run.mock.invocationCallOrder[0],
);
expect(expiry.run.mock.invocationCallOrder[0]).toBeLessThan(
digest.run.mock.invocationCallOrder[0],
);
});
it('a failing time-events step does not stop the digest', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
timeEvents.run.mockRejectedValue(new Error('boom'));
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(digest.run).toHaveBeenCalled();
expect(failure).toMatchObject({
failedSteps: ['time-events'],
result: { digestsWritten: 5 },
});
});
it('a failing autopilot step does not stop the expiry step', async () => {
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { AutopilotStep } from '../autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import { berlinToday } from '../common/dates';
import { DigestStep } from '../reminders/digest.step';
import { JobPartialFailure } from '../system/job-run.service';
import {
DailyEvaluationStep,
@@ -27,6 +28,7 @@ export class DailyEvaluationJob implements Job {
private readonly timeEvents: TimeEventsStep,
private readonly autopilot: AutopilotStep,
private readonly suggestionsExpiry: SuggestionsExpiryStep,
private readonly digest: DigestStep,
) {}
/** Evaluates the current Berlin day. */
@@ -35,6 +37,7 @@ export class DailyEvaluationJob implements Job {
this.timeEvents,
this.autopilot,
this.suggestionsExpiry,
this.digest,
];
const { result, failed, failures } = await runDailyEvaluationSteps(
steps,
+2
View File
@@ -8,6 +8,7 @@ import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cle
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SeasonModule } from '../season/season.module';
import { SystemModule } from '../system/system.module';
import { TasksModule } from '../tasks/tasks.module';
@@ -49,6 +50,7 @@ const JOB_CLASSES = [
SystemModule,
SeasonModule,
AutopilotModule,
RemindersModule,
],
providers: [
...JOB_CLASSES,