feat(backend): Phase B.1 day planner engine + routes + 11 tests

This commit is contained in:
saravanakumardb1 2026-04-18 18:03:55 -07:00
parent 0240d3c807
commit e021e96c80
5 changed files with 628 additions and 0 deletions

View 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);
}

View 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');
});
});

View 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 };
});
}

View 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>;

View File

@ -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({