Merge f/tz-rhythm: move check-ins when the time zone changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-04 04:18:08 +00:00
commit a7ae619b28
No known key found for this signature in database
8 changed files with 219 additions and 31 deletions

View File

@ -619,10 +619,10 @@ export function createRouter(deps: RouterDeps) {
profile: { profile: {
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)), get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
update: authed.profile.update.handler(({ context, input }) => 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 }) => forget: authed.profile.forget.handler(({ context, input }) =>
forgetProfile(deps.prisma, context.actor, input), forgetProfile(deps, context.actor, input),
), ),
}, },
coach: { coach: {

View File

@ -1,4 +1,6 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import type { CoachRhythmDeps } from "@rakazo/adapters";
import { syncCoachTimeZone } from "@rakazo/adapters";
import type { import type {
Actor, Actor,
ForgetUserFacts, ForgetUserFacts,
@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
} }
export async function updateProfile( export async function updateProfile(
prisma: PrismaClient, deps: CoachRhythmDeps,
actor: Actor, actor: Actor,
input: UpdateUserProfile, input: UpdateUserProfile,
): Promise<UserProfileState> { ): Promise<UserProfileState> {
try { try {
await updateUserProfile(prisma, actor.userId, input); await updateUserProfile(deps.prisma, actor.userId, input);
} catch (error) { } catch (error) {
throw toRpcError(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. */ /** Unknown ids are ignored, so a caller cannot probe another user's facts. */
export async function forgetProfile( export async function forgetProfile(
prisma: PrismaClient, deps: CoachRhythmDeps,
actor: Actor, actor: Actor,
input: ForgetUserFacts, input: ForgetUserFacts,
): Promise<UserProfileState> { ): Promise<UserProfileState> {
await forgetUserFacts( const target = input.factIds
prisma, ? { factIds: input.factIds }
actor.userId, : input.category
input.factIds ? { category: input.category }
? { factIds: input.factIds } : { all: true as const };
: input.category await forgetUserFacts(deps.prisma, actor.userId, target);
? { category: input.category } // Forgetting everything clears the time zone too.
: { all: true }, if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
); return getUserProfileState(deps.prisma, actor.userId);
return getUserProfileState(prisma, actor.userId);
} }

View File

@ -1,9 +1,11 @@
import { randomUUID } from "node:crypto"; 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 type { PrismaClient } from "@rakazo/db";
import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db"; import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js"; import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js";
import { updateUserProfileFromTool } from "./user-profile-tools.js";
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
const describePostgres = const describePostgres =
@ -179,4 +181,114 @@ describePostgres("coach rhythm (PostgreSQL)", () => {
expect(await coachRoutines(userId)).toEqual([]); expect(await coachRoutines(userId)).toEqual([]);
expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id)); 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();
});
}); });

View File

@ -8,7 +8,7 @@ import {
clockTimeToMinutes, clockTimeToMinutes,
minutesToClockTime, minutesToClockTime,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { nextCronDate } from "@rakazo/core"; import { nextCronDate, nextCronDateAcross } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
@ -146,3 +146,32 @@ export async function setCoachRhythm(
} }
return getCoachRhythm(prisma, userId); 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<void> {
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));
}
}
}

View File

@ -2607,7 +2607,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (name === "update_user_profile") { if (name === "update_user_profile") {
return finish( return finish(
await updateUserProfileFromTool( await updateUserProfileFromTool(
deps.prisma, deps,
{ {
userId: run.userId, userId: run.userId,
bot, bot,

View File

@ -1,8 +1,16 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest"; 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 { selectBuiltinToolsForRun } from "./executor-tools.js";
import { updateUserProfileFromTool } from "./user-profile-tools.js"; import { updateUserProfileFromTool } from "./user-profile-tools.js";
vi.mock("./coach-rhythm.js", async (importOriginal) => ({
...(await importOriginal<typeof CoachRhythmModule>()),
syncCoachTimeZone: vi.fn(async () => undefined),
}));
function toolNames(options: { function toolNames(options: {
mainBot?: boolean; mainBot?: boolean;
messagingChannelRun?: boolean; messagingChannelRun?: boolean;
@ -23,15 +31,16 @@ function toolNames(options: {
}).map((tool) => tool.name); }).map((tool) => tool.name);
} }
/** Any database access fails the test: refusals and invalid input must not reach it. */ /** Any database or job access fails the test: refusals and invalid input must not reach it. */
const untouchedPrisma = new Proxy( const untouched = new Proxy(
{}, {},
{ {
get(_target, property) { 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) => ({ const context = (kind: string) => ({
userId: "user-1", userId: "user-1",
@ -58,24 +67,24 @@ describe("update_user_profile", () => {
it("refuses specialists even when they call the tool anyway", async () => { it("refuses specialists even when they call the tool anyway", async () => {
await expect( await expect(
updateUserProfileFromTool(untouchedPrisma, context("specialist"), { updateUserProfileFromTool(untouchedDeps, context("specialist"), {
facts: [{ category: "work", value: "Nurse" }], facts: [{ category: "work", value: "Nurse" }],
}), }),
).resolves.toEqual({ error: "Only the user's main bot can update the user profile." }); ).resolves.toEqual({ error: "Only the user's main bot can update the user profile." });
}); });
it("rejects invalid or empty updates before writing", async () => { 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" }], facts: [{ category: "secrets", value: "x" }],
}); });
expect(invalidCategory).toEqual({ error: expect.stringContaining("facts.0.category") }); 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", time_zone: "Vienna",
}); });
expect(unknownZone).toEqual({ error: expect.stringContaining("Unknown time zone") }); 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.", error: "Nothing to save. Pass facts or a profile field.",
}); });
}); });
@ -96,8 +105,9 @@ describe("update_user_profile", () => {
const prisma = { const prisma = {
$transaction: (run: (client: typeof tx) => Promise<unknown>) => run(tx), $transaction: (run: (client: typeof tx) => Promise<unknown>) => run(tx),
} as unknown as PrismaClient; } 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: [ facts: [
{ category: "work", value: "Nurse" }, { category: "work", value: "Nurse" },
{ category: "health", value: "Asthma" }, { category: "health", value: "Asthma" },
@ -121,5 +131,12 @@ describe("update_user_profile", () => {
}), }),
expect.objectContaining({ category: "health", status: "pending", sensitive: true }), 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();
}); });
}); });

View File

@ -5,9 +5,10 @@ import {
UserFactCategorySchema, UserFactCategorySchema,
UserFactValueSchema, UserFactValueSchema,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { InvalidUserFactError, updateUserProfile } from "@rakazo/db"; import { InvalidUserFactError, updateUserProfile } from "@rakazo/db";
import { z } from "zod"; import { z } from "zod";
import type { CoachRhythmDeps } from "./coach-rhythm.js";
import { syncCoachTimeZone } from "./coach-rhythm.js";
const UpdateUserProfileArgs = z.object({ const UpdateUserProfileArgs = z.object({
facts: z facts: z
@ -29,7 +30,7 @@ export interface UserProfileToolContext {
/** Records what the main bot learned about the user, attributed to the conversation. */ /** Records what the main bot learned about the user, attributed to the conversation. */
export async function updateUserProfileFromTool( export async function updateUserProfileFromTool(
prisma: PrismaClient, deps: CoachRhythmDeps,
context: UserProfileToolContext, context: UserProfileToolContext,
rawArgs: Record<string, unknown>, rawArgs: Record<string, unknown>,
) { ) {
@ -54,7 +55,7 @@ export async function updateUserProfileFromTool(
return { error: "Nothing to save. Pass facts or a profile field." }; return { error: "Nothing to save. Pass facts or a profile field." };
} }
try { try {
const added = await updateUserProfile(prisma, context.userId, { const added = await updateUserProfile(deps.prisma, context.userId, {
timeZone: args.time_zone, timeZone: args.time_zone,
language: args.language, language: args.language,
communicationStyle: args.communication_style, communicationStyle: args.communication_style,
@ -66,6 +67,7 @@ export async function updateUserProfileFromTool(
sourceMessageId: context.sourceMessageId, sourceMessageId: context.sourceMessageId,
})), })),
}); });
if (args.time_zone !== undefined) await syncCoachTimeZone(deps, context.userId);
const pending = added.filter((fact) => fact.status === "pending").length; const pending = added.filter((fact) => fact.status === "pending").length;
return pending > 0 return pending > 0
? { ? {

View File

@ -247,6 +247,32 @@ describeWithDatabase("coach through the API and the main bot", () => {
expect(rearmed.active).toBe(true); expect(rearmed.active).toBe(true);
expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); 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) { async function signup(app: App, email: string, name: string) {