diff --git a/scripts/curator-profile-task-policy.test.ts b/scripts/curator-profile-task-policy.test.ts new file mode 100644 index 0000000..bac521d --- /dev/null +++ b/scripts/curator-profile-task-policy.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { resolveProfileTaskPolicy } from "../src/v1/curator/streak-utils.js"; + +assert.deepEqual( + resolveProfileTaskPolicy({ resumeAvailable: true, linkedinAvailable: false }), + { complete: false, serviceId: "social-branding-service" }, + "a resume upload must not complete or reroute the LinkedIn profile task", +); +assert.deepEqual( + resolveProfileTaskPolicy({ resumeAvailable: false, linkedinAvailable: true }), + { complete: true, serviceId: "social-branding-service" }, + "an existing LinkedIn connection completes the task on the next sprint read", +); +assert.deepEqual( + resolveProfileTaskPolicy({ resumeAvailable: true, linkedinAvailable: true }), + { complete: true, serviceId: "social-branding-service" }, + "LinkedIn remains the completion signal when both profile assets exist", +); + +console.log("curator profile-task policy regression: ok"); diff --git a/scripts/curator-trial-progression.test.ts b/scripts/curator-trial-progression.test.ts index bdcbebd..bfc0b26 100644 --- a/scripts/curator-trial-progression.test.ts +++ b/scripts/curator-trial-progression.test.ts @@ -5,8 +5,21 @@ import { resolveAgedTrialReuse, } from "../src/v1/curator/streak-utils.js"; -assert.equal(curatorLogicalDate(new Date("2026-07-14T18:29:59.999Z")), "2026-07-14"); -assert.equal(curatorLogicalDate(new Date("2026-07-14T18:30:00.000Z")), "2026-07-15"); +assert.equal( + curatorLogicalDate(new Date("2026-07-15T03:29:59.999Z")), + "2026-07-14", + "the previous Curator day remains active until 09:00 Asia/Kolkata", +); +assert.equal( + curatorLogicalDate(new Date("2026-07-15T03:30:00.000Z")), + "2026-07-15", + "the next Curator day starts at 09:00 Asia/Kolkata", +); +assert.equal( + curatorLogicalDate(new Date("2026-07-15T02:30:00.000Z"), 8), + "2026-07-15", + "deployments may explicitly configure a different business-morning hour", +); const reuse = resolveAgedTrialReuse({ existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 2, access: "trial" }, desired: { durationDays: 2, access: "trial" }, diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index b75ae5b..78b3aae 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -34,7 +34,7 @@ import { } from "./task-registry.js"; import { kpiDefinitionsForIcp } from "./kpi-registry.js"; import { outcomeDefinitionsForIcp } from "./outcome-report-registry.js"; -import { activeCuratorDayIndex, addDaysIso, composeTrialDay2, computeStreakFromDays, curatorLogicalDate, resolveAgedTrialReuse, todayIso } from "./streak-utils.js"; +import { activeCuratorDayIndex, addDaysIso, composeTrialDay2, computeStreakFromDays, curatorLogicalDate, resolveAgedTrialReuse, resolveProfileTaskPolicy, todayIso } from "./streak-utils.js"; // A streak day represents meaningful correlated activity, not necessarily a // terminal artifact. Keep this intentionally broader than full task completion. @@ -1224,14 +1224,16 @@ function buildTask( isPaidTask: seedTask.isPaidTask !== false, isRecoveryPractice: Boolean(seedTask.isRecoveryPractice), }; - const profileComplete = isProfileTask && (userContext.profile.resumeAvailable || userContext.profile.linkedinAvailable); - const routeTask = isProfileTask && userContext.profile.linkedinAvailable && !userContext.profile.resumeAvailable - ? { ...task, serviceId: "resume-service" as const } + const profilePolicy = isProfileTask + ? resolveProfileTaskPolicy(userContext.profile) + : undefined; + const routeTask = profilePolicy + ? { ...task, serviceId: profilePolicy.serviceId } : task; const taskWithRoute = { ...task, route: buildCuratorTaskDeepLink(routeTask, targetRole), - status: profileComplete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }), + status: profilePolicy?.complete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }), } satisfies CuratorTask; return taskWithRoute; } diff --git a/src/v1/curator/streak-utils.ts b/src/v1/curator/streak-utils.ts index 4162968..de92c23 100644 --- a/src/v1/curator/streak-utils.ts +++ b/src/v1/curator/streak-utils.ts @@ -17,9 +17,37 @@ export function indiaTodayIso(date = new Date()): string { }).format(date); } -// Timestamps stay UTC; only the product-day boundary is interpreted for India. -export function curatorLogicalDate(date = new Date()): string { - return indiaTodayIso(date); +const CURATOR_BUSINESS_DAY_START_HOUR = 9; + +// Timestamps stay UTC; the Curator product day rolls over at the next +// business morning in India rather than at calendar midnight. +export function curatorLogicalDate(date = new Date(), businessDayStartHour = CURATOR_BUSINESS_DAY_START_HOUR): string { + const indiaParts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Kolkata", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => indiaParts.find((item) => item.type === type)?.value ?? ""; + const indiaDate = `${part("year")}-${part("month")}-${part("day")}`; + return Number(part("hour")) < businessDayStartHour + ? addDaysIso(indiaDate, -1) + : indiaDate; +} + +export type ProfileTaskPolicyInput = { + resumeAvailable: boolean; + linkedinAvailable: boolean; +}; + +export function resolveProfileTaskPolicy(input: ProfileTaskPolicyInput) { + void input.resumeAvailable; + return { + complete: input.linkedinAvailable, + serviceId: "social-branding-service" as const, + }; } export function addDaysIso(startDate: string, days: number): string {