diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..3d0288869 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26856,7 +26856,7 @@ components: - ko today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). - Format: `YYYY-MM-DD`. Defaults to the server's UTC day." + Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." type: string pattern: ^\d{4}-\d{2}-\d{2}$ required: diff --git a/messages/de.json b/messages/de.json index b370a245b..c34ec8463 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen: Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", "refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.", - "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", "errorDemo": "In der Demo antwortet der Coach nicht.", "errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Gespeichert", "settingsCancel": "Abbrechen", "dailyLimitTitle": "Heute-Limit erreicht", - "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", "cluster": { diff --git a/messages/en.json b/messages/en.json index 232132002..fd91f3c6d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "I can only help with the health metrics in your log: measurements, medications, mood, trends. I cannot answer questions outside that scope.", "refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.", - "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.", + "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight in your time zone.", "errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.", "errorDemo": "The Coach doesn't answer in the demo.", "errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that. Connect your own key or a local model.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Saved", "settingsCancel": "Cancel", "dailyLimitTitle": "Daily limit reached", - "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.", + "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight in your time zone.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", "cluster": { diff --git a/messages/es.json b/messages/es.json index 521e07378..487a61a25 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. Preguntas fuera de ese ámbito no las respondo.", "refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud. Reformula tu pregunta dentro de ese marco.", - "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).", + "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche en tu zona horaria.", "errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.", "errorDemo": "En la demo, el Coach no responde.", "errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Guardado", "settingsCancel": "Cancelar", "dailyLimitTitle": "Límite diario alcanzado", - "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", + "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche en tu zona horaria.", "providerRateLimitTitle": "Proveedor con límite de tasa", "providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.", "cluster": { diff --git a/messages/fr.json b/messages/fr.json index c20ce3479..87d13f4fb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Je ne peux aider que pour les données de santé de ton journal : mesures, médicaments, humeur, tendances. Les questions hors de ce périmètre ne sont pas traitées.", "refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé. Reformule ta question dans ce cadre.", - "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).", + "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit dans ton fuseau horaire.", "errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.", "errorDemo": "Dans la démo, le Coach ne répond pas.", "errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Enregistré", "settingsCancel": "Annuler", "dailyLimitTitle": "Limite quotidienne atteinte", - "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", + "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit dans ton fuseau horaire.", "providerRateLimitTitle": "Fournisseur limité en débit", "providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.", "cluster": { diff --git a/messages/it.json b/messages/it.json index 292b56dd7..01f51b19d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Posso aiutarti solo con i dati di salute del tuo log: misurazioni, farmaci, umore, trend. Domande fuori da questo ambito non le rispondo.", "refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute: riformula la domanda in questo perimetro.", - "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).", + "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.", "errorDemo": "Nella demo il Coach non risponde.", "errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Salvato", "settingsCancel": "Annulla", "dailyLimitTitle": "Limite giornaliero raggiunto", - "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", + "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "providerRateLimitTitle": "Provider con rate-limit", "providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.", "cluster": { diff --git a/messages/ko.json b/messages/ko.json index db3908b97..f8d8ad8e6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "기록에 있는 건강 지표(측정값, 약, 기분, 추이)만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.", "refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.", - "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.", "errorDemo": "데모에서는 코치가 답하지 않아요.", "errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.", @@ -2999,7 +2999,7 @@ "settingsSaved": "저장됨", "settingsCancel": "취소", "dailyLimitTitle": "하루 한도 도달", - "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "providerRateLimitTitle": "제공자 요청 제한", "providerRateLimitBody": "제공자가 일시적으로 요청을 제한하고 있어요. 5분쯤 뒤에 다시 시도해 주세요.", "cluster": { diff --git a/messages/pl.json b/messages/pl.json index dba9b65ea..602f5a443 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Mogę pomóc tylko w sprawach danych zdrowotnych z twojego logu: pomiarów, leków, nastroju, trendów. Pytań spoza tego zakresu nie podejmuję.", "refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne. Przeformułuj pytanie w tych ramach.", - "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).", + "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy w Twojej strefie czasowej.", "errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.", "errorDemo": "W wersji demo Coach nie odpowiada.", "errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI: połącz własny klucz lub model lokalny.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Zapisano", "settingsCancel": "Anuluj", "dailyLimitTitle": "Osiągnięto dzienny limit", - "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", + "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy w Twojej strefie czasowej.", "providerRateLimitTitle": "Dostawca z ograniczeniem", "providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.", "cluster": { diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 199d99923..bfe9eeffd 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -73,11 +73,13 @@ interface ProviderHealthSummary { const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES]; export const GET = apiHandler(async () => { - await requireAdmin(); + const { user } = await requireAdmin(); annotate({ action: { name: "admin.provider-health.get" } }); - // The UTC day the ledger buckets by — the same key every reservation writes. - const dateKey = buildDateKey(); + // The ledger buckets each account by its own local day; the operator's + // "today" is the day in the operator's own zone, which on a single-zone + // instance is everyone's. + const dateKey = await buildDateKey(user.id); const groups = await prisma.providerHealth.groupBy({ // `lastStatus` joins the grouping key rather than an aggregate: the diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index eb8166511..1a1e25402 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -145,7 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // `admin-key` is the operator's own credential and draws the operator // ceiling; an override names the caller's own key, and every other resolved // type is the user's own egress, so those draw the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(user.id); const reservation = await reserveBudget( user.id, AI_TEST_MAX_TOKENS, diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 1fc8338c5..e011909a8 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -183,7 +183,7 @@ async function reserveFor( providerType: ProviderChainType, tokens: number, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, tokens, diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 7c01083fc..514c266ba 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -200,7 +200,7 @@ async function handleVisionIndex( const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, DOCUMENT_INDEX_RESERVE_TOKENS, diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 28ac50c64..da9d78fcc 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -122,7 +122,7 @@ async function reserveFor( userId: string, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentAssist.maxTokens, diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 9c2c7c484..c5e293890 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -216,7 +216,7 @@ async function reserveFor( ask: SummaryRequest, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(ask.userId); const reservation = await reserveBudget( ask.userId, budgetFor(ask.mode), diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 94a76ee03..a4e21e9d2 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -157,7 +157,7 @@ async function handleTextExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked provider // egresses on the operator's own key; a BYOK / Codex / local pick runs on the // user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.text, @@ -218,7 +218,7 @@ async function handleVisionExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked vision // provider egresses on the operator's own key; a BYOK / Codex pick runs on // the user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.vision, diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index c8bee510e..aaf949bfb 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -225,7 +225,10 @@ async function handleExtract(request: NextRequest): Promise { chain.map((entry) => entry.providerType), ); - const today = todayOverride ?? buildDateKey(); + // The account's own today: "tomorrow" in a description means the + // person's tomorrow, and the same key is the budget day below. + const accountToday = await buildDateKey(userId); + const today = todayOverride ?? accountToday; const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({ text, today, @@ -239,7 +242,7 @@ async function handleExtract(request: NextRequest): Promise { // paying for their own key by the operator's ceiling. `resolveDailyCap` // charges the operator ceiling only when the chain's primary is the // operator's own credential; every other chain gets the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = accountToday; const estimatedTokens = MAX_OUTPUT_TOKENS + Math.ceil((systemPrompt.length + userPrompt.length) / 4); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 9a712d453..dbd8738b2 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -560,7 +560,7 @@ function ChatBubbleImpl({ // v1.4.25 W5 — map server-emitted error codes to specific Coach // i18n keys. Distinct user-quota and provider-rate-limit copy so the - // user understands daily-cap (resets at UTC midnight) vs. transient + // user understands daily-cap (resets at their local midnight) vs. transient // provider load (retry in ~5 min). Codes that have no dedicated // translation fall back to the generic provider-unavailable copy. const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null; diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index 3757daa61..aea8edaa7 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -22,16 +22,45 @@ vi.mock("@/lib/db", () => ({ }, })); +const { zoneOf } = vi.hoisted(() => ({ + zoneOf: vi.fn(async (userId: string) => + userId === "berlin" + ? "Europe/Berlin" + : userId === "new-york" + ? "America/New_York" + : "UTC", + ), +})); +vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: zoneOf })); + +/** + * The budget day turns at the account's own midnight, the boundary the daily + * briefing and the other per-day generations use. On the UTC day, the first + * generations of a new local day east of UTC were counted against the old + * day's exhausted ceiling. + */ describe("buildDateKey", () => { - it("formats UTC YYYY-MM-DD", () => { - // 2026-05-10T22:30Z is UTC May 10 — Berlin would be May 11 already - const key = buildDateKey(new Date("2026-05-10T22:30:00.000Z")); - expect(key).toBe("2026-05-10"); + it("is the account's local day, not the UTC day", async () => { + // 22:30 UTC on May 10 is already 00:30 on May 11 in Berlin (CEST). + const at = new Date("2026-05-10T22:30:00.000Z"); + await expect(buildDateKey("berlin", at)).resolves.toBe("2026-05-11"); + await expect(buildDateKey("utc", at)).resolves.toBe("2026-05-10"); + expect(zoneOf).toHaveBeenCalledWith("berlin"); }); - it("rolls forward at UTC midnight", () => { - const key = buildDateKey(new Date("2026-05-11T00:01:00.000Z")); - expect(key).toBe("2026-05-11"); + it("turns at local midnight on either side of it", async () => { + // Berlin: 23:59 local on May 10, then 00:01 local on May 11. + await expect( + buildDateKey("berlin", new Date("2026-05-10T21:59:00.000Z")), + ).resolves.toBe("2026-05-10"); + await expect( + buildDateKey("berlin", new Date("2026-05-10T22:01:00.000Z")), + ).resolves.toBe("2026-05-11"); + // West of UTC the local day ends after the UTC one: 02:00 UTC on May 11 + // is still the evening of May 10 in New York. + await expect( + buildDateKey("new-york", new Date("2026-05-11T02:00:00.000Z")), + ).resolves.toBe("2026-05-10"); }); }); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index f1d387e0a..2849190b2 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -9,12 +9,13 @@ * us add per-day analytics ("avg replies per active user") later * without further migrations. * - * `dateKey` is a UTC `YYYY-MM-DD` string. UTC, not Europe/Berlin: the - * job runner that may seed off-hour tasks (planned v1.5) needs a - * boundary that does not jump on DST. Display layers can format with - * the user's timezone. + * `dateKey` is a `YYYY-MM-DD` string: the calendar day in the account's own + * time zone (`buildDateKey`), the same boundary as the daily briefing and + * every other per-day surface. */ import { prisma } from "@/lib/db"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; /** @@ -215,8 +216,9 @@ export interface DailySpend { export async function readDailySpend( userId: string, - dateKey: string = buildDateKey(), + dateKey?: string, ): Promise { + dateKey ??= await buildDateKey(userId); const row = await prisma.coachUsage.findUnique({ where: { userId_dateKey: { userId, dateKey } }, select: { totalTokens: true, operatorTokens: true }, @@ -228,15 +230,24 @@ export async function readDailySpend( } /** - * Build the UTC day-key for a given clock. Defaults to "now". + * The budget day of one account at `at` (default now): the calendar day in + * the account's own time zone. * - * UTC choice: the budget guards spend against the operator's LLM - * bill — the bill cycles at the provider's UTC midnight, so aligning - * the local meter to the same boundary keeps reasoning trivial. + * Up to v1.42.1 this was the UTC day. The daily briefing and the other + * per-day generations run on the account's local day, so in a zone east of + * UTC the first ones of a new local day still counted against the old UTC + * day, whose ceiling the evening had used up: a burst of refusals right + * after local midnight. The day now turns where the person's day turns. + * + * A caller resolves the key once per request or job and hands the same key + * to the reservation and the reconcile, so both land on one row even when + * the call spans midnight. */ -export function buildDateKey(at: Date = new Date()): string { - // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment) - return at.toISOString().slice(0, 10); +export async function buildDateKey( + userId: string, + at: Date = new Date(), +): Promise { + return userDayKey(at, await resolveUserTimezone(userId)); } /** diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index 86d352c7d..ac0959832 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -240,7 +240,7 @@ export async function deriveClarifyingQuestions( // serialises them on the row's unique key instead. The cap follows the // chain's cost owner, so a user on their own plan is not held to the // operator-cost ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, QUESTIONS_MAX_TOKENS, diff --git a/src/lib/ai/coach/turn/budget.ts b/src/lib/ai/coach/turn/budget.ts index 85a78cc69..603a08019 100644 --- a/src/lib/ai/coach/turn/budget.ts +++ b/src/lib/ai/coach/turn/budget.ts @@ -68,7 +68,7 @@ export async function reserveTurnBudget(args: { // egresses through the operator's own key or account; a chain on the // person's own plan or key gets the generous user-plan ceiling. const dailyCap = resolveDailyCap(chain); - const reqDateKey = buildDateKey(); + const reqDateKey = await buildDateKey(userId); const firstRound = toolMode ? Math.max(1, Math.floor(args.firstRound ?? AI_BUDGETS.coach.maxTokens)) : AI_BUDGETS.coach.maxTokens; diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index ef9133ee8..fc694a56e 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -290,7 +290,7 @@ export async function maybeAutoStageLabFacts( // ceiling under the cost owner the auto-index resolved. // v1.38.19 — auto-staging runs off the upload, with no one waiting // on it: a background surface, so half the day's ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.ocrExtractText.maxTokens, diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 1af3eb30a..90a834650 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -312,7 +312,7 @@ export async function streamFencedReply( await appendMessage({ conversationId, role: "user", content: message }); // ── Budget reservation (atomic, before the provider call) ── - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentChat.maxTokens, diff --git a/src/lib/documents/index-document.ts b/src/lib/documents/index-document.ts index 9674b2e1d..80c3ff25d 100644 --- a/src/lib/documents/index-document.ts +++ b/src/lib/documents/index-document.ts @@ -242,7 +242,7 @@ async function tryProviderIndex( return { outcome: null, note: null }; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentTranscribe.maxTokens, diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index fb2686927..422ec7fbc 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -93,7 +93,7 @@ export interface RunBriefingCompletionArgs { * a pinned seed would reproduce the phrasing the re-roll exists to vary. */ seed?: number; - /** UTC day-key override; defaults to today. Tests pin it. */ + /** Budget day-key override; defaults to the account's today. Tests pin it. */ dateKey?: string; /** * v1.41 — `daily_briefing` on the calls that may reason (the generation and @@ -122,7 +122,7 @@ export interface RunBriefingCompletionArgs { export async function runBriefingCompletion( args: RunBriefingCompletionArgs, ): Promise { - const dateKey = args.dateKey ?? buildDateKey(); + const dateKey = args.dateKey ?? (await buildDateKey(args.userId)); // v1.41 — one resolution per call: the operator's switch and cap, the payer // from the chain's primary. `undefined` is today's wire. diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index d098fe15c..6646c9771 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -250,7 +250,7 @@ export async function runStatusCompletion( // credential (`admin-openai` / `admin-codex`). A self-hoster on their own key // or a local model is measured against the generous user-plan ceiling, so // their own hardware/plan is never rationed by the operator's bill. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); // Thinking is billed as output, so a reasoning call reserves its thinking // budget too; the reconcile below settles on the reported count. const estimatedTokens = diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 3df90feeb..4ffb0e27e 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -211,7 +211,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { const maxTokens = budget.maxTokens ?? 160; // v1.38.19 — a background surface: half the day's ceiling. const dailyCap = resolveDailyCapFor("job", chain); - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(params.userId); // Per-user budget gate (atomic reserve; refunds itself on refusal). const reservation = await reserveBudget( diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 357619313..f355cb7d3 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -268,7 +268,7 @@ export async function runContentIndexBackfillForUser( break outer; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentTranscribe.maxTokens, diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 02f495b81..de889748f 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -223,7 +223,7 @@ export async function runDocumentSummaryJob( return; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentSummary.maxTokens, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index a30e48d3b..1051aae19 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -592,7 +592,7 @@ export async function runReactionLine( generationClaimId, job.userId, revision, - buildDateKey(), + await buildDateKey(job.userId), // v1.38.19 — a background surface: half the day's ceiling. resolveDailyCapFor("job", chain), resolveCostOwner(chain), diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 83d8de485..8f6bce5d3 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -1036,7 +1036,7 @@ export const medicationExtractRequest = z .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .describe( - 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.', + 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the account\'s own day in its time zone.', ), }) .meta({ diff --git a/tests/integration/coach-budget-reservation.test.ts b/tests/integration/coach-budget-reservation.test.ts index 2b4f28443..0ddc28a1d 100644 --- a/tests/integration/coach-budget-reservation.test.ts +++ b/tests/integration/coach-budget-reservation.test.ts @@ -9,7 +9,7 @@ * prove both contracts against a real Postgres; a mocked client could not * detect a missing atomic increment. */ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -180,3 +180,64 @@ describe("reserveBudget (real Postgres)", () => { expect(row?.totalTokens).toBeGreaterThan(0); }); }); + +/** + * The budget day turns at the account's own midnight, the boundary the daily + * briefing uses. On the UTC day a Berlin account's first generations after + * local midnight still counted against the previous day's spent ceiling. + */ +describe("the budget day is the account's local day", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("opens a fresh day at local midnight while the UTC day is still running", async () => { + const { buildDateKey, reserveBudget } = + await import("@/lib/ai/coach/budget"); + const user = await getPrismaClient().user.create({ + data: { + username: "budget-berlin", + email: "budget-berlin@example.test", + role: "USER", + timezone: "Europe/Berlin", + }, + }); + // 22:30 UTC on 2026-06-19 is 00:30 on 2026-06-20 in Berlin (CEST). + vi.useFakeTimers({ + toFake: ["Date"], + now: new Date("2026-06-19T22:30:00.000Z"), + }); + + // The evening used the whole ceiling, booked to the day that just ended. + await getPrismaClient().coachUsage.create({ + data: { + userId: user.id, + dateKey: "2026-06-19", + totalTokens: 1000, + operatorTokens: 1000, + messageCount: 3, + }, + }); + + const dateKey = await buildDateKey(user.id); + expect(dateKey).toBe("2026-06-20"); + const first = await reserveBudget( + user.id, + 200, + dateKey, + 1000, + "operator", + "job", + ); + expect(first.allowed).toBe(true); + const rows = await getPrismaClient().coachUsage.findMany({ + where: { userId: user.id }, + orderBy: { dateKey: "asc" }, + select: { dateKey: true, totalTokens: true }, + }); + expect(rows).toEqual([ + { dateKey: "2026-06-19", totalTokens: 1000 }, + { dateKey: "2026-06-20", totalTokens: 200 }, + ]); + }); +}); diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 9ae2ae8d9..bafa99d28 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -230,7 +230,9 @@ describe("POST /api/insights/chat — integration", () => { // the reservation is refunded so the ledger stays at the cap. const { userId } = await seedUserWithSession(); const prisma = getPrismaClient(); - const dateKey = new Date().toISOString().slice(0, 10); + // The account's own day, the key the route reserves under. + const { buildDateKey } = await import("@/lib/ai/coach/budget"); + const dateKey = await buildDateKey(userId); // v1.21.0 — the daily cap is now provider-aware: the seeded user resolves // to a user-egress chain, so the applicable ceiling is USER_PLAN_CAP. Seed // the spend at that ceiling so the reservation trips the gate. (Seeding the diff --git a/tests/integration/document-ai-runs.test.ts b/tests/integration/document-ai-runs.test.ts index 2b82d6575..ffa9dbba5 100644 --- a/tests/integration/document-ai-runs.test.ts +++ b/tests/integration/document-ai-runs.test.ts @@ -483,7 +483,7 @@ describe("the reaper", () => { const { readDailySpend, buildDateKey } = await import("@/lib/ai/coach/budget"); expect( - (await readDailySpend(user.id, buildDateKey())).total, + (await readDailySpend(user.id, await buildDateKey(user.id))).total, ).toBeGreaterThan(0); const { reapAiRuns } = await import("@/lib/documents/ai-runs/store"); @@ -493,7 +493,9 @@ describe("the reaper", () => { timedOut: 0, deleted: 0, }); - expect((await readDailySpend(user.id, buildDateKey())).total).toBe(0); + expect( + (await readDailySpend(user.id, await buildDateKey(user.id))).total, + ).toBe(0); const run = await getPrismaClient().documentAiRun.findUniqueOrThrow({ where: { id: runId }, diff --git a/tests/integration/rate-limit-429-headers.test.ts b/tests/integration/rate-limit-429-headers.test.ts index 87800caef..db4608027 100644 --- a/tests/integration/rate-limit-429-headers.test.ts +++ b/tests/integration/rate-limit-429-headers.test.ts @@ -185,7 +185,7 @@ describe("429 rate-limit headers (real Postgres)", () => { await getPrismaClient().coachUsage.create({ data: { userId: TEST_USER_ID, - dateKey: buildDateKey(), + dateKey: await buildDateKey(TEST_USER_ID), totalTokens: USER_PLAN_CAP, messageCount: 1, },