diff --git a/src/v1/curator/curator-events.ts b/src/v1/curator/curator-events.ts index 2ab01e7..579bb99 100644 --- a/src/v1/curator/curator-events.ts +++ b/src/v1/curator/curator-events.ts @@ -1,5 +1,22 @@ import { recordGrowEvent } from "../../events/record-grow-event.js"; +function curatorDedupeKey(input: { + userId: string; + type: string; + payload?: Record; +}) { + const payload = input.payload ?? {}; + const stableId = + payload.taskId ?? + payload.sprintId ?? + payload.startDate ?? + payload.sourceEventId ?? + payload.eventId ?? + payload.date; + + return `${input.userId}:${input.type}:${stableId ?? Date.now()}`; +} + export async function emitCuratorEvent(input: { userId: string; type: string; @@ -14,6 +31,6 @@ export async function emitCuratorEvent(input: { occurredAt: new Date().toISOString(), mission: input.mission, payload: input.payload ?? {}, - dedupeKey: `${input.userId}:${input.type}:${input.payload?.taskId ?? input.payload?.date ?? Date.now()}`, + dedupeKey: curatorDedupeKey(input), }, { userId: input.userId, source: "curator-v1" }); } diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 8e61cd0..6a9e732 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -1,6 +1,6 @@ import { and, desc, eq, gte, inArray, sql } from "drizzle-orm"; import { db } from "../../db/client.js"; -import { growEvents } from "../../db/schema.js"; +import { growEvents, growQscoreProjectionState } from "../../db/schema.js"; import { listMissionDefinitions } from "../../missions/registry.js"; import { listServiceCapabilities } from "../../workflows/service-capabilities.js"; import { emitCuratorEvent } from "./curator-events.js"; @@ -32,9 +32,10 @@ const VALID_COMPLETION_TYPES = [ ] as const; const CURATOR_SOURCE = "curator-v1"; -const CURATOR_PLAN_VERSION = "icp-v2"; -const SPRINT_DURATION_DAYS = 28; +const CURATOR_PLAN_VERSION = "icp-v3"; +const SPRINT_DURATION_DAYS = 30; const DAYS_PER_WEEK = 7; +const PLAN_WEEK_COUNT = 5; type TaskSeed = { taskType: CuratorTaskType; @@ -71,6 +72,29 @@ type CuratorIcpTemplateSet = { weeks: WeekTemplate[]; }; +type PlannedTask = TaskSeed & { + weekTheme: string; + weekSummary: string; +}; + +type PlanDaySeed = { + dayIndex: number; + dayIndexInWeek: number; + weekIndex: number; + weekTheme: string; + weekSummary: string; + focus: string; + plannedTasks: [PlannedTask, PlannedTask, PlannedTask]; + generationStatus: "seeded" | "generated" | "adapted"; + adaptationReason?: string; +}; + +type SprintState = { + startDate: string; + variantId: CuratorIcpId; + planDays: PlanDaySeed[]; +}; + const FRESHER_WEEK_TEMPLATES: WeekTemplate[] = [ { theme: "Baseline + Direction", @@ -807,14 +831,149 @@ function sprintIdFor(startDate: string) { return `curator-sprint:${CURATOR_PLAN_VERSION}:${startDate}`; } +function taskIdFor(startDate: string, dayIndex: number, taskType: CuratorTaskType) { + return `${sprintIdFor(startDate)}:day-${dayIndex}:${taskType}`; +} + +function stageIdFor(dayIndex: number, weekIndex: number, taskType: CuratorTaskType) { + return `wk-${weekIndex}:day-${dayIndexInWeek(dayIndex)}:${taskType}`; +} + function weekForDayIndex(dayIndex: number) { + if (dayIndex > DAYS_PER_WEEK * 4) return PLAN_WEEK_COUNT; return Math.ceil(dayIndex / DAYS_PER_WEEK); } function dayIndexInWeek(dayIndex: number) { + if (dayIndex > DAYS_PER_WEEK * 4) return dayIndex - DAYS_PER_WEEK * 4; return ((dayIndex - 1) % DAYS_PER_WEEK) + 1; } +function planFocus(weekTheme: string, seedTask: TaskSeed) { + if (seedTask.taskType === "measurement") return `Measure today against the ${weekTheme.toLowerCase()} theme.`; + if (seedTask.taskType === "proof") return `Turn progress from ${weekTheme.toLowerCase()} into visible proof.`; + return `Practice one concrete move that advances ${weekTheme.toLowerCase()}.`; +} + +function makePlannedTask(seedTask: TaskSeed, weekTheme: string, weekSummary: string): PlannedTask { + return { + ...seedTask, + weekTheme, + weekSummary, + }; +} + +function hasDirectServiceCompletion(serviceId: CuratorServiceId) { + const events = completionEventsForService(serviceId); + return !(events.length === 1 && events[0] === "curator.task.completed"); +} + +function normalizeEventBackedTask(task: PlannedTask, planDay: PlanDaySeed, recentRows: Awaited>): PlannedTask { + if (hasDirectServiceCompletion(task.serviceId)) return task; + + if (task.taskType === "proof") { + return makePlannedTask( + seed( + "proof", + "resume-service", + "Strengthen one proof asset in your resume workspace", + "Use the resume workspace to turn today’s proof goal into a concrete, event-backed artifact.", + "10 min", + "+7 projected", + "Open resume workspace", + ["proof asset", "resume proof"], + ), + planDay.weekTheme, + planDay.weekSummary, + ); + } + + if (task.taskType === "practice") { + const prefersRoleplay = hasRecentServiceSignal(recentRows, /\binterview(\.|-|\s)|mock\b/); + return makePlannedTask( + seed( + "practice", + prefersRoleplay ? "roleplay-service" : "interview-service", + prefersRoleplay ? "Run one guided roleplay rep for today’s goal" : "Run one guided interview rep for today’s goal", + prefersRoleplay + ? "Use a roleplay rep so today’s practice ends in a real event-backed coaching session." + : "Use an interview rep so today’s practice ends in a real event-backed coaching session.", + "10 min", + prefersRoleplay ? "+9 projected" : "+10 projected", + prefersRoleplay ? "Open roleplay preview" : "Open interview preview", + prefersRoleplay ? ["roleplay rep", "practice signal"] : ["interview rep", "practice signal"], + ), + planDay.weekTheme, + planDay.weekSummary, + ); + } + + return makePlannedTask( + seed( + "measurement", + "qscore-service", + "Review the latest readiness signal before continuing", + "Use analytics to anchor today’s work in a real measurable signal.", + "5 min", + "+5 projected", + "Review Q Score", + ["readiness signal", "analytics"], + ), + planDay.weekTheme, + planDay.weekSummary, + ); +} + +function fallbackCarryForwardWeek(templateSet: CuratorIcpTemplateSet): WeekTemplate { + const lastWeek = templateSet.weeks[templateSet.weeks.length - 1] ?? templateSet.weeks[0]!; + return { + theme: "Momentum + Carry Forward", + summary: `Use the strongest signals from ${lastWeek.theme.toLowerCase()} to lock in momentum and define the next move.`, + days: [ + { + measurement: seed("measurement", "qscore-service", "Review your 30-day movement", "Check which readiness signals moved most over the sprint and what still needs work.", "5 min", "+5 projected", "Review Q Score", ["30 day review", "signal movement"]), + proof: seed("proof", "resume-service", "Package your strongest proof into one asset", "Turn the best sprint outcome into a reusable bullet, story, or artifact.", "10 min", "+7 projected", "Open resume workspace", ["proof packaging", "story asset"]), + practice: seed("practice", "interview-service", "Run a final closeout interview rep", "Use one realistic question to prove the sprint improved your clarity and confidence.", "10 min", "+10 projected", "Open interview preview", ["closeout rep", "confidence"]), + }, + { + measurement: seed("measurement", "qscore-service", "Choose the next readiness driver", "Pick the one signal that should define the next 30-day push.", "5 min", "+5 projected", "Review Q Score", ["next sprint", "readiness driver"]), + proof: seed("proof", "social-branding-service", "Turn sprint movement into a visible update", "Package your strongest improvement into a public-safe profile or positioning update.", "10 min", "+6 projected", "Open social profile flow", ["visibility update", "carry forward"]), + practice: seed("practice", "matchmaking-service", "Review the next opportunities to act on", "Use the stronger profile and practice signals to shortlist the next roles or pathways.", "10 min", "+5 projected", "Open pathways", ["next opportunities", "carry forward"]), + }, + ...Array.from({ length: 5 }, () => lastWeek.days[lastWeek.days.length - 1] ?? lastWeek.days[0]!), + ], + }; +} + +function planSeedsForVariant(templateSet: CuratorIcpTemplateSet): PlanDaySeed[] { + const weekTemplates = [...templateSet.weeks, fallbackCarryForwardWeek(templateSet)]; + const planDays: PlanDaySeed[] = []; + + for (let index = 0; index < SPRINT_DURATION_DAYS; index += 1) { + const dayIndex = index + 1; + const weekIndex = weekForDayIndex(dayIndex); + const dayOfWeek = dayIndexInWeek(dayIndex); + const weekTemplate = weekTemplates[weekIndex - 1] ?? weekTemplates[0]!; + const dayTemplate = weekTemplate.days[dayOfWeek - 1] ?? weekTemplate.days[0]!; + planDays.push({ + dayIndex, + dayIndexInWeek: dayOfWeek, + weekIndex, + weekTheme: weekTemplate.theme, + weekSummary: weekTemplate.summary, + focus: planFocus(weekTemplate.theme, dayTemplate.measurement), + plannedTasks: [ + makePlannedTask(dayTemplate.measurement, weekTemplate.theme, weekTemplate.summary), + makePlannedTask(dayTemplate.proof, weekTemplate.theme, weekTemplate.summary), + makePlannedTask(dayTemplate.practice, weekTemplate.theme, weekTemplate.summary), + ], + generationStatus: "seeded", + }); + } + + return planDays; +} + function performanceLabel(percent: number): CuratorWeek["performance"] { if (percent >= 75) return "Excelling"; if (percent >= 50) return "Avg"; @@ -885,7 +1044,20 @@ function templateSetFor(variantId: CuratorIcpId) { return ICP_TEMPLATE_SETS[variantId] ?? ICP_TEMPLATE_SETS.fresher_early_professional; } -async function loadSprintState(userId: string, todayDate = todayIso()) { +function clonePlanDay(day: PlanDaySeed): PlanDaySeed { + return { + ...day, + plannedTasks: day.plannedTasks.map((task) => ({ ...task })) as PlanDaySeed["plannedTasks"], + }; +} + +function storedPlanDaysFromPayload(payload: Record | undefined): PlanDaySeed[] | null { + const value = payload?.planDays; + if (!Array.isArray(value) || value.length !== SPRINT_DURATION_DAYS) return null; + return value.map((raw) => raw as PlanDaySeed); +} + +async function loadSprintState(userId: string, todayDate = todayIso()): Promise { const latest = await db .select({ payload: growEvents.payload }) .from(growEvents) @@ -907,10 +1079,17 @@ async function loadSprintState(userId: string, todayDate = todayIso()) { existingVariant in ICP_TEMPLATE_SETS && calendarDiffDays(existingStartDate, todayDate) < SPRINT_DURATION_DAYS ) { - return { startDate: existingStartDate, variantId: existingVariant as CuratorIcpId }; + const templateSet = templateSetFor(existingVariant as CuratorIcpId); + return { + startDate: existingStartDate, + variantId: existingVariant as CuratorIcpId, + planDays: storedPlanDaysFromPayload(latest[0]?.payload)?.map(clonePlanDay) ?? planSeedsForVariant(templateSet), + }; } const variantId = await inferIcpVariant(userId); + const templateSet = templateSetFor(variantId); + const planDays = planSeedsForVariant(templateSet); await emitCuratorEvent({ userId, type: "curator.sprint.started", @@ -920,11 +1099,31 @@ async function loadSprintState(userId: string, todayDate = todayIso()) { durationDays: SPRINT_DURATION_DAYS, version: CURATOR_PLAN_VERSION, variantId, - icpLabel: templateSetFor(variantId).label, - sprintTheme: templateSetFor(variantId).sprintTheme, + icpLabel: templateSet.label, + sprintTheme: templateSet.sprintTheme, + goals: [templateSet.sprintTheme, templateSet.goal], + planDays, }, }); - return { startDate: todayDate, variantId }; + return { startDate: todayDate, variantId, planDays: planDays.map(clonePlanDay) }; +} + +async function loadRecentContextRows(userId: string, sinceDate: string) { + return db + .select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt }) + .from(growEvents) + .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)))) + .orderBy(desc(growEvents.occurredAt)) + .limit(200); +} + +async function loadCurrentQScore(userId: string) { + const [row] = await db + .select({ score: growQscoreProjectionState.score }) + .from(growQscoreProjectionState) + .where(eq(growQscoreProjectionState.userId, userId)) + .limit(1); + return typeof row?.score === "number" ? row.score : null; } async function loadCompletionRows(userId: string, sinceDate: string) { @@ -939,16 +1138,133 @@ async function loadCompletionRows(userId: string, sinceDate: string) { .orderBy(desc(growEvents.occurredAt)); } -function taskCompletedByEvents(task: CuratorTask, rows: Awaited>) { +function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: Awaited>) { return rows.some((row) => { const payload = row.payload ?? {}; const correlation = row.correlation ?? {}; - const taskId = payload.taskId ?? correlation.taskId; - if (taskId === task.id && (row.type === "curator.task.completed" || task.completionEvents.includes(row.type))) return true; - return false; + const eventTaskId = payload.taskId ?? correlation.taskId; + return eventTaskId === taskId && (row.type === "curator.task.completed" || completionEvents.includes(row.type)); }); } +function taskCompletedByEvents(task: CuratorTask, rows: Awaited>) { + return taskCompletedByTaskId(task.id, task.completionEvents, rows); +} + +function hasRecentServiceSignal(rows: Awaited>, pattern: RegExp) { + return rows.some((row) => pattern.test(`${row.source} ${row.type} ${eventText(row.payload)}`.toLowerCase())); +} + +function adaptCurrentDayPlan( + sprintStartDate: string, + focusDayIndex: number, + planDays: PlanDaySeed[], + completionRows: Awaited>, + recentRows: Awaited>, + currentQScore: number | null, +) { + const plan = planDays.map(clonePlanDay); + const current = plan[focusDayIndex - 1]; + if (!current) return plan; + + const hasResumeSignal = hasRecentServiceSignal(recentRows, /\bresume(\.|-|\s)|cv\b|linkedin\b/); + const hasInterviewSignal = hasRecentServiceSignal(recentRows, /\binterview(\.|-|\s)|mock\b/); + const hasRoleplaySignal = hasRecentServiceSignal(recentRows, /\broleplay(\.|-|\s)|scenario\b/); + let adapted = false; + const reasons: string[] = []; + + if (!hasResumeSignal) { + current.plannedTasks[1] = makePlannedTask( + seed("proof", "resume-service", "Upload or refresh your resume baseline", "Bring in your resume or raw experience so the rest of the sprint can work from real proof.", "10 min", "+7 projected", "Open resume workspace", ["resume baseline", "proof"]), + current.weekTheme, + current.weekSummary, + ); + adapted = true; + reasons.push("resume baseline missing"); + } + + if (!hasInterviewSignal && !hasRoleplaySignal && focusDayIndex >= 3 && current.plannedTasks[2].serviceId === "matchmaking-service") { + current.plannedTasks[2] = makePlannedTask( + seed("practice", "interview-service", "Run a real interview warm-up rep", "Start a live interview-style practice so the sprint captures real readiness movement, not just planning.", "10 min", "+10 projected", "Open interview preview", ["interview warm-up", "first rep"]), + current.weekTheme, + current.weekSummary, + ); + adapted = true; + reasons.push("practice signal missing"); + } + + if (focusDayIndex > 1) { + const previous = planDays[focusDayIndex - 2]; + if (!previous) { + plan[focusDayIndex - 1] = current; + return plan; + } + const previousCompleted = previous.plannedTasks.filter((task) => ( + taskCompletedByTaskId( + taskIdFor(sprintStartDate, previous.dayIndex, task.taskType), + completionEventsForService(task.serviceId), + completionRows, + ) + )).length; + + if (previousCompleted < 3) { + current.plannedTasks[0] = makePlannedTask( + seed("measurement", "qscore-service", `Review what blocked Day ${previous.dayIndex}`, "Check the strongest blocker from yesterday before generating more blind work.", "5 min", "+5 projected", "Review Q Score", ["blocker review", "momentum"]), + current.weekTheme, + current.weekSummary, + ); + + const unfinishedPractice = previous.plannedTasks.find((task) => ( + task.taskType === "practice" && + !taskCompletedByTaskId( + taskIdFor(sprintStartDate, previous.dayIndex, task.taskType), + completionEventsForService(task.serviceId), + completionRows, + ) + )); + + if (unfinishedPractice) { + current.plannedTasks[2] = makePlannedTask( + { + ...unfinishedPractice, + title: `Close the loop on ${unfinishedPractice.title.toLowerCase()}`, + subtitle: `Yesterday finished ${previousCompleted}/3 tasks. Use today to close the unfinished practice gap before moving on.`, + cta: unfinishedPractice.cta, + }, + current.weekTheme, + current.weekSummary, + ); + } + + current.focus = `Recover momentum after Day ${previous.dayIndex} finished ${previousCompleted}/3 tasks.`; + adapted = true; + reasons.push(`day ${previous.dayIndex} incomplete`); + } + } + + if ((currentQScore ?? 0) > 0 && (currentQScore ?? 0) < 55) { + current.plannedTasks[0] = makePlannedTask( + seed("measurement", "qscore-service", "Review the weakest readiness driver", "Use analytics to focus today on the signal that is still pulling the score down most.", "5 min", "+5 projected", "Review Q Score", ["weakest driver", "analytics"]), + current.weekTheme, + current.weekSummary, + ); + adapted = true; + reasons.push(`qscore ${currentQScore}`); + } + + if (adapted) { + current.generationStatus = "adapted"; + current.adaptationReason = reasons.join(" · "); + } else { + current.generationStatus = "generated"; + } + + current.plannedTasks = current.plannedTasks.map((task) => normalizeEventBackedTask(task, current, recentRows)) as PlanDaySeed["plannedTasks"]; + + plan[focusDayIndex - 1] = current; + return plan; +} + function buildTask( sprintStartDate: string, date: string, @@ -960,9 +1276,9 @@ function buildTask( completionRows: Awaited>, ): CuratorTask { const dayOfWeek = dayIndexInWeek(dayIndex); - const id = `${sprintIdFor(sprintStartDate)}:day-${dayIndex}:${seedTask.taskType}`; + const id = taskIdFor(sprintStartDate, dayIndex, seedTask.taskType); const missionInstanceId = sprintIdFor(sprintStartDate); - const stageId = `wk-${weekIndex}:day-${dayOfWeek}:${seedTask.taskType}`; + const stageId = stageIdFor(dayIndex, weekIndex, seedTask.taskType); const task: CuratorTask = { id, date, @@ -995,7 +1311,7 @@ function buildTask( { label: "Week theme", value: weekTheme }, { label: "Task type", value: seedTask.taskType }, { label: "Service handoff", value: serviceName(seedTask.serviceId) }, - { label: "Sprint day", value: `Day ${dayIndex}/28` }, + { label: "Sprint day", value: `Day ${dayIndex}/${SPRINT_DURATION_DAYS}` }, ], contextNarrative: contextNarrative(seedTask, weekTheme, weekSummary), subtasks: subtaskCopy(seedTask, weekTheme), @@ -1009,32 +1325,44 @@ function buildTask( }; } -function daySeeds(dayIndex: number, templates: WeekTemplate[]) { - const weekIndex = weekForDayIndex(dayIndex); - const dayOfWeek = dayIndexInWeek(dayIndex); - const template = (templates[weekIndex - 1] ?? templates[0]) as WeekTemplate; - const day = (template.days[dayOfWeek - 1] ?? template.days[0]) as WeekTemplate["days"][number]; - return { - weekIndex, - dayOfWeek, - theme: template.theme, - summary: template.summary, - day, - }; +function buildTasksForPlanDay( + sprintStartDate: string, + date: string, + planDay: PlanDaySeed, + completionRows: Awaited>, +) { + return planDay.plannedTasks.map((task) => ( + buildTask( + sprintStartDate, + date, + planDay.dayIndex, + planDay.weekIndex, + planDay.weekTheme, + planDay.weekSummary, + task, + completionRows, + ) + )); } export async function buildCuratorTasks(userId: string, date = todayIso()): Promise { const sprintState = await loadSprintState(userId, date); const sprintStartDate = sprintState.startDate; - const templateSet = templateSetFor(sprintState.variantId); const dayIndex = clamp(calendarDiffDays(sprintStartDate, date) + 1, 1, SPRINT_DURATION_DAYS); - const { weekIndex, theme, summary, day } = daySeeds(dayIndex, templateSet.weeks); const completionRows = await loadCompletionRows(userId, sprintStartDate); - return [ - buildTask(sprintStartDate, date, dayIndex, weekIndex, theme, summary, day.measurement, completionRows), - buildTask(sprintStartDate, date, dayIndex, weekIndex, theme, summary, day.proof, completionRows), - buildTask(sprintStartDate, date, dayIndex, weekIndex, theme, summary, day.practice, completionRows), - ]; + const recentRows = await loadRecentContextRows(userId, sprintStartDate); + const currentQScore = await loadCurrentQScore(userId); + const adaptedPlanDays = adaptCurrentDayPlan( + sprintStartDate, + dayIndex, + sprintState.planDays, + completionRows, + recentRows, + currentQScore, + ); + const planDay = adaptedPlanDays[dayIndex - 1]; + if (!planDay) return []; + return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows); } export async function buildCuratorStreak(userId: string): Promise { @@ -1075,49 +1403,60 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() const templateSet = templateSetFor(sprintState.variantId); const streak = await buildCuratorStreak(userId); const completionRows = await loadCompletionRows(userId, sprintStartDate); + const recentRows = await loadRecentContextRows(userId, sprintStartDate); + const currentQScore = await loadCurrentQScore(userId); + const activeDayIndex = clamp(calendarDiffDays(sprintStartDate, focusDate) + 1, 1, SPRINT_DURATION_DAYS); + const planDays = adaptCurrentDayPlan( + sprintStartDate, + activeDayIndex, + sprintState.planDays, + completionRows, + recentRows, + currentQScore, + ); const days: CuratorPlanDay[] = []; for (let index = 0; index < SPRINT_DURATION_DAYS; index += 1) { const dayIndex = index + 1; const date = addDaysIso(sprintStartDate, index); - const { weekIndex, dayOfWeek, theme, summary, day } = daySeeds(dayIndex, templateSet.weeks); - const tasks = [ - buildTask(sprintStartDate, date, dayIndex, weekIndex, theme, summary, day.measurement, completionRows), - buildTask(sprintStartDate, date, dayIndex, weekIndex, theme, summary, day.proof, completionRows), - buildTask(sprintStartDate, date, dayIndex, weekIndex, theme, summary, day.practice, completionRows), - ]; + const planDay = planDays[index]!; + const tasks = date <= focusDate ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows) : []; const completedCount = tasks.filter((task) => task.status === "completed").length; const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming"; days.push({ date, - dayIndex, - dayIndexInWeek: dayOfWeek, - weekIndex, - weekTheme: theme, - weekSummary: summary, + dayIndex: planDay.dayIndex, + dayIndexInWeek: planDay.dayIndexInWeek, + weekIndex: planDay.weekIndex, + weekTheme: planDay.weekTheme, + weekSummary: planDay.weekSummary, + focus: planDay.focus, + plannedServices: planDay.plannedTasks.map((task) => task.serviceId), + generationStatus: planDay.generationStatus, + adaptationReason: planDay.adaptationReason, completedCount, - totalCount: tasks.length, + totalCount: 3, unlockState, tasks, }); } - const weeks: CuratorWeek[] = templateSet.weeks.map((template, index) => { + const weeks: CuratorWeek[] = Array.from({ length: PLAN_WEEK_COUNT }, (_, index) => { const weekIndex = index + 1; - const startDayIndex = index * DAYS_PER_WEEK + 1; - const endDayIndex = startDayIndex + DAYS_PER_WEEK - 1; + const template = planDays.find((day) => day.weekIndex === weekIndex); + const startDayIndex = planDays.find((day) => day.weekIndex === weekIndex)?.dayIndex ?? (index * DAYS_PER_WEEK + 1); + const endDayIndex = [...planDays].reverse().find((day) => day.weekIndex === weekIndex)?.dayIndex ?? startDayIndex; const weekDays = days.slice(startDayIndex - 1, endDayIndex); const completedTaskCount = weekDays.reduce((sum, day) => sum + day.completedCount, 0); const totalTaskCount = weekDays.reduce((sum, day) => sum + day.totalCount, 0); const completionPercent = totalTaskCount ? Math.round((completedTaskCount / totalTaskCount) * 100) : 0; - const activeDayIndex = clamp(calendarDiffDays(sprintStartDate, focusDate) + 1, 1, SPRINT_DURATION_DAYS); const lifecycle: CuratorWeek["lifecycle"] = activeDayIndex > endDayIndex ? "done" : activeDayIndex >= startDayIndex ? "active" : "upcoming"; return { weekIndex, title: `WK ${weekIndex}`, - theme: template.theme, - summary: template.summary, + theme: template?.weekTheme ?? `Week ${weekIndex}`, + summary: template?.weekSummary ?? "Curator sprint week", startDayIndex, endDayIndex, lifecycle, @@ -1129,7 +1468,6 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso() }; }); - const activeDayIndex = clamp(calendarDiffDays(sprintStartDate, focusDate) + 1, 1, SPRINT_DURATION_DAYS); const activeWeekIndex = weekForDayIndex(activeDayIndex); const activeDay = (days[activeDayIndex - 1] ?? days[0])!; const activeWeek = (weeks[activeWeekIndex - 1] ?? weeks[0])!; diff --git a/src/v1/curator/curator-types.ts b/src/v1/curator/curator-types.ts index 50f6e74..58ebcbe 100644 --- a/src/v1/curator/curator-types.ts +++ b/src/v1/curator/curator-types.ts @@ -28,9 +28,9 @@ export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg", export const curatorTaskSchema = z.object({ id: z.string(), date: z.string(), - dayIndex: z.number().int().min(1).max(28), + dayIndex: z.number().int().min(1).max(30), dayIndexInWeek: z.number().int().min(1).max(7), - weekIndex: z.number().int().min(1).max(4), + weekIndex: z.number().int().min(1).max(5), taskType: curatorTaskTypeSchema, title: z.string(), subtitle: z.string(), @@ -63,11 +63,15 @@ export const curatorStreakSchema = z.object({ export const curatorPlanDaySchema = z.object({ date: z.string(), - dayIndex: z.number().int().min(1).max(28), + dayIndex: z.number().int().min(1).max(30), dayIndexInWeek: z.number().int().min(1).max(7), - weekIndex: z.number().int().min(1).max(4), + weekIndex: z.number().int().min(1).max(5), weekTheme: z.string(), weekSummary: z.string(), + focus: z.string().optional(), + plannedServices: z.array(curatorServiceIdSchema).max(3).default([]), + generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"), + adaptationReason: z.string().optional(), completedCount: z.number().int().min(0), totalCount: z.number().int().min(0), unlockState: z.enum(["completed", "active", "upcoming"]), @@ -75,18 +79,18 @@ export const curatorPlanDaySchema = z.object({ }); export const curatorWeekSchema = z.object({ - weekIndex: z.number().int().min(1).max(4), + weekIndex: z.number().int().min(1).max(5), title: z.string(), theme: z.string(), summary: z.string(), - startDayIndex: z.number().int().min(1).max(28), - endDayIndex: z.number().int().min(1).max(28), + startDayIndex: z.number().int().min(1).max(30), + endDayIndex: z.number().int().min(1).max(30), lifecycle: curatorWeekLifecycleSchema, performance: curatorWeekPerformanceSchema, completedTaskCount: z.number().int().min(0), totalTaskCount: z.number().int().min(0), completionPercent: z.number().min(0).max(100), - days: z.array(curatorPlanDaySchema).length(7), + days: z.array(curatorPlanDaySchema).min(2).max(7), }); export const curatorPlanSchema = z.object({ @@ -96,9 +100,9 @@ export const curatorPlanSchema = z.object({ endDate: z.string(), goals: z.array(z.string()), generatedAt: z.string(), - durationDays: z.literal(28), - weeks: z.array(curatorWeekSchema).length(4), - days: z.array(curatorPlanDaySchema).length(28), + durationDays: z.literal(30), + weeks: z.array(curatorWeekSchema).length(5), + days: z.array(curatorPlanDaySchema).length(30), streak: curatorStreakSchema, source: z.literal("curator-v1"), }); @@ -121,9 +125,9 @@ export const curatorSprintResponseSchema = z.object({ sprintId: z.string(), plan: curatorPlanSchema, activeWeek: curatorWeekSchema, - activeWeekIndex: z.number().int().min(1).max(4), + activeWeekIndex: z.number().int().min(1).max(5), activeDay: curatorPlanDaySchema, - activeDayIndex: z.number().int().min(1).max(28), + activeDayIndex: z.number().int().min(1).max(30), todayTasks: z.array(curatorTaskSchema).length(3), streak: curatorStreakSchema, completedCount: z.number().int().min(0),