Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/7371-ai-usage-indicator-weekly-reset.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/app-shell/src/hooks/useAiUsage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). */
Expand All@@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);

const meters = React.useMemo<RenderableMeter[]>(() => {
if (!usage) return [];
const out: RenderableMeter[] = [];
Expand All@@ -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));
Expand DownExpand Up@@ -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 (
<li key={key} className="flex items-start gap-2.5">
<MeterRing fraction={fraction} tone={tone} size={22} />
Expand All@@ -189,7 +232,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage
{statusLabel(tone)}
</span>
</div>
<div className="text-xs text-muted-foreground">{resetLabel(meter)}</div>
{reset ? <div className="text-xs text-muted-foreground">{reset}</div> : null}
{showCta ? (
<Button
variant="link"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,13 @@ import type { AiUsageResponse, AiMeterUsage } from '../../hooks/useAiUsage';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
// Interpolates `{{name}}` from the options object (mirrors real i18next
// closely enough for count-driven copy like `resetsWeeklyDays`) — a plain
// `String(options?.defaultValue ?? key)` would leave `{{count}}` literal.
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));
const openMock = vi.fn();
Expand DownExpand Up@@ -92,4 +98,61 @@ describe('AiUsageIndicator', () => {
fireEvent.click(cta);
expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer');
});

// objectui#7371 — the free plan's `resetKind: 'weekly'` (cloud PR #1852).
describe('resetKind: weekly', () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;

it('shows "N days" when resetsAt is more than a day out', () => {
const resetsAt = new Date(Date.now() + 3 * ONE_DAY_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 3 days')).toBeInTheDocument();
});

it('switches to hours when resetsAt is within a day (D5: never a token count)', () => {
const resetsAt = new Date(Date.now() + 5 * ONE_HOUR_MS).toISOString();
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.getByText('Resets in 5 hours')).toBeInTheDocument();
});

it('shows no reset line when weekly has no resetsAt yet — contract-first, never guessed client-side', () => {
setUsage({
meters: {
build: meter({ resetKind: 'weekly', resetsAt: null }),
dataChat: meter({ fraction: null }),
},
});
render(<AiUsageIndicator apiBase="/api/v1/ai" />);
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});

it('falls back to no reset line (not a crash or stale copy) on an unrecognized resetKind', () => {
setUsage({
meters: {
// Cast past the union: a future backend value this build doesn't know yet.
build: meter({ resetKind: 'quarterly' as unknown as AiMeterUsage['resetKind'] }),
dataChat: meter({ fraction: null }),
},
});
expect(() => render(<AiUsageIndicator apiBase="/api/v1/ai" />)).not.toThrow();
fireEvent.click(screen.getByTestId('ai-usage-indicator'));
expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
});
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1499,6 +1499,12 @@ const ar = {
statusFull: "تم بلوغ الحد",
resetsDaily: "تُعاد التهيئة الليلة",
resetsMonthly: "تُعاد التهيئة في الدورة القادمة",
resetsWeeklyDays: "{{count}} يوم(أيام) حتى إعادة التعيين",
resetsWeeklyDays_one: "{{count}} يوم حتى إعادة التعيين",
resetsWeeklyDays_other: "{{count}} أيام حتى إعادة التعيين",
resetsWeeklyHours: "{{count}} ساعة(ساعات) حتى إعادة التعيين",
resetsWeeklyHours_one: "{{count}} ساعة حتى إعادة التعيين",
resetsWeeklyHours_other: "{{count}} ساعات حتى إعادة التعيين",
ctaUpgrade: "قم بالترقية للمتابعة",
ctaTopUp: "أضف أرصدة للمتابعة",
ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const de = {
statusFull: "Limit erreicht",
resetsDaily: "Wird heute Nacht zurückgesetzt",
resetsMonthly: "Wird im nächsten Zyklus zurückgesetzt",
resetsWeeklyDays: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyDays_one: "Wird in {{count}} Tag zurückgesetzt",
resetsWeeklyDays_other: "Wird in {{count}} Tagen zurückgesetzt",
resetsWeeklyHours: "Wird in {{count}} Stunden zurückgesetzt",
resetsWeeklyHours_one: "Wird in {{count}} Stunde zurückgesetzt",
resetsWeeklyHours_other: "Wird in {{count}} Stunden zurückgesetzt",
ctaUpgrade: "Upgraden, um weiterzumachen",
ctaTopUp: "Credits hinzufügen, um fortzufahren",
ariaLabel: "KI-Nutzung: {{status}}",
Expand Down
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1805,6 +1805,17 @@ const en = {
statusFull: 'Limit reached',
resetsDaily: 'Resets tonight',
resetsMonthly: 'Resets next cycle',
// `resetKind: 'weekly'` (free plan's rolling 7-day window, cloud PR
// #1852): "N days" (or "N hours" inside the final day). A REAL
// i18next plural family — see the `unsavedCount` note above — so the
// BASE key carries no suffix and must stay in every pack's lookup
// chain (`all-locales-key-parity.test.ts`'s base-key rule).
resetsWeeklyDays: 'Resets in {{count}} days',
resetsWeeklyDays_one: 'Resets in {{count}} day',
resetsWeeklyDays_other: 'Resets in {{count}} days',
resetsWeeklyHours: 'Resets in {{count}} hours',
resetsWeeklyHours_one: 'Resets in {{count}} hour',
resetsWeeklyHours_other: 'Resets in {{count}} hours',
ctaUpgrade: 'Upgrade to keep going',
ctaTopUp: 'Add credits to continue',
ariaLabel: 'AI usage: {{status}}',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1496,6 +1496,12 @@ const es = {
statusFull: "Límite alcanzado",
resetsDaily: "Se restablece esta noche",
resetsMonthly: "Se restablece en el próximo ciclo",
resetsWeeklyDays: "Se restablece en {{count}} días",
resetsWeeklyDays_one: "Se restablece en {{count}} día",
resetsWeeklyDays_other: "Se restablece en {{count}} días",
resetsWeeklyHours: "Se restablece en {{count}} horas",
resetsWeeklyHours_one: "Se restablece en {{count}} hora",
resetsWeeklyHours_other: "Se restablece en {{count}} horas",
ctaUpgrade: "Mejore el plan para continuar",
ctaTopUp: "Añada créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const fr = {
statusFull: "Limite atteinte",
resetsDaily: "Réinitialisation ce soir",
resetsMonthly: "Réinitialisation au prochain cycle",
resetsWeeklyDays: "Réinitialisation dans {{count}} jours",
resetsWeeklyDays_one: "Réinitialisation dans {{count}} jour",
resetsWeeklyDays_other: "Réinitialisation dans {{count}} jours",
resetsWeeklyHours: "Réinitialisation dans {{count}} heures",
resetsWeeklyHours_one: "Réinitialisation dans {{count}} heure",
resetsWeeklyHours_other: "Réinitialisation dans {{count}} heures",
ctaUpgrade: "Passer à l'offre supérieure pour continuer",
ctaTopUp: "Ajouter des crédits pour continuer",
ariaLabel: "Utilisation de l'IA : {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1494,6 +1494,12 @@ const ja = {
statusFull: "上限に達しました",
resetsDaily: "今夜リセットされます",
resetsMonthly: "次のサイクルでリセットされます",
resetsWeeklyDays: "{{count}}日後にリセットされます",
resetsWeeklyDays_one: "{{count}}日後にリセットされます",
resetsWeeklyDays_other: "{{count}}日後にリセットされます",
resetsWeeklyHours: "{{count}}時間後にリセットされます",
resetsWeeklyHours_one: "{{count}}時間後にリセットされます",
resetsWeeklyHours_other: "{{count}}時間後にリセットされます",
ctaUpgrade: "アップグレードして続行",
ctaTopUp: "クレジットを追加して続行",
ariaLabel: "AI 使用状況: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,6 +1492,12 @@ const ko = {
statusFull: "한도에 도달했습니다",
resetsDaily: "오늘 밤 초기화됩니다",
resetsMonthly: "다음 주기에 초기화됩니다",
resetsWeeklyDays: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_one: "{{count}}일 후 초기화됩니다",
resetsWeeklyDays_other: "{{count}}일 후 초기화됩니다",
resetsWeeklyHours: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_one: "{{count}}시간 후 초기화됩니다",
resetsWeeklyHours_other: "{{count}}시간 후 초기화됩니다",
ctaUpgrade: "업그레이드하고 계속하기",
ctaTopUp: "크레딧을 추가하고 계속하기",
ariaLabel: "AI 사용량: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1491,6 +1491,12 @@ const pt = {
statusFull: "Limite atingido",
resetsDaily: "Redefine hoje à noite",
resetsMonthly: "Redefine no próximo ciclo",
resetsWeeklyDays: "Redefine em {{count}} dias",
resetsWeeklyDays_one: "Redefine em {{count}} dia",
resetsWeeklyDays_other: "Redefine em {{count}} dias",
resetsWeeklyHours: "Redefine em {{count}} horas",
resetsWeeklyHours_one: "Redefine em {{count}} hora",
resetsWeeklyHours_other: "Redefine em {{count}} horas",
ctaUpgrade: "Faça upgrade para continuar",
ctaTopUp: "Adicione créditos para continuar",
ariaLabel: "Uso de IA: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,6 +1504,12 @@ const ru = {
statusFull: "Лимит исчерпан",
resetsDaily: "Сбросится сегодня ночью",
resetsMonthly: "Сбросится в следующем цикле",
resetsWeeklyDays: "Сброс через {{count}} дней",
resetsWeeklyDays_one: "Сброс через {{count}} день",
resetsWeeklyDays_other: "Сброс через {{count}} дней",
resetsWeeklyHours: "Сброс через {{count}} часов",
resetsWeeklyHours_one: "Сброс через {{count}} час",
resetsWeeklyHours_other: "Сброс через {{count}} часов",
ctaUpgrade: "Повысьте тариф, чтобы продолжить",
ctaTopUp: "Добавьте кредиты, чтобы продолжить",
ariaLabel: "Использование ИИ: {{status}}",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1652,6 +1652,12 @@ const zh = {
statusFull: '额度已用完',
resetsDaily: '今晚重置',
resetsMonthly: '下个周期重置',
resetsWeeklyDays: '{{count}} 天后重置',
resetsWeeklyDays_one: '{{count}} 天后重置',
resetsWeeklyDays_other: '{{count}} 天后重置',
resetsWeeklyHours: '{{count}} 小时后重置',
resetsWeeklyHours_one: '{{count}} 小时后重置',
resetsWeeklyHours_other: '{{count}} 小时后重置',
ctaUpgrade: '升级以继续使用',
ctaTopUp: '购买额度包以继续',
ariaLabel: 'AI 用量:{{status}}',
Expand Down
Loading