feat(backend): Phase B.1 day planner engine + routes + 11 tests
This commit is contained in:
parent
0240d3c807
commit
e021e96c80
199
backend/src/modules/planner/engine.ts
Normal file
199
backend/src/modules/planner/engine.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Day Planner engine — deterministic slot-fitting algorithm.
|
||||||
|
*
|
||||||
|
* 1. Load existing timers for the target day
|
||||||
|
* 2. Compute available slots (gaps between timers)
|
||||||
|
* 3. Fit requested activities into slots respecting constraints + priority order
|
||||||
|
* 4. Assign urgency based on priority
|
||||||
|
* 5. Assign cascade presets based on urgency
|
||||||
|
* 6. Add prep time buffers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Activity, PlanDayRequest, ProposedTimer, PlanDayResponse } from './types.js';
|
||||||
|
import type { TimerDoc } from '../timers/types.js';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
interface TimeSlot {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a proposed day plan.
|
||||||
|
*/
|
||||||
|
export function planDay(
|
||||||
|
request: PlanDayRequest,
|
||||||
|
existingTimers: TimerDoc[],
|
||||||
|
): PlanDayResponse {
|
||||||
|
const dateStr = request.date;
|
||||||
|
const dayStart = new Date(`${dateStr}T${pad(request.dayStartHour)}:00:00.000Z`).getTime();
|
||||||
|
const dayEnd = new Date(`${dateStr}T${pad(request.dayEndHour)}:00:00.000Z`).getTime();
|
||||||
|
const prepMs = request.prepTimeMinutes * 60_000;
|
||||||
|
|
||||||
|
// Collect occupied intervals from existing timers on this day
|
||||||
|
const occupied: TimeSlot[] = existingTimers
|
||||||
|
.filter(t => {
|
||||||
|
const tt = new Date(t.targetTime).getTime();
|
||||||
|
return tt >= dayStart && tt <= dayEnd && !['dismissed', 'completed'].includes(t.state);
|
||||||
|
})
|
||||||
|
.map(t => {
|
||||||
|
const start = new Date(t.targetTime).getTime() - t.duration;
|
||||||
|
const end = new Date(t.targetTime).getTime();
|
||||||
|
return { start: Math.max(start, dayStart), end: Math.min(end, dayEnd) };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
// Compute free slots
|
||||||
|
const freeSlots: TimeSlot[] = [];
|
||||||
|
let cursor = dayStart;
|
||||||
|
for (const occ of occupied) {
|
||||||
|
if (cursor < occ.start) {
|
||||||
|
freeSlots.push({ start: cursor, end: occ.start });
|
||||||
|
}
|
||||||
|
cursor = Math.max(cursor, occ.end);
|
||||||
|
}
|
||||||
|
if (cursor < dayEnd) {
|
||||||
|
freeSlots.push({ start: cursor, end: dayEnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort activities by priority (high first), then by constraint order
|
||||||
|
const sorted = [...request.activities].sort((a, b) => {
|
||||||
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||||
|
return (priorityOrder[a.priority ?? 'medium'] ?? 1) - (priorityOrder[b.priority ?? 'medium'] ?? 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve constraints: "after" a time or reference
|
||||||
|
const resolvedActivities = sorted.map(act => {
|
||||||
|
let earliestStart = dayStart;
|
||||||
|
if (act.constraints) {
|
||||||
|
for (const c of act.constraints) {
|
||||||
|
if (c.type === 'after' && c.referenceTime) {
|
||||||
|
const refMs = parseTimeToMs(c.referenceTime, dateStr);
|
||||||
|
if (refMs !== null) earliestStart = Math.max(earliestStart, refMs);
|
||||||
|
}
|
||||||
|
if (c.type === 'before' && c.referenceTime) {
|
||||||
|
// handled during slot selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...act, earliestStart };
|
||||||
|
});
|
||||||
|
|
||||||
|
const proposed: ProposedTimer[] = [];
|
||||||
|
const overflow: ProposedTimer[] = [];
|
||||||
|
const usedSlots: TimeSlot[] = [];
|
||||||
|
|
||||||
|
for (const act of resolvedActivities) {
|
||||||
|
const needMs = (act.durationMinutes + request.prepTimeMinutes) * 60_000;
|
||||||
|
let latestEnd = dayEnd;
|
||||||
|
|
||||||
|
if (act.constraints) {
|
||||||
|
for (const c of act.constraints) {
|
||||||
|
if (c.type === 'before' && c.referenceTime) {
|
||||||
|
const refMs = parseTimeToMs(c.referenceTime, dateStr);
|
||||||
|
if (refMs !== null) latestEnd = Math.min(latestEnd, refMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first free slot that fits
|
||||||
|
let placed = false;
|
||||||
|
for (const slot of freeSlots) {
|
||||||
|
const effectiveStart = Math.max(slot.start, act.earliestStart);
|
||||||
|
const effectiveEnd = Math.min(slot.end, latestEnd);
|
||||||
|
const availableMs = effectiveEnd - effectiveStart;
|
||||||
|
|
||||||
|
if (availableMs >= needMs && !overlapsUsed(effectiveStart, effectiveStart + needMs, usedSlots)) {
|
||||||
|
const actStart = effectiveStart + prepMs;
|
||||||
|
const actEnd = actStart + act.durationMinutes * 60_000;
|
||||||
|
|
||||||
|
proposed.push(toProposed(act, actStart, actEnd));
|
||||||
|
usedSlots.push({ start: effectiveStart, end: actEnd });
|
||||||
|
placed = true;
|
||||||
|
|
||||||
|
// Shrink the free slot
|
||||||
|
slot.start = actEnd;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!placed) {
|
||||||
|
// Overflow — doesn't fit
|
||||||
|
overflow.push(toProposed(act, 0, act.durationMinutes * 60_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort proposed by start time
|
||||||
|
proposed.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||||
|
|
||||||
|
return {
|
||||||
|
planId: randomUUID(),
|
||||||
|
date: dateStr,
|
||||||
|
proposed,
|
||||||
|
overflow,
|
||||||
|
totalMinutesPlanned: proposed.reduce((s, p) => s + p.durationMinutes, 0),
|
||||||
|
totalMinutesOverflow: overflow.reduce((s, p) => s + p.durationMinutes, 0),
|
||||||
|
existingTimerCount: occupied.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return n.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProposed(act: Activity & { earliestStart?: number }, startMs: number, endMs: number): ProposedTimer {
|
||||||
|
const urgency = act.urgency ?? priorityToUrgency(act.priority ?? 'medium');
|
||||||
|
return {
|
||||||
|
label: act.label,
|
||||||
|
startTime: startMs > 0 ? new Date(startMs).toISOString() : '',
|
||||||
|
endTime: endMs > 0 && startMs > 0 ? new Date(endMs).toISOString() : '',
|
||||||
|
durationMinutes: act.durationMinutes,
|
||||||
|
priority: act.priority ?? 'medium',
|
||||||
|
urgency,
|
||||||
|
category: act.category,
|
||||||
|
cascade: urgencyToCascade(urgency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityToUrgency(priority: string): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': return 'important';
|
||||||
|
case 'low': return 'gentle';
|
||||||
|
default: return 'standard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyToCascade(urgency: string): string {
|
||||||
|
switch (urgency) {
|
||||||
|
case 'critical': return 'aggressive';
|
||||||
|
case 'important': return 'standard';
|
||||||
|
case 'gentle':
|
||||||
|
case 'passive': return 'minimal';
|
||||||
|
default: return 'standard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeToMs(timeStr: string, dateStr: string): number | null {
|
||||||
|
// Accept "HH:MM", "2pm", "14:00", "2:30pm"
|
||||||
|
const isoMatch = timeStr.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
if (isoMatch) {
|
||||||
|
const h = parseInt(isoMatch[1], 10);
|
||||||
|
const m = parseInt(isoMatch[2], 10);
|
||||||
|
return new Date(`${dateStr}T${pad(h)}:${pad(m)}:00.000Z`).getTime();
|
||||||
|
}
|
||||||
|
const ampmMatch = timeStr.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
|
||||||
|
if (ampmMatch) {
|
||||||
|
let h = parseInt(ampmMatch[1], 10);
|
||||||
|
const m = parseInt(ampmMatch[2] || '0', 10);
|
||||||
|
if (ampmMatch[3].toLowerCase() === 'pm' && h < 12) h += 12;
|
||||||
|
if (ampmMatch[3].toLowerCase() === 'am' && h === 12) h = 0;
|
||||||
|
return new Date(`${dateStr}T${pad(h)}:${pad(m)}:00.000Z`).getTime();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapsUsed(start: number, end: number, used: TimeSlot[]): boolean {
|
||||||
|
return used.some(u => start < u.end && end > u.start);
|
||||||
|
}
|
||||||
249
backend/src/modules/planner/planner.test.ts
Normal file
249
backend/src/modules/planner/planner.test.ts
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Day Planner engine + routes — Phase B.1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { planDay } from './engine.js';
|
||||||
|
import type { PlanDayRequest } from './types.js';
|
||||||
|
import type { TimerDoc } from '../timers/types.js';
|
||||||
|
|
||||||
|
function makeTimer(label: string, targetTime: string, durationMs: number, state = 'active'): TimerDoc {
|
||||||
|
return {
|
||||||
|
id: `timer-${label}`,
|
||||||
|
userId: 'u1',
|
||||||
|
productId: 'chronomind',
|
||||||
|
label,
|
||||||
|
type: 'countdown',
|
||||||
|
state: state as TimerDoc['state'],
|
||||||
|
urgency: 'standard',
|
||||||
|
duration: durationMs,
|
||||||
|
targetTime,
|
||||||
|
createdAt: '2026-01-15T00:00:00.000Z',
|
||||||
|
syncVersion: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('planDay — empty schedule', () => {
|
||||||
|
it('places all activities sequentially', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 5,
|
||||||
|
activities: [
|
||||||
|
{ label: 'Meeting', durationMinutes: 30, priority: 'high' },
|
||||||
|
{ label: 'Deep Work', durationMinutes: 60, priority: 'medium' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
|
||||||
|
expect(result.proposed).toHaveLength(2);
|
||||||
|
expect(result.overflow).toHaveLength(0);
|
||||||
|
expect(result.proposed[0].label).toBe('Meeting');
|
||||||
|
expect(result.proposed[1].label).toBe('Deep Work');
|
||||||
|
expect(result.totalMinutesPlanned).toBe(90);
|
||||||
|
expect(result.existingTimerCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects prep time', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 10,
|
||||||
|
activities: [
|
||||||
|
{ label: 'Task A', durationMinutes: 30, priority: 'medium' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
const start = new Date(result.proposed[0].startTime).getTime();
|
||||||
|
const dayStart = new Date('2026-01-15T08:00:00.000Z').getTime();
|
||||||
|
|
||||||
|
// Should start 10 minutes after day start (prep time)
|
||||||
|
expect(start - dayStart).toBe(10 * 60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planDay — existing timers', () => {
|
||||||
|
it('avoids occupied slots', () => {
|
||||||
|
const timers = [
|
||||||
|
// Timer from 08:00 to 09:00 (duration 1h, targetTime at end)
|
||||||
|
makeTimer('Existing', '2026-01-15T09:00:00.000Z', 3_600_000),
|
||||||
|
];
|
||||||
|
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'New Task', durationMinutes: 30, priority: 'medium' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, timers);
|
||||||
|
|
||||||
|
expect(result.proposed).toHaveLength(1);
|
||||||
|
expect(result.existingTimerCount).toBe(1);
|
||||||
|
// Should start at or after 09:00 (after existing timer)
|
||||||
|
const startMs = new Date(result.proposed[0].startTime).getTime();
|
||||||
|
expect(startMs).toBeGreaterThanOrEqual(new Date('2026-01-15T09:00:00.000Z').getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores dismissed timers', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer('Dismissed', '2026-01-15T09:00:00.000Z', 3_600_000, 'dismissed'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'New Task', durationMinutes: 30, priority: 'medium' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, timers);
|
||||||
|
expect(result.existingTimerCount).toBe(0);
|
||||||
|
const startMs = new Date(result.proposed[0].startTime).getTime();
|
||||||
|
expect(startMs).toBe(new Date('2026-01-15T08:00:00.000Z').getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planDay — priority ordering', () => {
|
||||||
|
it('high priority activities placed first', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'Low', durationMinutes: 30, priority: 'low' },
|
||||||
|
{ label: 'High', durationMinutes: 30, priority: 'high' },
|
||||||
|
{ label: 'Medium', durationMinutes: 30, priority: 'medium' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
// All placed, earliest start should be the highest priority
|
||||||
|
expect(result.proposed[0].label).toBe('High');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planDay — overflow', () => {
|
||||||
|
it('overflows when not enough time', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 9, // Only 1 hour
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'Big Task', durationMinutes: 45, priority: 'high' },
|
||||||
|
{ label: 'Another', durationMinutes: 30, priority: 'medium' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
expect(result.proposed).toHaveLength(1);
|
||||||
|
expect(result.overflow).toHaveLength(1);
|
||||||
|
expect(result.overflow[0].label).toBe('Another');
|
||||||
|
expect(result.totalMinutesOverflow).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planDay — constraints', () => {
|
||||||
|
it('respects "after" time constraint', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'Afternoon Task', durationMinutes: 30, priority: 'medium', constraints: [{ type: 'after', referenceTime: '14:00' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
expect(result.proposed).toHaveLength(1);
|
||||||
|
const startMs = new Date(result.proposed[0].startTime).getTime();
|
||||||
|
expect(startMs).toBeGreaterThanOrEqual(new Date('2026-01-15T14:00:00.000Z').getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects "before" time constraint', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'Morning Task', durationMinutes: 30, priority: 'medium', constraints: [{ type: 'before', referenceTime: '10:00' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
expect(result.proposed).toHaveLength(1);
|
||||||
|
const endMs = new Date(result.proposed[0].endTime).getTime();
|
||||||
|
expect(endMs).toBeLessThanOrEqual(new Date('2026-01-15T10:00:00.000Z').getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planDay — urgency + cascade assignment', () => {
|
||||||
|
it('assigns urgency based on priority', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'High', durationMinutes: 30, priority: 'high' },
|
||||||
|
{ label: 'Low', durationMinutes: 30, priority: 'low' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
const highTimer = result.proposed.find(p => p.label === 'High')!;
|
||||||
|
const lowTimer = result.proposed.find(p => p.label === 'Low')!;
|
||||||
|
|
||||||
|
expect(highTimer.urgency).toBe('important');
|
||||||
|
expect(highTimer.cascade).toBe('standard');
|
||||||
|
expect(lowTimer.urgency).toBe('gentle');
|
||||||
|
expect(lowTimer.cascade).toBe('minimal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects explicit urgency override', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [
|
||||||
|
{ label: 'Critical', durationMinutes: 30, priority: 'medium', urgency: 'critical' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
expect(result.proposed[0].urgency).toBe('critical');
|
||||||
|
expect(result.proposed[0].cascade).toBe('aggressive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planDay — metadata', () => {
|
||||||
|
it('returns a planId', () => {
|
||||||
|
const req: PlanDayRequest = {
|
||||||
|
date: '2026-01-15',
|
||||||
|
dayStartHour: 8,
|
||||||
|
dayEndHour: 22,
|
||||||
|
prepTimeMinutes: 0,
|
||||||
|
activities: [{ label: 'Task', durationMinutes: 30, priority: 'medium' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = planDay(req, []);
|
||||||
|
expect(result.planId).toBeTruthy();
|
||||||
|
expect(typeof result.planId).toBe('string');
|
||||||
|
expect(result.date).toBe('2026-01-15');
|
||||||
|
});
|
||||||
|
});
|
||||||
99
backend/src/modules/planner/routes.ts
Normal file
99
backend/src/modules/planner/routes.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Day Planner REST endpoints — Phase B.1.
|
||||||
|
*
|
||||||
|
* POST /planner/plan-day — generate proposed day plan
|
||||||
|
* POST /planner/apply-plan — apply a proposed plan (create timers)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { BadRequestError } from '@bytelyst/errors';
|
||||||
|
import { extractAuth } from '../../lib/auth.js';
|
||||||
|
import { isFeatureEnabled } from '../../lib/feature-flags.js';
|
||||||
|
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||||
|
import { trackEvent } from '../../lib/telemetry.js';
|
||||||
|
import { PLANNER_DAY_PLANNED, PLANNER_PLAN_APPLIED, PLANNER_PLAN_REJECTED } from '../../lib/telemetry-events.js';
|
||||||
|
import { planDay } from './engine.js';
|
||||||
|
import { PlanDayRequestSchema, ApplyPlanRequestSchema } from './types.js';
|
||||||
|
import * as timerRepo from '../timers/repository.js';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export async function plannerRoutes(app: FastifyInstance) {
|
||||||
|
// ── Feature flag gate ─────────────────────────────────────
|
||||||
|
app.addHook('onRequest', async (_req, reply) => {
|
||||||
|
if (!isFeatureEnabled('day_planner.enabled')) {
|
||||||
|
reply.code(400);
|
||||||
|
throw new BadRequestError('Day planner is not enabled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate day plan
|
||||||
|
app.post('/planner/plan-day', async req => {
|
||||||
|
const auth = await extractAuth(req);
|
||||||
|
const parsed = PlanDayRequestSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing timers for the date range
|
||||||
|
const dayStart = `${parsed.data.date}T00:00:00.000Z`;
|
||||||
|
const dayEnd = `${parsed.data.date}T23:59:59.999Z`;
|
||||||
|
const { items: existingTimers } = await timerRepo.listTimers(auth.sub, PRODUCT_ID, {
|
||||||
|
sortBy: 'targetTime',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const dayTimers = existingTimers.filter(t => {
|
||||||
|
return t.targetTime >= dayStart && t.targetTime <= dayEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = planDay(parsed.data, dayTimers);
|
||||||
|
|
||||||
|
req.log.info({ planId: plan.planId, proposed: plan.proposed.length, overflow: plan.overflow.length }, 'Day plan generated');
|
||||||
|
trackEvent(PLANNER_DAY_PLANNED, auth.sub, { planId: plan.planId, activityCount: parsed.data.activities.length, proposedCount: plan.proposed.length });
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply a proposed plan — creates timers from the proposed list
|
||||||
|
app.post('/planner/apply-plan', async (req, reply) => {
|
||||||
|
const auth = await extractAuth(req);
|
||||||
|
const parsed = ApplyPlanRequestSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
for (const p of parsed.data.proposed) {
|
||||||
|
const timerId = randomUUID();
|
||||||
|
const startMs = new Date(p.startTime).getTime();
|
||||||
|
const endMs = new Date(p.endTime).getTime();
|
||||||
|
|
||||||
|
await timerRepo.createTimer({
|
||||||
|
id: timerId,
|
||||||
|
userId: auth.sub,
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
label: p.label,
|
||||||
|
type: 'countdown',
|
||||||
|
state: 'active',
|
||||||
|
urgency: (p.urgency as 'standard') || 'standard',
|
||||||
|
duration: endMs - startMs,
|
||||||
|
targetTime: p.endTime,
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: p.startTime,
|
||||||
|
cascade: { preset: p.cascade as 'standard', intervals: [] },
|
||||||
|
category: p.category,
|
||||||
|
syncVersion: 1,
|
||||||
|
});
|
||||||
|
created.push(timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.log.info({ planId: parsed.data.planId, count: created.length }, 'Plan applied — timers created');
|
||||||
|
trackEvent(PLANNER_PLAN_APPLIED, auth.sub, { planId: parsed.data.planId, timerCount: created.length });
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return { planId: parsed.data.planId, created };
|
||||||
|
});
|
||||||
|
}
|
||||||
79
backend/src/modules/planner/types.ts
Normal file
79
backend/src/modules/planner/types.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Day Planner types — Phase B.1.
|
||||||
|
*
|
||||||
|
* Generates proposed timer placements for a day based on activity requests,
|
||||||
|
* existing timers, and constraints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ── Activity request (input) ──
|
||||||
|
|
||||||
|
export const PlanConstraintSchema = z.object({
|
||||||
|
type: z.enum(['after', 'before', 'back_to_back']),
|
||||||
|
referenceLabel: z.string().max(500).optional(),
|
||||||
|
referenceTime: z.string().max(64).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ActivitySchema = z.object({
|
||||||
|
label: z.string().min(1).max(500),
|
||||||
|
durationMinutes: z.number().int().min(1).max(480),
|
||||||
|
priority: z.enum(['high', 'medium', 'low']).default('medium'),
|
||||||
|
category: z.string().max(128).optional(),
|
||||||
|
urgency: z.enum(['critical', 'important', 'standard', 'gentle', 'passive']).optional(),
|
||||||
|
constraints: z.array(PlanConstraintSchema).max(5).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PlanDayRequestSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'),
|
||||||
|
dayStartHour: z.number().int().min(0).max(23).default(8),
|
||||||
|
dayEndHour: z.number().int().min(1).max(24).default(22),
|
||||||
|
activities: z.array(ActivitySchema).min(1).max(20),
|
||||||
|
prepTimeMinutes: z.number().int().min(0).max(30).default(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Proposed timer (output) ──
|
||||||
|
|
||||||
|
export interface ProposedTimer {
|
||||||
|
label: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
urgency: string;
|
||||||
|
category?: string;
|
||||||
|
cascade: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanDayResponse {
|
||||||
|
planId: string;
|
||||||
|
date: string;
|
||||||
|
proposed: ProposedTimer[];
|
||||||
|
overflow: ProposedTimer[];
|
||||||
|
totalMinutesPlanned: number;
|
||||||
|
totalMinutesOverflow: number;
|
||||||
|
existingTimerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply plan ──
|
||||||
|
|
||||||
|
export const ApplyPlanRequestSchema = z.object({
|
||||||
|
planId: z.string().min(1).max(128),
|
||||||
|
proposed: z.array(z.object({
|
||||||
|
label: z.string().min(1).max(500),
|
||||||
|
startTime: z.string().datetime(),
|
||||||
|
endTime: z.string().datetime(),
|
||||||
|
durationMinutes: z.number().int().min(1),
|
||||||
|
priority: z.enum(['high', 'medium', 'low']),
|
||||||
|
urgency: z.string().max(64),
|
||||||
|
category: z.string().max(128).optional(),
|
||||||
|
cascade: z.string().max(64),
|
||||||
|
})).min(1).max(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Inferred types ──
|
||||||
|
|
||||||
|
export type PlanConstraint = z.infer<typeof PlanConstraintSchema>;
|
||||||
|
export type Activity = z.infer<typeof ActivitySchema>;
|
||||||
|
export type PlanDayRequest = z.infer<typeof PlanDayRequestSchema>;
|
||||||
|
export type ApplyPlanRequest = z.infer<typeof ApplyPlanRequestSchema>;
|
||||||
@ -13,6 +13,7 @@ import { householdRoutes } from './modules/households/routes.js';
|
|||||||
import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
|
import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
|
||||||
import { webhookRoutes } from './modules/webhooks/routes.js';
|
import { webhookRoutes } from './modules/webhooks/routes.js';
|
||||||
import { agentActionRoutes } from './modules/agent-actions/routes.js';
|
import { agentActionRoutes } from './modules/agent-actions/routes.js';
|
||||||
|
import { plannerRoutes } from './modules/planner/routes.js';
|
||||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { initDatastore } from './lib/datastore.js';
|
import { initDatastore } from './lib/datastore.js';
|
||||||
import { initEncryption } from './lib/field-encrypt.js';
|
import { initEncryption } from './lib/field-encrypt.js';
|
||||||
@ -59,6 +60,7 @@ await app.register(householdRoutes, { prefix: '/api' });
|
|||||||
await app.register(sharedTimerRoutes, { prefix: '/api' });
|
await app.register(sharedTimerRoutes, { prefix: '/api' });
|
||||||
await app.register(webhookRoutes, { prefix: '/api' });
|
await app.register(webhookRoutes, { prefix: '/api' });
|
||||||
await app.register(agentActionRoutes, { prefix: '/api' });
|
await app.register(agentActionRoutes, { prefix: '/api' });
|
||||||
|
await app.register(plannerRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
// ── Phase A.4: Context-aware AI messages ─────────────────────────
|
// ── Phase A.4: Context-aware AI messages ─────────────────────────
|
||||||
const ContextMessageSchema = z.object({
|
const ContextMessageSchema = z.object({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user