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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user