diff --git a/backend/src/modules/planner/engine.ts b/backend/src/modules/planner/engine.ts new file mode 100644 index 0000000..649b42a --- /dev/null +++ b/backend/src/modules/planner/engine.ts @@ -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); +} diff --git a/backend/src/modules/planner/planner.test.ts b/backend/src/modules/planner/planner.test.ts new file mode 100644 index 0000000..1805664 --- /dev/null +++ b/backend/src/modules/planner/planner.test.ts @@ -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'); + }); +}); diff --git a/backend/src/modules/planner/routes.ts b/backend/src/modules/planner/routes.ts new file mode 100644 index 0000000..5d4ec77 --- /dev/null +++ b/backend/src/modules/planner/routes.ts @@ -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 }; + }); +} diff --git a/backend/src/modules/planner/types.ts b/backend/src/modules/planner/types.ts new file mode 100644 index 0000000..c2a35ea --- /dev/null +++ b/backend/src/modules/planner/types.ts @@ -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; +export type Activity = z.infer; +export type PlanDayRequest = z.infer; +export type ApplyPlanRequest = z.infer; diff --git a/backend/src/server.ts b/backend/src/server.ts index 4354a7c..aa40d1d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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({