From 3cda171b68d40abbc4476e249f9b36685bbda743 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 18 Apr 2026 18:12:47 -0700 Subject: [PATCH] feat(backend): Phase D.1 smart routine suggestions engine + routes + 6 tests --- backend/src/modules/suggestions/engine.ts | 135 ++++++++++++++++++ backend/src/modules/suggestions/routes.ts | 53 +++++++ .../modules/suggestions/suggestions.test.ts | 116 +++++++++++++++ backend/src/modules/suggestions/types.ts | 39 +++++ backend/src/server.ts | 2 + 5 files changed, 345 insertions(+) create mode 100644 backend/src/modules/suggestions/engine.ts create mode 100644 backend/src/modules/suggestions/routes.ts create mode 100644 backend/src/modules/suggestions/suggestions.test.ts create mode 100644 backend/src/modules/suggestions/types.ts diff --git a/backend/src/modules/suggestions/engine.ts b/backend/src/modules/suggestions/engine.ts new file mode 100644 index 0000000..b67d948 --- /dev/null +++ b/backend/src/modules/suggestions/engine.ts @@ -0,0 +1,135 @@ +/** + * Smart Routine Suggestions engine — Phase D.1. + * + * Analyzes completed timers to detect patterns and suggest routines. + */ + +import type { TimerDoc } from '../timers/types.js'; +import type { RoutineDoc } from '../routines/types.js'; +import type { RoutineSuggestion, SuggestionType } from './types.js'; +import { randomUUID } from 'node:crypto'; + +interface PatternGroup { + label: string; + category?: string; + count: number; + avgDurationMs: number; + urgency: string; +} + +/** + * Generate suggestions from usage patterns. + */ +export function generateSuggestions( + completedTimers: TimerDoc[], + routines: RoutineDoc[], + maxSuggestions: number, +): { suggestions: RoutineSuggestion[]; analyzedTimers: number; analyzedRoutines: number } { + const suggestions: RoutineSuggestion[] = []; + const now = new Date().toISOString(); + + // ── Pattern 1: Repeated timer labels → suggest routine creation ── + const labelCounts = new Map(); + for (const t of completedTimers) { + const key = t.label.toLowerCase().trim(); + const existing = labelCounts.get(key); + if (existing) { + existing.count++; + existing.avgDurationMs = (existing.avgDurationMs * (existing.count - 1) + t.duration) / existing.count; + } else { + labelCounts.set(key, { + label: t.label, + category: t.category, + count: 1, + avgDurationMs: t.duration, + urgency: t.urgency, + }); + } + } + + const repeatedLabels = [...labelCounts.values()] + .filter(g => g.count >= 3) + .sort((a, b) => b.count - a.count); + + for (const group of repeatedLabels.slice(0, 2)) { + const existingRoutine = routines.find(r => + r.name.toLowerCase().includes(group.label.toLowerCase()) + ); + if (!existingRoutine) { + suggestions.push(makeSuggestion('routine_creation', + `Create a "${group.label}" routine`, + `You've used "${group.label}" ${group.count} times. Consider making it a routine with a ${Math.round(group.avgDurationMs / 60_000)}m timer.`, + Math.min(0.9, 0.5 + group.count * 0.05), + { label: group.label, avgDurationMinutes: Math.round(group.avgDurationMs / 60_000), category: group.category }, + now, + )); + } + } + + // ── Pattern 2: Long continuous focus → suggest break reminders ── + const longTimers = completedTimers.filter(t => + t.type !== 'pomodoro' && t.duration > 90 * 60_000 + ); + if (longTimers.length >= 2) { + suggestions.push(makeSuggestion('break_reminder', + 'Add break reminders for long sessions', + `You completed ${longTimers.length} sessions over 90 minutes. Consider using Pomodoro mode or adding cascade pre-warnings.`, + 0.7, + { longSessionCount: longTimers.length }, + now, + )); + } + + // ── Pattern 3: Similar category timers → suggest merge ── + const byCat = new Map(); + for (const t of completedTimers) { + if (t.category) { + const arr = byCat.get(t.category) || []; + arr.push(t); + byCat.set(t.category, arr); + } + } + for (const [cat, timers] of byCat) { + const uniqueLabels = new Set(timers.map(t => t.label.toLowerCase().trim())); + if (uniqueLabels.size >= 3 && timers.length >= 5) { + suggestions.push(makeSuggestion('routine_merge', + `Combine "${cat}" timers into a routine`, + `You have ${uniqueLabels.size} different "${cat}" timers. Merging them into a multi-step routine could save setup time.`, + 0.6, + { category: cat, uniqueLabels: [...uniqueLabels], totalCount: timers.length }, + now, + )); + } + } + + // ── Pattern 4: Aggressive cascade on gentle timers ── + const misconfigured = completedTimers.filter(t => + t.urgency === 'gentle' && t.cascade?.preset === 'aggressive' + ); + if (misconfigured.length >= 2) { + suggestions.push(makeSuggestion('cascade_adjustment', + 'Reduce cascade intensity for gentle timers', + `${misconfigured.length} gentle-urgency timers used aggressive cascades. Switch to "minimal" or "standard" for a calmer experience.`, + 0.65, + { count: misconfigured.length, labels: misconfigured.slice(0, 3).map(t => t.label) }, + now, + )); + } + + return { + suggestions: suggestions.slice(0, maxSuggestions), + analyzedTimers: completedTimers.length, + analyzedRoutines: routines.length, + }; +} + +function makeSuggestion( + type: SuggestionType, + title: string, + description: string, + confidence: number, + data: Record, + createdAt: string, +): RoutineSuggestion { + return { id: randomUUID(), type, title, description, confidence, data, createdAt }; +} diff --git a/backend/src/modules/suggestions/routes.ts b/backend/src/modules/suggestions/routes.ts new file mode 100644 index 0000000..80ea9a2 --- /dev/null +++ b/backend/src/modules/suggestions/routes.ts @@ -0,0 +1,53 @@ +/** + * Smart Routine Suggestions routes — Phase D.1. + * + * GET /suggestions — analyze usage patterns and return suggestions + */ + +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 { generateSuggestions } from './engine.js'; +import { GetSuggestionsSchema } from './types.js'; +import * as timerRepo from '../timers/repository.js'; +import * as routineRepo from '../routines/repository.js'; + +export async function suggestionRoutes(app: FastifyInstance) { + app.addHook('onRequest', async (_req, reply) => { + if (!isFeatureEnabled('smart_suggestions.enabled')) { + reply.code(400); + throw new BadRequestError('Smart suggestions are not enabled'); + } + }); + + app.get('/suggestions', async req => { + const auth = await extractAuth(req); + const parsed = GetSuggestionsSchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + // Load completed timers from the lookback window + const since = new Date(Date.now() - parsed.data.lookbackDays * 86_400_000).toISOString(); + const { items: allTimers } = await timerRepo.listTimers(auth.sub, PRODUCT_ID, { + sortBy: 'createdAt', + sortOrder: 'desc', + limit: 100, + offset: 0, + }); + const completedTimers = allTimers.filter(t => + ['completed', 'dismissed', 'fired'].includes(t.state) && t.createdAt >= since + ); + + const { items: routines } = await routineRepo.listRoutines(auth.sub, PRODUCT_ID, { + sortBy: 'createdAt', + sortOrder: 'desc', + limit: 50, + offset: 0, + }); + + return generateSuggestions(completedTimers, routines, parsed.data.maxSuggestions); + }); +} diff --git a/backend/src/modules/suggestions/suggestions.test.ts b/backend/src/modules/suggestions/suggestions.test.ts new file mode 100644 index 0000000..33e293f --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for Smart Routine Suggestions engine — Phase D.1. + */ + +import { describe, it, expect } from 'vitest'; +import { generateSuggestions } from './engine.js'; +import type { TimerDoc } from '../timers/types.js'; +import type { RoutineDoc } from '../routines/types.js'; + +function makeTimer(label: string, overrides: Partial = {}): TimerDoc { + return { + id: `t-${label}-${Math.random()}`, + userId: 'u1', + productId: 'chronomind', + label, + type: 'countdown', + state: 'completed', + urgency: 'standard', + duration: 30 * 60_000, + targetTime: '2026-01-15T10:00:00.000Z', + createdAt: '2026-01-15T09:30:00.000Z', + syncVersion: 1, + ...overrides, + }; +} + +function makeRoutine(name: string): RoutineDoc { + return { + id: `r-${name}`, + userId: 'u1', + productId: 'chronomind', + name, + steps: [], + totalDurationMinutes: 30, + status: 'ready', + currentStepIndex: 0, + isTemplate: false, + createdAt: '2026-01-15T00:00:00.000Z', + elapsedBeforePause: 0, + syncVersion: 1, + }; +} + +describe('generateSuggestions', () => { + it('suggests routine creation for repeated labels', () => { + const timers = [ + makeTimer('Morning Standup'), + makeTimer('Morning Standup'), + makeTimer('Morning Standup'), + makeTimer('Morning Standup'), + ]; + + const result = generateSuggestions(timers, [], 5); + + expect(result.suggestions.length).toBeGreaterThanOrEqual(1); + const routineSug = result.suggestions.find(s => s.type === 'routine_creation'); + expect(routineSug).toBeDefined(); + expect(routineSug!.title).toContain('Morning Standup'); + expect(routineSug!.confidence).toBeGreaterThan(0.5); + }); + + it('does not suggest routine if one already exists', () => { + const timers = [ + makeTimer('Morning Standup'), + makeTimer('Morning Standup'), + makeTimer('Morning Standup'), + ]; + const routines = [makeRoutine('Morning Standup Routine')]; + + const result = generateSuggestions(timers, routines, 5); + const routineSug = result.suggestions.find(s => + s.type === 'routine_creation' && s.title.toLowerCase().includes('morning standup') + ); + expect(routineSug).toBeUndefined(); + }); + + it('suggests break reminders for long sessions', () => { + const timers = [ + makeTimer('Deep Work', { duration: 120 * 60_000 }), + makeTimer('Research', { duration: 100 * 60_000 }), + ]; + + const result = generateSuggestions(timers, [], 5); + const breakSug = result.suggestions.find(s => s.type === 'break_reminder'); + expect(breakSug).toBeDefined(); + }); + + it('suggests cascade adjustment for misconfigured timers', () => { + const timers = [ + makeTimer('Gentle A', { urgency: 'gentle', cascade: { preset: 'aggressive', intervals: [] } }), + makeTimer('Gentle B', { urgency: 'gentle', cascade: { preset: 'aggressive', intervals: [] } }), + ]; + + const result = generateSuggestions(timers, [], 5); + const cascadeSug = result.suggestions.find(s => s.type === 'cascade_adjustment'); + expect(cascadeSug).toBeDefined(); + }); + + it('respects maxSuggestions limit', () => { + const timers = Array.from({ length: 20 }, (_, i) => + makeTimer(`Task ${i % 3}`, { category: 'work' }) + ); + + const result = generateSuggestions(timers, [], 2); + expect(result.suggestions.length).toBeLessThanOrEqual(2); + }); + + it('returns counts of analyzed items', () => { + const timers = [makeTimer('A'), makeTimer('B')]; + const routines = [makeRoutine('R1')]; + + const result = generateSuggestions(timers, routines, 5); + expect(result.analyzedTimers).toBe(2); + expect(result.analyzedRoutines).toBe(1); + }); +}); diff --git a/backend/src/modules/suggestions/types.ts b/backend/src/modules/suggestions/types.ts new file mode 100644 index 0000000..f0e5fb0 --- /dev/null +++ b/backend/src/modules/suggestions/types.ts @@ -0,0 +1,39 @@ +/** + * Smart Routine Suggestions types — Phase D.1. + * + * Analyzes user's completed timers/routines and suggests improvements. + */ + +import { z } from 'zod'; + +export const SuggestionTypes = [ + 'routine_creation', + 'time_optimization', + 'break_reminder', + 'routine_merge', + 'cascade_adjustment', +] as const; +export type SuggestionType = (typeof SuggestionTypes)[number]; + +export interface RoutineSuggestion { + id: string; + type: SuggestionType; + title: string; + description: string; + confidence: number; + data: Record; + createdAt: string; +} + +export interface SuggestionsResponse { + suggestions: RoutineSuggestion[]; + analyzedTimers: number; + analyzedRoutines: number; +} + +export const GetSuggestionsSchema = z.object({ + lookbackDays: z.coerce.number().int().min(1).max(90).default(14), + maxSuggestions: z.coerce.number().int().min(1).max(10).default(5), +}); + +export type GetSuggestionsInput = z.infer; diff --git a/backend/src/server.ts b/backend/src/server.ts index aa40d1d..abb0ccb 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,6 +14,7 @@ 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 { suggestionRoutes } from './modules/suggestions/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { initDatastore } from './lib/datastore.js'; import { initEncryption } from './lib/field-encrypt.js'; @@ -61,6 +62,7 @@ await app.register(sharedTimerRoutes, { prefix: '/api' }); await app.register(webhookRoutes, { prefix: '/api' }); await app.register(agentActionRoutes, { prefix: '/api' }); await app.register(plannerRoutes, { prefix: '/api' }); +await app.register(suggestionRoutes, { prefix: '/api' }); // ── Phase A.4: Context-aware AI messages ───────────────────────── const ContextMessageSchema = z.object({