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 { webhookRoutes } from './modules/webhooks/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 { initDatastore } from './lib/datastore.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(webhookRoutes, { prefix: '/api' });
|
||||
await app.register(agentActionRoutes, { prefix: '/api' });
|
||||
await app.register(plannerRoutes, { prefix: '/api' });
|
||||
|
||||
// ── Phase A.4: Context-aware AI messages ─────────────────────────
|
||||
const ContextMessageSchema = z.object({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user