From 2372fee37693335b8a646b0bd67611d1a0685862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 09:58:02 +0200 Subject: [PATCH] fix(coach): count the daily question limit from the person's midnight The limit on clarifying questions reset at UTC midnight, which is the middle of the night in Europe and the afternoon on the American west coast. It now counts from the start of the person's own day. --- .../ai/coach/__tests__/clarify-tool.test.ts | 26 +++++++++++++++++++ src/lib/ai/coach/clarify.ts | 13 +++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/coach/__tests__/clarify-tool.test.ts b/src/lib/ai/coach/__tests__/clarify-tool.test.ts index af7c6f27e..136350387 100644 --- a/src/lib/ai/coach/__tests__/clarify-tool.test.ts +++ b/src/lib/ai/coach/__tests__/clarify-tool.test.ts @@ -229,6 +229,32 @@ describe("clarificationAllowed", () => { ).resolves.toBe(false); }); + it("counts the day in the person's zone, not from UTC midnight", async () => { + // 23:30 UTC on 1 March is already 2 March in Auckland and still 1 March + // in Los Angeles; each day starts at its own local midnight. + const now = new Date("2026-03-01T23:30:00.000Z"); + await clarificationAllowed({ + userId: "u1", + conversationId: "c1", + now, + tz: "Pacific/Auckland", + }); + await clarificationAllowed({ + userId: "u1", + conversationId: "c1", + now, + tz: "America/Los_Angeles", + }); + const since = db.messageCount.mock.calls.map( + ([q]) => + (q as { where: { createdAt: { gte: Date } } }).where.createdAt.gte, + ); + expect(since).toEqual([ + new Date("2026-03-01T11:00:00.000Z"), + new Date("2026-03-01T08:00:00.000Z"), + ]); + }); + it("refuses when the count cannot be read", async () => { db.messageFindMany.mockRejectedValue(new Error("down")); await expect( diff --git a/src/lib/ai/coach/clarify.ts b/src/lib/ai/coach/clarify.ts index 3b12191c9..25ea236f0 100644 --- a/src/lib/ai/coach/clarify.ts +++ b/src/lib/ai/coach/clarify.ts @@ -30,6 +30,9 @@ import { type LatestMessagesLoader, } from "@/lib/ai/coach/latest-messages"; import { prisma } from "@/lib/db"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; import type { Locale } from "@/lib/i18n/config"; import { resolveIntlLocale } from "@/lib/format-locale"; @@ -704,6 +707,8 @@ export async function clarificationAllowed(args: { userId: string; conversationId: string; now?: Date; + /** The person's zone; resolved from the account when absent. */ + tz?: string; }): Promise { const now = args.now ?? new Date(); try { @@ -726,9 +731,11 @@ export async function clarificationAllowed(args: { ) { return false; } - const dayStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), - ); + // The person's day, not the server's: a limit that reset at UTC + // midnight reset at one or two in the morning in Berlin and in the + // afternoon in the Pacific. + const tz = args.tz ?? (await resolveUserTimezone(args.userId)); + const dayStart = startOfLocalDayKey(userDayKey(now, tz), tz); const today = await prisma.coachMessage.count({ where: { conversation: { userId: args.userId },