Move plan/quota UI to Account; Gemini-style usage card (#73) #74

Merged
westfarn merged 2 commits from issue-73-move-usage-ui-to-account into master 2026-07-31 07:08:07 -07:00
Showing only changes of commit d799379eb7 - Show all commits
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import {
formatBillingDate,
formatTokenCount,
SubscriptionMe,
} from '../../utils/finance';
@@ -20,35 +21,72 @@ const GlassCard = styled.div`
const CardTitle = styled.h2`
font-size: 1.8rem;
margin-bottom: 1.5rem;
margin: 0 0 0.35rem 0;
color: ${({ theme }) => theme.colors.text};
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
padding-bottom: 1rem;
`;
const SettingRow = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
const PlanName = styled.p`
margin: 0 0 1.75rem 0;
font-size: 1rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text};
opacity: 0.85;
`;
&:last-child {
border-bottom: none;
const MeterBlock = styled.div`
margin-bottom: 1.5rem;
&:last-of-type {
margin-bottom: 0;
}
`;
const SettingLabel = styled.span`
font-size: 1.1rem;
color: ${({ theme }) => theme.colors.text};
font-weight: 500;
const MeterHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.55rem;
`;
const SettingValue = styled.span`
font-size: 1.1rem;
const MeterLabel = styled.span`
font-size: 1rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text};
opacity: 0.9;
text-align: right;
`;
const MeterMeta = styled.span`
font-size: 0.85rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.65;
white-space: nowrap;
`;
const Track = styled.div`
width: 100%;
height: 0.55rem;
border-radius: 999px;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)'};
overflow: hidden;
`;
const Fill = styled.div<{ $pct: number; $warn: boolean }>`
height: 100%;
width: ${({ $pct }) => `${Math.min(100, Math.max(0, $pct))}%`};
border-radius: 999px;
background: ${({ theme, $warn }) => ($warn ? '#e67e22' : theme.main)};
transition: width 0.35s ease;
`;
const MeterFooter = styled.div`
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 0.4rem;
font-size: 0.8rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.65;
`;
const BodyText = styled.p`
@@ -58,6 +96,49 @@ const BodyText = styled.p`
line-height: 1.5;
`;
const UpgradeHint = styled.p`
margin: 1.5rem 0 0 0;
padding-top: 1.25rem;
border-top: 1px solid ${({ theme }) => theme.colors.cardBorder};
font-size: 0.9rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
line-height: 1.45;
`;
function clampPct(used: number, quota: number): number {
if (quota <= 0) return 0;
return Math.min(100, Math.round((used / quota) * 1000) / 10);
}
type MeterProps = {
label: string;
meta: string;
usedLabel: string;
pct: number | null;
};
const UsageMeter = ({ label, meta, usedLabel, pct }: MeterProps): JSX.Element => {
const showBar = pct !== null;
const warn = showBar && pct >= 90;
return (
<MeterBlock>
<MeterHeader>
<MeterLabel>{label}</MeterLabel>
<MeterMeta>{meta}</MeterMeta>
</MeterHeader>
<Track aria-hidden={!showBar}>
{showBar ? <Fill $pct={pct} $warn={warn} /> : null}
</Track>
<MeterFooter>
<span>{usedLabel}</span>
<span>{showBar ? `${pct}%` : '—'}</span>
</MeterFooter>
</MeterBlock>
);
};
const UsageSummaryCard = (): JSX.Element => {
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
const [loading, setLoading] = useState(true);
@@ -79,35 +160,73 @@ const UsageSummaryCard = (): JSX.Element => {
}, [load]);
const usage = subscription?.usage;
const promptsLabel =
!usage || usage.prompts_remaining === null || usage.prompt_quota === null
? '—'
: `${usage.prompts_remaining}/${usage.prompt_quota}`;
const promptQuota = usage?.prompt_quota ?? null;
const promptsUsed = usage?.prompts_in_window ?? 0;
const promptPct =
promptQuota !== null ? clampPct(promptsUsed, promptQuota) : null;
const tokenQuota = usage?.monthly_token_quota ?? null;
const tokensUsed = usage?.tokens_total_period ?? null;
const tokenPct =
tokenQuota !== null && tokensUsed !== null
? clampPct(tokensUsed, tokenQuota)
: tokenQuota !== null
? 0
: null;
return (
<GlassCard data-testid="usage-summary-card">
<CardTitle>Plan &amp; usage</CardTitle>
<CardTitle>Usage limit</CardTitle>
{loading ? (
<BodyText>Loading usage</BodyText>
) : !subscription?.plan ? (
<BodyText>No active plan yet.</BodyText>
) : (
<>
<SettingRow>
<SettingLabel>Plan</SettingLabel>
<SettingValue>{subscription.plan.name}</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Prompts left ({usage?.window_hours ?? '—'}h)</SettingLabel>
<SettingValue>{promptsLabel}</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Period tokens</SettingLabel>
<SettingValue>
in {formatTokenCount(usage?.tokens_in_period ?? null)} · out{' '}
{formatTokenCount(usage?.tokens_out_period ?? null)}
</SettingValue>
</SettingRow>
<PlanName>{subscription.plan.name}</PlanName>
<UsageMeter
label="Prompts"
meta={`Resets every ${usage?.window_hours ?? '—'}h`}
usedLabel={
promptQuota === null
? `${promptsUsed.toLocaleString()} used`
: `${promptsUsed.toLocaleString()} / ${promptQuota.toLocaleString()}`
}
pct={promptPct}
/>
{tokenQuota !== null ? (
<UsageMeter
label="Tokens this month"
meta={
usage?.period_end
? `Resets ${formatBillingDate(usage.period_end)}`
: 'Calendar month'
}
usedLabel={
tokensUsed === null
? `— / ${tokenQuota.toLocaleString()}`
: `${formatTokenCount(tokensUsed)} / ${tokenQuota.toLocaleString()}`
}
pct={tokenPct}
/>
) : (
<UsageMeter
label="Tokens this period"
meta={
usage?.period_end
? `Period ends ${formatBillingDate(usage.period_end)}`
: 'This month'
}
usedLabel={`in ${formatTokenCount(usage?.tokens_in_period ?? null)} · out ${formatTokenCount(usage?.tokens_out_period ?? null)}`}
pct={null}
/>
)}
<UpgradeHint>
Need more capacity? Manage or upgrade your plan in Billing below.
</UpgradeHint>
</>
)}
</GlassCard>