Build adaptive 30-day curator sprint

This commit is contained in:
Sai-karthik
2026-06-19 21:51:34 +00:00
parent 27c9f58b80
commit 20c18583db
3 changed files with 426 additions and 67 deletions

View File

@@ -1,5 +1,22 @@
import { recordGrowEvent } from "../../events/record-grow-event.js";
function curatorDedupeKey(input: {
userId: string;
type: string;
payload?: Record<string, unknown>;
}) {
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" });
}

View File

@@ -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<ReturnType<typeof loadRecentContextRows>>): 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 todays 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 todays goal" : "Run one guided interview rep for todays goal",
prefersRoleplay
? "Use a roleplay rep so todays practice ends in a real event-backed coaching session."
: "Use an interview rep so todays 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 todays 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<string, unknown> | 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<SprintState> {
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<ReturnType<typeof loadCompletionRows>>) {
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
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<ReturnType<typeof loadCompletionRows>>) {
return taskCompletedByTaskId(task.id, task.completionEvents, rows);
}
function hasRecentServiceSignal(rows: Awaited<ReturnType<typeof loadRecentContextRows>>, 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<ReturnType<typeof loadCompletionRows>>,
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
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<ReturnType<typeof loadCompletionRows>>,
): 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<ReturnType<typeof loadCompletionRows>>,
) {
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<CuratorTask[]> {
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<CuratorStreak> {
@@ -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])!;

View File

@@ -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),