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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
computeCanEditCutoff,
computeCanRedeemResetCredit,
hasQuotaCutoffOverrides,
getSoonestResetInfo,
type CardStatus,
type SoonestResetInfo,
} from "./utils";
import QuotaCardHeader from "./parts/QuotaCardHeader";
import QuotaCardExpanded from "./parts/QuotaCardExpanded";
Expand Down Expand Up @@ -92,6 +94,12 @@ export default function QuotaCard({
[connection.provider, rawQuotas, quotaVisibility]
);
const cardStatus = useMemo<CardStatus>(() => worstStatus(quotas), [quotas]);
// Soonest future reset across the card's rows — surfaced as a header chip so
// expiration is scannable across a whole grid of cards without reading rows.
const soonestReset = useMemo<SoonestResetInfo | null>(
() => getSoonestResetInfo(quotas),
[quotas]
);
const tierMeta = useMemo(
() =>
normalizePlanTier(
Expand Down Expand Up @@ -140,6 +148,7 @@ export default function QuotaCard({
connection={connection}
providerLabel={providerLabel}
cardStatus={cardStatus}
soonestReset={soonestReset}
tierMeta={tierMeta}
resolvedPlan={resolvedPlan}
emailsVisible={emailsVisible}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function ProviderQuotaSection({
</div>
</summary>
<div className="px-3 pb-3">
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3">
{connections.map((conn) => (
<QuotaCard
key={conn.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getQuotaRemainingPercentage,
getQuotaVisibilityKey,
shouldShowQuotaUsageCount,
resetUrgency,
} from "../utils";
import QuotaMiniBar from "../QuotaMiniBar";
import { translateUsageOrFallback, type UsageTranslationValues } from "../i18nFallback";
Expand Down Expand Up @@ -270,6 +271,20 @@ function QuotaDetailRow({
const usedNum = Number(q.used || 0);
const totalNum = Number(q.total || 0);
const showUsage = shouldShowQuotaUsageCount(q);
// Countdown urgency + absolute-time tooltip: the reset date must be
// readable at a glance, not a stranded 10px gray string. Finite rows with
// no reset (idle windows) render an aligned em-dash so the right column
// keeps its rhythm across every row.
const resetMs = q.resetAt ? new Date(q.resetAt).getTime() : Number.NaN;
const resetTitle =
Number.isFinite(resetMs) ? new Date(resetMs).toLocaleString() : undefined;
const resetCls = Number.isFinite(resetMs)
? resetUrgency(resetMs - Date.now()) === "critical"
? "text-rose-500"
: resetUrgency(resetMs - Date.now()) === "warning"
? "text-amber-500"
: "text-text-muted"
: "text-text-muted";

return (
<div className="flex flex-col gap-1 py-1" title={q.modelKey || q.name}>
Expand Down Expand Up @@ -310,8 +325,12 @@ function QuotaDetailRow({
{q.staleAfterReset ? (
<span title={t("refreshing")}>⟳</span>
) : cd ? (
<span>
⏱ {t("resetsIn")} {cd}
<span className={`inline-flex items-center gap-0.5 font-medium ${resetCls}`} title={resetTitle}>
⏱ {cd}
</span>
) : !q.unlimited ? (
<span className="text-text-muted/60" title={t("noPlanFromProvider")}>
—
</span>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Badge from "@/shared/components/Badge";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { readCookieExpiresAt } from "@/shared/utils/webCookieExpiry";
import { formatCountdown, type CardStatus } from "../utils";
import { formatCountdown, type CardStatus, type SoonestResetInfo } from "../utils";
import { translateUsageOrFallback } from "../i18nFallback";

const STATUS_DOT_CLASS: Record<CardStatus, string> = {
Expand All @@ -19,6 +19,8 @@ interface Props {
connection: any;
providerLabel: string;
cardStatus: CardStatus;
/** Soonest future quota reset across the card's rows (null when unknown). */
soonestReset: SoonestResetInfo | null;
tierMeta: { key: string; label: string; variant: any };
resolvedPlan: string | null;
emailsVisible: boolean;
Expand All @@ -29,10 +31,17 @@ interface Props {
togglingActive: boolean;
}

const RESET_URGENCY_CLASS: Record<string, string> = {
critical: "text-rose-500",
warning: "text-amber-500",
calm: "text-text-muted",
};

export default function QuotaCardHeader({
connection,
providerLabel,
cardStatus,
soonestReset,
tierMeta,
resolvedPlan,
emailsVisible,
Expand Down Expand Up @@ -127,7 +136,16 @@ export default function QuotaCardHeader({
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5 self-center">
<div className="flex shrink-0 items-center gap-1 self-center">
{soonestReset && (
<span
className={`inline-flex h-5 shrink-0 items-center gap-0.5 rounded-full border border-border px-1.5 text-[10px] font-semibold leading-none tabular-nums ${RESET_URGENCY_CLASS[soonestReset.urgency] ?? "text-text-muted"}`}
title={`${t("resetsIn")} ${soonestReset.countdown} — ${new Date(soonestReset.iso).toLocaleString()}`}
>
<span className="material-symbols-outlined text-[11px] leading-none">schedule</span>
{soonestReset.countdown}
</span>
)}
<button
type="button"
disabled={togglingActive}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,30 @@ export function formatCountdown(resetAt: string | null | undefined): string | nu
}
}

export function getNextResetSummary(quotas: any[] | undefined): string | null {
export type ResetUrgency = "critical" | "warning" | "calm";

/**
* How soon until the soonest quota reset? Pure classification so the header
* chip and per-row countdowns share one threshold set: <1h critical (red),
* <6h warning (amber), otherwise calm (muted).
*/
export function resetUrgency(msUntilReset: number): ResetUrgency {
if (msUntilReset <= 3_600_000) return "critical";
if (msUntilReset <= 6 * 3_600_000) return "warning";
return "calm";
}

export interface SoonestResetInfo {
/** Absolute ISO time of the soonest future reset across the card's quotas. */
iso: string;
/** Relative countdown label, e.g. "2h 14m". */
countdown: string;
/** Urgency bucket for coloring. */
urgency: ResetUrgency;
}

/** Soonest future reset across a card's quota rows (past/absent resets ignored). */
export function getSoonestResetInfo(quotas: any[] | undefined): SoonestResetInfo | null {
if (!quotas || quotas.length === 0) return null;
const now = Date.now();
let soonest = Number.POSITIVE_INFINITY;
Expand All @@ -507,7 +530,16 @@ export function getNextResetSummary(quotas: any[] | undefined): string | null {
soonestIso = typeof q.resetAt === "string" ? q.resetAt : new Date(ts).toISOString();
}
}
return soonestIso ? formatCountdown(soonestIso) : null;
if (!soonestIso) return null;
return {
iso: soonestIso,
countdown: formatCountdown(soonestIso) ?? "",
urgency: resetUrgency(soonest - now),
};
}

export function getNextResetSummary(quotas: any[] | undefined): string | null {
return getSoonestResetInfo(quotas)?.countdown ?? null;
}

function addQuotaModelIdVariants(out: Set<string>, provider: string, modelId: string) {
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/quota-card-grid-density-6815.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ function parseColumnRules(className: string): ColumnRule[] {
continue;
}

const autoFitMatch = rest.match(/^\[repeat\(auto-fit,\s*minmax\((.+),\s*1fr\)\)\]$/);
const autoFitMatch = rest.match(
/^\[repeat\((?:auto-fill|auto-fit),\s*minmax\((.+),\s*1fr\)\)\]$/
);
if (autoFitMatch) {
const trackPxMatches = [...autoFitMatch[1].matchAll(/(\d+)px/g)];
if (trackPxMatches.length > 0) {
Expand Down
18 changes: 14 additions & 4 deletions tests/unit/quota-card-grid-mobile-7072.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,26 @@ function assertSingleColumnMobileFallback(cardGridClassName: string): void {
return;
}

// Container-driven layout (#7027 style): an arbitrary-value auto-fit grid
// template computes column count from available width, not a breakpoint.
// Container-driven layout (#7027 style): an arbitrary-value auto-fill/auto-fit
// grid template computes column count from available width, not a breakpoint.
// auto-fill is the required shape since the 2026-09 consistency redesign:
// auto-fit collapses empty tracks, stretching a provider's lone card
// full-width while multi-account providers show narrow cards.
const autoFitToken = tokens.find(
(t) => t.startsWith("grid-cols-[") && t.includes("auto-fit") && t.includes("minmax(")
(t) =>
t.startsWith("grid-cols-[") &&
(t.includes("auto-fill") || t.includes("auto-fit")) &&
t.includes("minmax(")
);
assert.ok(
autoFitToken,
`expected either an unprefixed grid-cols-1 mobile fallback or a repeat(auto-fit, minmax(...)) ` +
`expected either an unprefixed grid-cols-1 mobile fallback or a repeat(auto-fill, minmax(...)) ` +
`container grid, got className="${cardGridClassName}"`
);
assert.ok(
autoFitToken!.includes("auto-fill"),
"per-group card grid must use auto-fill (not auto-fit): auto-fit stretches a lone card full-width"
);

const minTrackMatch =
autoFitToken!.match(/minmax\(min\(100%,(\d+)px\)/) ?? autoFitToken!.match(/minmax\((\d+)px/);
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/quota-card-sort-stability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ test("sub-minute reset jitter must not reorder same-status cards", () => {
// Both accounts idle (0 used): resets differ by 2 SECONDS only — the exact
// production shape after a bulk refresh. Order must fall through to the
// stable name comparison, not flip with the jitter.
const base = Date.now() + 5 * 3600_000;
// Anchor 10s into a minute so base and base+2s can never straddle the
// minute boundary the sort rounds to.
const base = Math.floor((Date.now() + 5 * 3600_000) / 60_000) * 60_000 + 10_000;
const a = conn("aaa", "alpha");
const b = conn("bbb", "beta");
// alpha's jitter is LATER — the buggy sort would put beta first purely on
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/quota-soonest-reset-chip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Soonest-reset header chip (2026-09 quota page redesign): expiration must be
// scannable across a grid of cards. Pins the shared pure helpers — soonest
// future reset selection (past/absent resets ignored), countdown presence,
// and the urgency buckets (<1h critical red, <6h warning amber, else calm).
import test from "node:test";
import assert from "node:assert/strict";
import {
getSoonestResetInfo,
getNextResetSummary,
resetUrgency,
} from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils";

function inMs(ms: number): string {
return new Date(Date.now() + ms).toISOString();
}

test("getSoonestResetInfo — picks the soonest FUTURE reset, ignoring past and absent", () => {
const info = getSoonestResetInfo([
{ name: "a", resetAt: null },
{ name: "b", resetAt: inMs(2 * 3600_000) },
{ name: "c", resetAt: inMs(30 * 60_000) },
{ name: "d", resetAt: new Date(Date.now() - 3600_000).toISOString() }, // past
]);
assert.ok(info);
assert.equal(info.urgency, "critical", "30 minutes out is critical");
assert.ok(info.countdown.length > 0);
});

test("getSoonestResetInfo — null when nothing has a future reset", () => {
assert.equal(getSoonestResetInfo([]), null);
assert.equal(getSoonestResetInfo([{ name: "a", resetAt: null }]), null);
assert.equal(getSoonestResetInfo([{ name: "a", resetAt: "2020-01-01T00:00:00Z" }]), null);
assert.equal(getSoonestResetInfo(undefined), null);
});

test("resetUrgency — threshold buckets", () => {
assert.equal(resetUrgency(59 * 60_000), "critical");
assert.equal(resetUrgency(61 * 60_000), "warning");
assert.equal(resetUrgency(5 * 3600_000 + 60_000), "warning");
assert.equal(resetUrgency(7 * 3600_000), "calm");
});

test("getNextResetSummary — delegates to the shared info (back-compat string)", () => {
assert.equal(getNextResetSummary([{ name: "a", resetAt: inMs(90 * 60_000) }]), "1h 30m");
assert.equal(getNextResetSummary([{ name: "a", resetAt: null }]), null);
});