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:
commit
a7ae619b28
@ -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: {
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user