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..1ae66824d9 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 { @@ -96,6 +99,20 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage const { t } = useObjectTranslation(); const { usage } = useAiUsage({ apiBase, enabled }); + // "Now", read OUTSIDE render (react-hooks/purity forbids `Date.now()` in the + // render body — it is non-deterministic and the compiler assumes render can + // re-run any number of times). `null` until the mount effect measures it — + // the render body itself never calls `Date.now()`, only reads this state — + // then refreshed periodically so a long-open popover's "N days/hours" stays + // roughly current; the countdown is day/hour-grained, so a minute of drift + // is invisible. + const [now, setNow] = React.useState(null); + React.useEffect(() => { + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, []); + const meters = React.useMemo(() => { if (!usage) return []; const out: RenderableMeter[] = []; @@ -122,10 +139,35 @@ 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` and + // the `now` state above — PURE given those two inputs, no clock read here. + // 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, nowMs: number): string => { + const diffMs = new Date(resetsAt).getTime() - nowMs; + 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, a `weekly` meter with no `resetsAt` + // yet (nothing counted) is never guessed at (objectui#7371), and `now` not + // yet measured (the one frame before the mount effect above runs) is the + // same "nothing to show yet" as any other missing input. + 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 && now !== null ? weeklyResetLabel(meter.resetsAt, now) : 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 +212,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 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage {statusLabel(tone)} -
    {resetLabel(meter)}
    + {reset ?
    {reset}
    : null} {showCta ? (