From 1b7793e10f25cfba8d2d19d9a761fbc98b4a04db Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:56:43 +0800 Subject: [PATCH 1/2] fix(app-shell): AiUsageIndicator recognizes resetKind 'weekly' + resetsAt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free plan's AI quota moved to a rolling 7-day window (cloud PR #1852), which added `resetKind: 'weekly'` to the usage endpoint. AiUsageIndicator only knew 'daily'/'monthly' and fell through to no reset copy for a weekly meter. It now renders "Resets in N days" (or N hours inside the final day), derived from the endpoint's `resetsAt` — never guessed client-side — with new @object-ui/i18n keys (base + _one/_other plural family, all ten packs). An unrecognized resetKind still fails soft: no crash, no reset line. Co-Authored-By: Claude Fable 5.1 --- .../7371-ai-usage-indicator-weekly-reset.md | 24 +++++++ packages/app-shell/src/hooks/useAiUsage.ts | 9 ++- .../app-shell/src/layout/AiUsageIndicator.tsx | 35 ++++++++-- .../__tests__/AiUsageIndicator.test.tsx | 65 ++++++++++++++++++- packages/i18n/src/locales/ar.ts | 6 ++ packages/i18n/src/locales/de.ts | 6 ++ packages/i18n/src/locales/en.ts | 11 ++++ packages/i18n/src/locales/es.ts | 6 ++ packages/i18n/src/locales/fr.ts | 6 ++ packages/i18n/src/locales/ja.ts | 6 ++ packages/i18n/src/locales/ko.ts | 6 ++ packages/i18n/src/locales/pt.ts | 6 ++ packages/i18n/src/locales/ru.ts | 6 ++ packages/i18n/src/locales/zh.ts | 6 ++ 14 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 .changeset/7371-ai-usage-indicator-weekly-reset.md diff --git a/.changeset/7371-ai-usage-indicator-weekly-reset.md b/.changeset/7371-ai-usage-indicator-weekly-reset.md new file mode 100644 index 0000000000..37fa3b379a --- /dev/null +++ b/.changeset/7371-ai-usage-indicator-weekly-reset.md @@ -0,0 +1,24 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +Fix `AiUsageIndicator` to recognize the free plan's new `resetKind: 'weekly'` and its +`resetsAt` (objectui#7371, consumer of cloud PR #1852's rolling 7-day AI quota window). + +Before this change a `weekly` meter fell through to the component's unrecognized-kind +path and rendered no reset line at all — not a crash, but silently wrong information +next to a live progress ring. The indicator now shows "Resets in N days" (or "Resets in +N hours" once inside the final day, e.g. `console.ai.usage.resetsWeeklyHours`), computed +from the endpoint's `resetsAt`, in both languages via `@object-ui/i18n` +(`console.ai.usage.resetsWeeklyDays` / `resetsWeeklyHours`, real i18next plural families +with a base key so every locale pack resolves correctly, all ten packs translated). D5 is +preserved — no token count is ever rendered, only the days/hours until reset. + +Contract-first: `resetsAt` is read verbatim from the endpoint, never re-derived or +guessed client-side. A `weekly` meter with `resetsAt: null` (nothing counted yet in the +window) and any `resetKind` this build does not recognize both render no reset line — +fail-soft, not a crash or stale copy. + +`AiUsageResetKind` (`packages/app-shell/src/hooks/useAiUsage.ts`) gains the `'weekly'` +member; `resetsAt` was already `string | null` and needed no shape change. diff --git a/packages/app-shell/src/hooks/useAiUsage.ts b/packages/app-shell/src/hooks/useAiUsage.ts index 4e349107bf..f2cc6b90b5 100644 --- a/packages/app-shell/src/hooks/useAiUsage.ts +++ b/packages/app-shell/src/hooks/useAiUsage.ts @@ -22,7 +22,7 @@ import * as React from 'react'; import { AI_USAGE_REFRESH_EVENT } from '@object-ui/plugin-chatbot'; -export type AiUsageResetKind = 'daily' | 'monthly'; +export type AiUsageResetKind = 'daily' | 'weekly' | 'monthly'; export type AiUsagePlanType = 'free' | 'paid'; /** One meter's D5-safe usage signal (mirrors the cloud endpoint). */ @@ -33,7 +33,12 @@ export interface AiMeterUsage { /** No finite cap (usage-based) — the UI would draw spend, not a ring. */ unmetered: boolean; resetKind: AiUsageResetKind; - /** Best-effort reset instant (ISO); null when unknown (e.g. monthly cycle anchor). */ + /** + * Best-effort reset instant (ISO); null when unknown. Weekly (the free + * plan's rolling 7-day window, cloud PR #1852): null while nothing is + * counted yet — never guessed client-side (objectui#7371). Monthly: null, + * the billing-cycle anchor is not known at this layer. + */ resetsAt: string | null; /** Free-tier upgrade CTA applies. */ upgrade: boolean; diff --git a/packages/app-shell/src/layout/AiUsageIndicator.tsx b/packages/app-shell/src/layout/AiUsageIndicator.tsx index 9ee8b1d2f0..4a70e3aa05 100644 --- a/packages/app-shell/src/layout/AiUsageIndicator.tsx +++ b/packages/app-shell/src/layout/AiUsageIndicator.tsx @@ -25,6 +25,9 @@ import { cloudConsoleUrl } from '../console/marketplace/marketplaceApi.js'; /** Fraction at/above which a meter is "running low" (amber + CTA). */ export const NEAR_FULL = 0.8; +const ONE_HOUR_MS = 60 * 60 * 1000; +const ONE_DAY_MS = 24 * ONE_HOUR_MS; + type Tone = 'ok' | 'low' | 'full'; function toneFor(fraction: number): Tone { @@ -122,10 +125,31 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage return t('console.ai.usage.statusOk', { defaultValue: 'Plenty left' }); }; - const resetLabel = (meter: AiMeterUsage): string => - meter.resetKind === 'daily' - ? t('console.ai.usage.resetsDaily', { defaultValue: 'Resets tonight' }) - : t('console.ai.usage.resetsMonthly', { defaultValue: 'Resets next cycle' }); + // `resetKind: 'weekly'` (the free plan's rolling 7-day window, cloud PR #1852): + // "N days" (or "N hours" inside the final day), derived from `resetsAt`. + // Contract-first (objectui#7371) — `resetsAt` is the ONE source of the reset + // instant; never re-derive or guess it client-side. + const weeklyResetLabel = (resetsAt: string): string => { + const diffMs = new Date(resetsAt).getTime() - Date.now(); + if (diffMs <= ONE_DAY_MS) { + const hours = Math.max(1, Math.ceil(diffMs / ONE_HOUR_MS)); + return t('console.ai.usage.resetsWeeklyHours', { count: hours, defaultValue: 'Resets in {{count}} hours' }); + } + const days = Math.ceil(diffMs / ONE_DAY_MS); + return t('console.ai.usage.resetsWeeklyDays', { count: days, defaultValue: 'Resets in {{count}} days' }); + }; + + // `null` = render nothing for this line — an unrecognized `resetKind` (a + // future backend value this build doesn't know yet) fails soft instead of + // crashing or showing stale/wrong copy, and a `weekly` meter with no + // `resetsAt` yet (nothing counted) is never guessed at (objectui#7371). + const resetLabel = (meter: AiMeterUsage): string | null => { + if (meter.resetKind === 'daily') return t('console.ai.usage.resetsDaily', { defaultValue: 'Resets tonight' }); + if (meter.resetKind === 'monthly') + return t('console.ai.usage.resetsMonthly', { defaultValue: 'Resets next cycle' }); + if (meter.resetKind === 'weekly') return meter.resetsAt ? weeklyResetLabel(meter.resetsAt) : null; + return null; + }; // Worst meter drives the trigger accent + the inline "running low" hint. const worst = meters.reduce((a, b) => (b.fraction > a.fraction ? b : a)); @@ -170,6 +194,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage // No upstream cloud named by the runtime ⇒ no control plane to // send anyone to, so no CTA (objectui#7253). const showCta = tone !== 'ok' && (meter.upgrade || meter.topUp) && !!cloudConsoleUrl(); + const reset = resetLabel(meter); return (
  • @@ -189,7 +214,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage {statusLabel(tone)} -
    {resetLabel(meter)}
    + {reset ?
    {reset}
    : null} {showCta ? (