Build adaptive 30-day curator sprint
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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 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<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])!;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user