From 68f9dfb34be941c4fc68a5111fde4c11e69fd48a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:08:05 +0000 Subject: [PATCH] fix(coach): move check-ins when the user's time zone changes The coach check-ins are main-bot routines scheduled in the profile time zone, but a later zone change (About you, onboarding, the main bot's update_user_profile tool, or forgetting the whole profile) left them in the old zone until the rhythm was saved again, so they fired at the wrong local time. syncCoachTimeZone, next to the rhythm code, moves the main bot's coach routines that are not in the current profile zone into it, recomputes the next run of armed ones and re-arms their wakeup. Paused check-ins move but stay paused, routines the user made are never touched, users without check-ins get none, and a save that leaves the zone as it is changes nothing. The profile update and forget RPCs and the profile tool call it whenever they write the zone. Quiet hours, the daily nudge cap and coach dates are evaluated in the profile zone when they are used, so they need no migration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 4 +- apps/api/src/user-profile.ts | 30 ++--- .../src/coach-rhythm.postgres.test.ts | 114 +++++++++++++++++- packages/adapters/src/coach-rhythm.ts | 31 ++++- packages/adapters/src/executor.ts | 2 +- .../adapters/src/user-profile-tools.test.ts | 35 ++++-- packages/adapters/src/user-profile-tools.ts | 8 +- packages/testkit/src/coach.postgres.test.ts | 26 ++++ 8 files changed, 219 insertions(+), 31 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index a196f22..9d0e90f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -619,10 +619,10 @@ export function createRouter(deps: RouterDeps) { profile: { get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)), update: authed.profile.update.handler(({ context, input }) => - updateProfile(deps.prisma, context.actor, input), + updateProfile(deps, context.actor, input), ), forget: authed.profile.forget.handler(({ context, input }) => - forgetProfile(deps.prisma, context.actor, input), + forgetProfile(deps, context.actor, input), ), }, coach: { diff --git a/apps/api/src/user-profile.ts b/apps/api/src/user-profile.ts index 4307f1a..5192c06 100644 --- a/apps/api/src/user-profile.ts +++ b/apps/api/src/user-profile.ts @@ -1,4 +1,6 @@ import { ORPCError } from "@orpc/server"; +import type { CoachRhythmDeps } from "@rakazo/adapters"; +import { syncCoachTimeZone } from "@rakazo/adapters"; import type { Actor, ForgetUserFacts, @@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise { try { - await updateUserProfile(prisma, actor.userId, input); + await updateUserProfile(deps.prisma, actor.userId, input); } catch (error) { throw toRpcError(error); } - return getUserProfileState(prisma, actor.userId); + if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId); + return getUserProfileState(deps.prisma, actor.userId); } /** Unknown ids are ignored, so a caller cannot probe another user's facts. */ export async function forgetProfile( - prisma: PrismaClient, + deps: CoachRhythmDeps, actor: Actor, input: ForgetUserFacts, ): Promise { - await forgetUserFacts( - prisma, - actor.userId, - input.factIds - ? { factIds: input.factIds } - : input.category - ? { category: input.category } - : { all: true }, - ); - return getUserProfileState(prisma, actor.userId); + const target = input.factIds + ? { factIds: input.factIds } + : input.category + ? { category: input.category } + : { all: true as const }; + await forgetUserFacts(deps.prisma, actor.userId, target); + // Forgetting everything clears the time zone too. + if ("all" in target) await syncCoachTimeZone(deps, actor.userId); + return getUserProfileState(deps.prisma, actor.userId); } diff --git a/packages/adapters/src/coach-rhythm.postgres.test.ts b/packages/adapters/src/coach-rhythm.postgres.test.ts index 73136e2..e55c5f6 100644 --- a/packages/adapters/src/coach-rhythm.postgres.test.ts +++ b/packages/adapters/src/coach-rhythm.postgres.test.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; -import { routineJobKey } from "@rakazo/adapter-kit"; +import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; +import { nextCronDate } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js"; +import { updateUserProfileFromTool } from "./user-profile-tools.js"; const databaseUrl = process.env.DATABASE_URL; const describePostgres = @@ -179,4 +181,114 @@ describePostgres("coach rhythm (PostgreSQL)", () => { expect(await coachRoutines(userId)).toEqual([]); expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id)); }); + + it("moves the check-ins, and only them, when the profile time zone changes", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const deps = { prisma, jobs }; + await setCoachRhythm(deps, { + userId, + rhythm: { morning: "07:30", evening: "21:00", weekly: { day: 0, time: "18:00" } }, + }); + const conversation = await ensureMainConversation(prisma, userId); + const evening = await prisma.routine.findFirstOrThrow({ + where: { userId, coachKey: "evening" }, + }); + await prisma.routine.update({ + where: { id: evening.id }, + data: { active: false, nextRunAt: null }, + }); + const own = await prisma.routine.create({ + data: { + botId: conversation.leadBotId, + userId, + spaceId: conversation.spaceId, + name: "Water the plants", + prompt: "Remind me to water the plants.", + crons: ["0 9 * * *"], + timezone: "Europe/Vienna", + active: true, + nextRunAt: nextCronDate("0 9 * * *", new Date(), "Europe/Vienna"), + }, + }); + const setZone = (time_zone: string) => + updateUserProfileFromTool( + deps, + { + userId, + bot: { id: conversation.leadBotId, kind: "main" }, + threadId: conversation.threadId, + sourceMessageId: null, + }, + { time_zone }, + ); + const before = await coachRoutines(userId); + + // Saving the zone the check-ins already use changes nothing. + jobs.enqueue.mockClear(); + expect(await setZone("Europe/Vienna")).toMatchObject({ ok: true }); + expect(await coachRoutines(userId)).toEqual(before); + expect(jobs.enqueue).not.toHaveBeenCalled(); + + expect(await setZone("America/New_York")).toMatchObject({ ok: true }); + const moved = await coachRoutines(userId); + expect(moved.map((routine) => [routine.coachKey, routine.timezone])).toEqual([ + ["evening", "America/New_York"], + ["morning", "America/New_York"], + ["weekly", "America/New_York"], + ]); + const localTime = (date: Date) => + new Intl.DateTimeFormat("en-GB", { + timeZone: "America/New_York", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(date); + const [pausedEvening, morning, weekly] = moved; + expect(localTime(morning!.nextRunAt!)).toMatch(/ 07:30$/); + expect(localTime(weekly!.nextRunAt!)).toBe("Sun 18:00"); + for (const routine of [morning!, weekly!]) { + expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + expect(routine.nextRunAt).not.toEqual(before.find((old) => old.id === routine.id)?.nextRunAt); + } + expect(jobs.enqueue).toHaveBeenCalledTimes(2); + expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(morning!.id, morning!.nextRunAt!)); + expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(weekly!.id, weekly!.nextRunAt!)); + // A paused check-in moves but stays paused; the user's own routine keeps its zone. + expect(pausedEvening).toMatchObject({ active: false, nextRunAt: null }); + expect(await prisma.routine.findUniqueOrThrow({ where: { id: own.id } })).toEqual(own); + expect(await getCoachRhythm(prisma, userId)).toEqual({ + morning: "07:30", + evening: null, + weekly: { day: 0, time: "18:00" }, + }); + + // Saving the new zone again changes nothing. + jobs.enqueue.mockClear(); + await setZone("America/New_York"); + expect(await coachRoutines(userId)).toEqual(moved); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it("creates no check-ins for a user without any when the time zone changes", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const main = await ensureMainConversation(prisma, userId); + await updateUserProfileFromTool( + { prisma, jobs }, + { + userId, + bot: { id: main.leadBotId, kind: "main" }, + threadId: main.threadId, + sourceMessageId: null, + }, + { time_zone: "Asia/Tokyo" }, + ); + expect(await prisma.userProfile.findUnique({ where: { userId } })).toMatchObject({ + timeZone: "Asia/Tokyo", + }); + expect(await prisma.routine.count({ where: { userId } })).toBe(0); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); }); diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index 06ae758..c385eec 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -8,7 +8,7 @@ import { clockTimeToMinutes, minutesToClockTime, } from "@rakazo/contracts"; -import { nextCronDate } from "@rakazo/core"; +import { nextCronDate, nextCronDateAcross } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; @@ -146,3 +146,32 @@ export async function setCoachRhythm( } return getCoachRhythm(prisma, userId); } + +/** + * Moves the main bot's check-ins into the user's current profile time zone and re-arms each + * armed one for its next local run. Check-ins already in that zone and routines the user made + * stay as they are, so calling it after every time zone save is safe. + */ +export async function syncCoachTimeZone(deps: CoachRhythmDeps, userId: string): Promise { + const { prisma, jobs } = deps; + const botId = await findMainBotId(prisma, userId); + if (!botId) return; + const timezone = await coachTimeZone(prisma, userId); + const moved = await prisma.routine.findMany({ + where: { botId, userId, coachKey: { not: null }, timezone: { not: timezone } }, + }); + for (const routine of moved) { + const armed = routine.active && routine.nextRunAt !== null; + const nextRunAt = armed + ? (nextCronDateAcross(routine.crons, new Date(), timezone) ?? routine.nextRunAt) + : routine.nextRunAt; + // Skipped when another save moved it meanwhile. + const { count } = await prisma.routine.updateMany({ + where: { id: routine.id, timezone: routine.timezone }, + data: { timezone, nextRunAt }, + }); + if (count === 1 && armed && nextRunAt) { + await jobs.enqueue(routineWakeupJob(routine.id, nextRunAt)); + } + } +} diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index eb1df2a..49dfd97 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -2607,7 +2607,7 @@ export function createRunExecutor(deps: ExecutorDeps) { if (name === "update_user_profile") { return finish( await updateUserProfileFromTool( - deps.prisma, + deps, { userId: run.userId, bot, diff --git a/packages/adapters/src/user-profile-tools.test.ts b/packages/adapters/src/user-profile-tools.test.ts index b6200cf..0b118a3 100644 --- a/packages/adapters/src/user-profile-tools.test.ts +++ b/packages/adapters/src/user-profile-tools.test.ts @@ -1,8 +1,16 @@ +import type { JobPublisher } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; +import type * as CoachRhythmModule from "./coach-rhythm.js"; +import { syncCoachTimeZone } from "./coach-rhythm.js"; import { selectBuiltinToolsForRun } from "./executor-tools.js"; import { updateUserProfileFromTool } from "./user-profile-tools.js"; +vi.mock("./coach-rhythm.js", async (importOriginal) => ({ + ...(await importOriginal()), + syncCoachTimeZone: vi.fn(async () => undefined), +})); + function toolNames(options: { mainBot?: boolean; messagingChannelRun?: boolean; @@ -23,15 +31,16 @@ function toolNames(options: { }).map((tool) => tool.name); } -/** Any database access fails the test: refusals and invalid input must not reach it. */ -const untouchedPrisma = new Proxy( +/** Any database or job access fails the test: refusals and invalid input must not reach it. */ +const untouched = new Proxy( {}, { get(_target, property) { - throw new Error(`unexpected database access: ${String(property)}`); + throw new Error(`unexpected access: ${String(property)}`); }, }, -) as PrismaClient; +); +const untouchedDeps = { prisma: untouched as PrismaClient, jobs: untouched as JobPublisher }; const context = (kind: string) => ({ userId: "user-1", @@ -58,24 +67,24 @@ describe("update_user_profile", () => { it("refuses specialists even when they call the tool anyway", async () => { await expect( - updateUserProfileFromTool(untouchedPrisma, context("specialist"), { + updateUserProfileFromTool(untouchedDeps, context("specialist"), { facts: [{ category: "work", value: "Nurse" }], }), ).resolves.toEqual({ error: "Only the user's main bot can update the user profile." }); }); it("rejects invalid or empty updates before writing", async () => { - const invalidCategory = await updateUserProfileFromTool(untouchedPrisma, context("main"), { + const invalidCategory = await updateUserProfileFromTool(untouchedDeps, context("main"), { facts: [{ category: "secrets", value: "x" }], }); expect(invalidCategory).toEqual({ error: expect.stringContaining("facts.0.category") }); - const unknownZone = await updateUserProfileFromTool(untouchedPrisma, context("main"), { + const unknownZone = await updateUserProfileFromTool(untouchedDeps, context("main"), { time_zone: "Vienna", }); expect(unknownZone).toEqual({ error: expect.stringContaining("Unknown time zone") }); - await expect(updateUserProfileFromTool(untouchedPrisma, context("main"), {})).resolves.toEqual({ + await expect(updateUserProfileFromTool(untouchedDeps, context("main"), {})).resolves.toEqual({ error: "Nothing to save. Pass facts or a profile field.", }); }); @@ -96,8 +105,9 @@ describe("update_user_profile", () => { const prisma = { $transaction: (run: (client: typeof tx) => Promise) => run(tx), } as unknown as PrismaClient; + const jobs = { enqueue: vi.fn(), cancel: vi.fn() }; - const result = await updateUserProfileFromTool(prisma, context("main"), { + const result = await updateUserProfileFromTool({ prisma, jobs }, context("main"), { facts: [ { category: "work", value: "Nurse" }, { category: "health", value: "Asthma" }, @@ -121,5 +131,12 @@ describe("update_user_profile", () => { }), expect.objectContaining({ category: "health", status: "pending", sensitive: true }), ]); + // A new time zone moves the check-ins; facts alone leave them be. + expect(syncCoachTimeZone).toHaveBeenCalledExactlyOnceWith({ prisma, jobs }, "user-1"); + vi.mocked(syncCoachTimeZone).mockClear(); + await updateUserProfileFromTool({ prisma, jobs }, context("main"), { + facts: [{ category: "work", value: "Nurse" }], + }); + expect(syncCoachTimeZone).not.toHaveBeenCalled(); }); }); diff --git a/packages/adapters/src/user-profile-tools.ts b/packages/adapters/src/user-profile-tools.ts index 19255cb..9ba0991 100644 --- a/packages/adapters/src/user-profile-tools.ts +++ b/packages/adapters/src/user-profile-tools.ts @@ -5,9 +5,10 @@ import { UserFactCategorySchema, UserFactValueSchema, } from "@rakazo/contracts"; -import type { PrismaClient } from "@rakazo/db"; import { InvalidUserFactError, updateUserProfile } from "@rakazo/db"; import { z } from "zod"; +import type { CoachRhythmDeps } from "./coach-rhythm.js"; +import { syncCoachTimeZone } from "./coach-rhythm.js"; const UpdateUserProfileArgs = z.object({ facts: z @@ -29,7 +30,7 @@ export interface UserProfileToolContext { /** Records what the main bot learned about the user, attributed to the conversation. */ export async function updateUserProfileFromTool( - prisma: PrismaClient, + deps: CoachRhythmDeps, context: UserProfileToolContext, rawArgs: Record, ) { @@ -54,7 +55,7 @@ export async function updateUserProfileFromTool( return { error: "Nothing to save. Pass facts or a profile field." }; } try { - const added = await updateUserProfile(prisma, context.userId, { + const added = await updateUserProfile(deps.prisma, context.userId, { timeZone: args.time_zone, language: args.language, communicationStyle: args.communication_style, @@ -66,6 +67,7 @@ export async function updateUserProfileFromTool( sourceMessageId: context.sourceMessageId, })), }); + if (args.time_zone !== undefined) await syncCoachTimeZone(deps, context.userId); const pending = added.filter((fact) => fact.status === "pending").length; return pending > 0 ? { diff --git a/packages/testkit/src/coach.postgres.test.ts b/packages/testkit/src/coach.postgres.test.ts index 7f86a1a..386250d 100644 --- a/packages/testkit/src/coach.postgres.test.ts +++ b/packages/testkit/src/coach.postgres.test.ts @@ -247,6 +247,32 @@ describeWithDatabase("coach through the API and the main bot", () => { expect(rearmed.active).toBe(true); expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); }); + + it("moves the check-ins when the user's time zone changes or is forgotten", async () => { + const cookie = await signup(app, `coach-zone-${stamp}@rakazo.test`, "Coach Zone"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + await rpc(app, cookie, "coach/rhythm/set", { morning: "07:00", evening: null, weekly: null }); + const morning = () => + handles.prisma.routine.findFirstOrThrow({ + where: { userId: me.userId, coachKey: "morning" }, + }); + expect(await morning()).toMatchObject({ timezone: "UTC" }); + + await rpc(app, cookie, "profile/update", { timeZone: "Asia/Tokyo" }); + const moved = await morning(); + expect(moved.timezone).toBe("Asia/Tokyo"); + expect( + new Intl.DateTimeFormat("en-GB", { + timeZone: "Asia/Tokyo", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(moved.nextRunAt!), + ).toBe("07:00"); + + await rpc(app, cookie, "profile/forget", { all: true }); + expect(await morning()).toMatchObject({ timezone: "UTC" }); + }); }); async function signup(app: App, email: string, name: string) {