Any authenticated user can view their AI credit balance and current quota consumption (5-hour and weekly rolling windows) in a single unified box in their profile — no special role required.
- The unified box lives in
BillingSection.vueinProfileScreen.vue— not a new route. - The box contains three stacked elements: (1) AI credit balance (
AiCreditPanel), (2) 5h window progress bar, (3) weekly window progress bar. - UC-08006 owns the entire unified box. Previously the credit panel was a separate pre-existing component; it is now considered part of this box’s scope.
- Data is loaded on component mount and refreshed on demand via a single Refresh button for the whole box. No polling, no SSE.
- Quota windows display percentage and time-to-reset only — no dollar amounts. The internal rate-limit threshold (e.g.
limitCents) is intentionally hidden from the user. - AI credit balance (real-money credit) does show dollar amounts — that is legitimate billing information.
- Backend reuses
QuotaCheckService.getSnapshot(userId)(introduced in UC-08002). The endpoint adds computation ofresetsAttimestamps per window derived fromwindowStartAt + TTL. - Data source is Redis (fast, in-window read via Lua snapshot script). PostgreSQL
usage_events(UC-08005 SSoT) are not queried by this endpoint — Redis is the live quota state. - Window reset logic:
- 5-hour window:
windowStartAt + 5h (18 000 s). IfwindowStartAtis null (no usage yet), returnsnow + 5has a conservative upper bound. - Weekly window:
windowStartAt + 7d (604 800 s). Same null-guard applies.
- 5-hour window:
- Thresholds for UI colour coding (FE-only):
- < 50 % → green (normal)
- ≥ 50 % → amber (mild warning)
- ≥ 85 % → amber dark (strong warning)
- ≥ 95 % → red (critical)
- Accessibility: progress bars carry
aria-labelwith percentage only — no dollar amounts. - i18n: both Czech (
cs) and English (en) translations required. - Related: UC-08002 Mara Fatigue & Quota Enforcement — defines the Redis quota model. UC-08005 Reconciliation Log — defines
usage_eventsas SSoT post-MR1. - Cross-reference: UC-08004 admin capacity dashboard continues to display $ amounts for admins — the no-$ rule applies only to this user-facing UC-08006 view.
sequenceDiagram
actor User
User->>+FE: opens Profile → Billing & Usage section
FE->>FE: onMounted — trigger quota load
FE->>+BE: GET /api/v1/users/me/quota <br> Authorization: Bearer {accessToken}
BE->>BE: extract userId from JWT
alt JWT missing or invalid
BE-->>FE: 401 Unauthorized <br> ErrorResponse
FE-->>User: show auth error / redirect to login
end
BE->>+QuotaCheckService: getSnapshot(userId)
Note over QuotaCheckService: SNAPSHOT_LUA_SCRIPT — read-only <br> Redis keys: 5h_used, 5h_window_start, <br> weekly_used, weekly_window_start
QuotaCheckService-->>-BE: UserFatigueSnapshot
BE->>BE: compute resetsAt per window <br> (windowStartAt + TTL, null-guarded)
BE-->>-FE: 200 OK <br> QuotaSnapshotResponse
FE->>FE: compute pct = usedCents / limitCents per window
FE->>FE: render progress bars + % labels + time-to-reset countdown
FE-->>-User: display unified AI credit & usage box
User->>+FE: clicks Refresh button
FE->>FE: set loading = true, disable Refresh button
FE->>+BE: GET /api/v1/users/me/quota <br> Authorization: Bearer {accessToken}
BE->>+QuotaCheckService: getSnapshot(userId)
QuotaCheckService-->>-BE: UserFatigueSnapshot
BE-->>-FE: 200 OK <br> QuotaSnapshotResponse
FE->>FE: update reactive state, set loading = false
FE-->>-User: updated usage values displayed
GET /api/v1/users/me/quota — no request body; authentication via JWT Bearer token.
200 OK QuotaSnapshotResponse:
{
"fiveHourWindow": {
"usedCents": 320,
"limitCents": 500,
"resetsAt": "2026-05-09T18:42:00Z"
},
"weeklyWindow": {
"usedCents": 1850,
"limitCents": 3000,
"resetsAt": "2026-05-14T13:07:00Z"
}
}
Field notes:
usedCents/limitCents— integer, USD micro-cents (1 cent = $0.01). BE returns real values; other clients (admin, internal tooling) may use them freely.- FE computes
pct = usedCents / limitCentsand renders only the percentage — dollar amounts from quota windows are never shown to the end user in UC-08006. resetsAt— ISO 8601 UTC timestamp. FE computes a human-readable countdown from this value locally (e.g. “Resets in 2h 18m”).- When a window has never been used,
usedCents= 0 andresetsAt=now + window TTL(conservative).
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Frontend
Validations
| Field / Control | Constraints | Note |
|---|---|---|
| Refresh button | disabled while loading === true | Prevents concurrent requests |
| Progress bar value | pct = usedCents / limitCents, clamped to [0, 100] % before render | Guards against usedCents > limitCents edge case (over-budget) |
| Quota window display | FE renders only pct (e.g. 12%) — does NOT render $ amounts from usedCents / limitCents | limitCents value is intentionally hidden; only ratio is shown |
resetsAt countdown | computed from resetsAt ISO timestamp at render time, not polled | Re-evaluated on each refresh only |
| Colour threshold (≥ 50 %) | amber warning class applied | Accessible: numeric % label always visible |
| Colour threshold (≥ 85 %) | amber dark warning class applied | Accessible: numeric % label always visible |
| Colour threshold (≥ 95 %) | red danger class applied | Accessible: numeric % label always visible |
UX Guidelines
User Flow
Path 1 — On-mount automatický load
- User klikne na “Billing” v levém navigačním panelu ProfileScreen (
?section=billing). BillingSectionse mountuje — volábudgetStore.fetchBudget()(existující AI credit balance) a nověusageStore.fetchUsage().- Během fetchu se zobrazí loading skeleton v celém unified boxu.
- Po úspěšném načtení — skeleton mizí, zobrazí se živá data:
- AI credit balance: existující
AiCreditPanel(zůstává nezměněn, zobrazuje $ balance). - 5h window progress bar: procento + time-to-reset countdown (bez $ částek).
- Weekly window progress bar: procento + time-to-reset countdown (bez $ částek).
- AI credit balance: existující
- Refresh button (top-right boxu) je aktivní ihned po první load.
Path 2 — Manual refresh
- User klikne Refresh button (ikona
RefreshCwz lucide-vue-next + label “Refresh”). - Tlačítko přejde do loading stavu: ikona se roztočí (
animate-spin), label změní na “Refreshing…” — tlačítko zůstane viditelné ale disabled. usageStore.fetchUsage()se zavolá znovu.- Existující data v progress barech zůstanou zobrazena (žádný skeleton — jen tiché update hodnot po fetch).
- Po dokončení fetch — button se vrátí do normálního stavu, hodnoty se aktualizují in-place s
transition-all duration-300.
Path 3 — Error při fetch
- Fetch selže (network error nebo 401).
- Toast “Failed to load usage data. Please try again.” (rose, 5 s, dismissible) se zobrazí.
- Existující data (pokud byly jednou načteny) zůstanou v progress barech — uživatel vidí stale data s discreet indikátorem “Last updated: X min ago” ve fg-4 barvě.
- Pokud initial load selže (žádná předchozí data) — progress bar sekce zobrazí ”— Unable to load” s Retry tlačítkem.
Layout
Screen type: Rozšíření existující sekce (ne nová route)
Umístění: BillingSection.vue — sjednocený box obsahující AI credit balance + oba quota progress bary
Container: Sdílí existující main class="py-8 px-8 pb-16" z ProfileScreen
ASCII mockup — unified AI credit & usage box (desktop)
┌─ Profile → Billing & Usage ─────────────────────────────────────────────┐
│ │
│ BILLING & USAGE │
│ Manage your AI credits and monitor usage windows. │
│ │
│ ┌─ AI CREDIT & USAGE ─────────────────────────────────────── [↻] ───┐ │
│ │ │ │
│ │ AI CREDIT │ │
│ │ $48.20 remaining Spent: $51.80 │ │
│ │ ████████████░░░░░░░░░░░ 52% Initial: $100.00 │ │
│ │ │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ │ │
│ │ 5H ROLLING WINDOW │ │
│ │ ████████░░░░░░░░░░░░░░░░░░░░ 12% │ │
│ │ Resets in 1h 18m │ │
│ │ │ │
│ │ WEEKLY ROLLING WINDOW │ │
│ │ ████████████████░░░░░░░░░░░░ 62% │ │
│ │ Resets in 4d 7h │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ (existující HostingBillingPanel, Estimated costs, FieldRows, Invoices) │
└──────────────────────────────────────────────────────────────────────────┘
Klíčové: quota windows zobrazují pouze % a countdown — žádné “12% of $10.00” ani žádná jiná $ hodnota pro quota windows. AI credit balance nahoře ($ zůstatok) je výjimka — to je legitimní billing info.
ASCII mockup — 5h window detail
┌─ 5H ROLLING WINDOW ────────────────────────────────────────────────────┐
│ 5H ROLLING WINDOW │
│ │
│ ┌── SpendProgressBar ──────────────────────────────────────────────┐ │
│ │ [████████░░░░░░░░░░░░░░░░░░░░] bar track h-2.5 │ │
│ │ 12% │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ Resets in 1h 18m │
│ (text-xs fg-3, ikona Clock size-12 inline vlevo) │
└────────────────────────────────────────────────────────────────────────┘
Responsive chování
| Breakpoint | Layout |
|---|---|
>= md (768px) | Dvě window progress bar sekce side-by-side — grid grid-cols-2 gap-4 |
< md (< 768px) | Dvě window progress bar sekce stacked — grid grid-cols-1 gap-4 |
Refresh button je v top-right celého unified boxu na všech breakpointech.
Komponenty
| Element | Komponenta | Soubor | Mapping |
|---|---|---|---|
| Unified box wrapper | sekce v BillingSection | src/screens/profile/components/BillingSection.vue | Nahrazuje předchozí oddělený layout |
| AI credit panel | AiCreditPanel (existující) | src/screens/budget/components/AiCreditPanel.vue | Beze změny, první řádek boxu |
| 5h window progress bar | SpendProgressBar (reuse) | src/screens/admin/components/SpendProgressBar.vue | pct z usedCents/limitCents; bez $ popisků |
| Weekly window progress bar | SpendProgressBar (reuse) | src/screens/admin/components/SpendProgressBar.vue | pct z usedCents/limitCents; bez $ popisků |
| Time-to-reset display (oba windows) | inline pod progress barem | — | Clock (lucide, size 12) + humanized string |
| Refresh button | lucide RefreshCw + button | — | animate-spin při loading, aria-label povinný; jeden button pro celý box |
| Error toast | existující toast systém | — | rose, 5 s, dismissible |
Poznámka pro budoucí refactor:
SpendProgressBarse importuje zsrc/screens/admin/— uvažovat o přesunu dosrc/common/components/až bude sdílena ve více než dvou místech.
SpendProgressBar props mapping
Existující komponenta (src/screens/admin/components/SpendProgressBar.vue) přijímá:
spent: number— použité centy (z APIusedCents)cap: number— limit v centech (z APIlimitCents)
Pozor: Pro UC-08006 quota windows se předávají usedCents a limitCents do SpendProgressBar pro výpočet %, ale komponenta nesmí renderovat $ hodnoty — buď se odstraní z template slotu, nebo se přidá prop hideAmounts: boolean. Doporučeno: přidat volitelný prop hideAmounts?: boolean (default false); při hideAmounts=true renderuje jen progress bar + % číslo.
Zároveň je nutné rozšíření prop earlyWarnAt?: number (default undefined) — pro UC-08006 volat s earlyWarnAt=50 (4stupňová škála 50/85/95).
Color Thresholds
Konzistentní s UC-08004 SpendProgressBar, rozšířeno o 50% early warning:
| Rozsah | Barva | CSS var | Popis |
|---|---|---|---|
| 0–49 % | Zelená | var(--green) | Normální stav |
| 50–84 % | Žlutá/Amber | var(--amber) | Mírné varování — přes polovinu okna |
| 85–94 % | Oranžová (amber dark) | oklch(0.68 0.16 55) | Silné varování — limit se blíží |
| 95–100 % | Červená | var(--rose) | Kritické — téměř vyčerpáno |
Barva NIKDY není jediný indikátor — procento je vždy textově zobrazeno. Dolarové limity quota oken se nezobrazují.
Time-to-reset Display
Formát: “Resets in Xd Yh Zm” — humanized, počítáno na FE z resetsAt ISO timestamp.
resetsAt: "2026-05-09T18:30:00Z"
now: "2026-05-09T17:12:00Z"
delta: 1h 18m
display: "Resets in 1h 18m"
Pravidla formátování:
| Delta | Formát | Příklad |
|---|---|---|
| > 1 den | Xd Yh | ”Resets in 4d 7h” |
| > 1 hodina | Xh Ym | ”Resets in 1h 18m” |
| > 1 minuta | Xm | ”Resets in 42m” |
| < 1 minuta | ”Resets soon” | — |
| resetsAt v minulosti | ”Resetting…” | — |
| resetsAt null | skryj řádek | — |
Implementace: Pure computed z resetsAt ISO string, žádný live countdown (hodnota je statická po fetch, obnoví se jen při manuálním refresh). FE utility funkce humanizeDuration(resetsAt: string): string.
i18n: Klíč billing.resetsIn s interpolací — viz sekce i18n níže.
Loading States
On-mount (initial load)
- Unified box zobrazí loading skeleton pro quota windows souběžně (
usageStore.loading === true). AiCreditPanelmá vlastní skeleton (existujícíbudgetStore.loading), není dotčen.- Nadpis boxu a Refresh button se zobrazí ihned (nejsou skeletovány).
Manual refresh
- Refresh button: ikona
RefreshCwpřejde naanimate-spin, label změní nat('billing.refreshing'). - Button se stane
disabled(opacity-60, cursor-not-allowed). - Obsah progress barů se neskeltonizuje — data zůstanou viditelná, jen tiché in-place update po fetch.
- Po dokončení fetch: spinner → normální ikona, values se aktualizují s
transition-all duration-300.
Error States
| Chyba | Stav dat | Zobrazení |
|---|---|---|
| Initial load — network error | Žádná předchozí data | Quota window sekce zobrazí “Unable to load” text + Retry button |
| Initial load — 401 | Žádná předchozí data | Toast “Session expired” + redirect na login (existující auth interceptor) |
| Refresh — network error | Stale data dostupná | Toast (rose, 5 s): t('billing.errors.refreshFailed'). Progress bary zachovají poslední úspěšná data. |
| Refresh — 401 | Stale data dostupná | Existující auth interceptor → redirect na login |
| resetsAt null / malformed | — | Skryj countdown řádek, nezobrazuj fallback datum |
Toast behavior: Neblokující — zobrazí se v rohu (existující toast systém projektu), automaticky zmizí po 5 s, dismissible.
Accessibility
Progress bars
Každý SpendProgressBar pro quota windows musí mít:
role="progressbar"na track elementuaria-valuemin="0"aria-valuemax="100"aria-valuenow="{pct}"aria-labelve formátu obsahujícím pouze % — bez $ částek
Příklady aria-label:
"5h window: 12% used"(krátká forma)"5h window: 12% — resets in 1h 18m"(doporučená forma s countdown)
Příklad implementace v slotu:
<SpendProgressBar
:spent="usedCents"
:cap="limitCents"
:hide-amounts="true"
:early-warn-at="50"
:aria-label="`${t('billing.fiveHour.ariaLabel', { pct, resetIn: humanizeDuration(resetsAt) })}`"
/>
Pozor: aria-label nesmí obsahovat $ amount pro quota windows. I18n klíč billing.fiveHour.ariaLabel musí být upraven — viz sekce i18n.
Refresh button
<button
:aria-label="t('billing.refresh.ariaLabel')"
:aria-busy="usageStore.loading"
:disabled="usageStore.loading"
@click="usageStore.fetchUsage()">
<RefreshCw :size="14" :class="{ 'animate-spin': usageStore.loading }" />
<span>{{ usageStore.loading ? t('billing.refreshing') : t('billing.refresh') }}</span>
</button>
Obecná pravidla
- Unified box dostane
role="region"saria-label="AI credit and usage". AiCreditPanelmá vlastní accessibility řešení (existující, nezměněno).- Time-to-reset text je plain
<p>(žádný ARIA role), barva není podmíněná. - Keyboard navigace: Tab pořadí — Refresh button → AiCreditPanel → 5h progress bar → Weekly progress bar → HostingBillingPanel.
- Focus visible na Refresh button — projekt používá standard browser focus ring, neodebrávat.
- Skeleton stav:
aria-live="polite"na wrapper quota sekce — screen reader oznámí update po loadu. - WCAG AA kontrast: amber/rose/green accenty na
var(--bg-2)background jsou ≥ 4.5:1 (konzistentní s UC-08004 rozhodnutím).
i18n Keys
Czech (cs)
billing.usage.sectionTitle = "Využití AI"
billing.usage.sectionSubtitle = "Přehled spotřeby v klouzavých oknech"
billing.fiveHour.title = "5h okno"
billing.fiveHour.ariaLabel = "5h okno: {pct}% — resetuje se za {resetIn}"
billing.weekly.title = "Týdenní okno"
billing.weekly.ariaLabel = "Týdenní okno: {pct}% — resetuje se za {resetIn}"
billing.refresh = "Obnovit"
billing.refreshing = "Obnovuji..."
billing.refresh.ariaLabel = "Obnovit data využití"
billing.resetsIn = "Resetuje se za {duration}"
billing.resetsSoon = "Resetuje se brzy"
billing.resetting = "Resetování..."
billing.errors.loadFailed = "Nepodařilo se načíst data využití. Zkus to znovu."
billing.errors.refreshFailed = "Obnovení selhalo. Data jsou z {time}."
billing.retry = "Zkusit znovu"
billing.unableToLoad = "Nelze načíst data"
billing.lastUpdated = "Naposledy aktualizováno: {time}"
English (en)
billing.usage.sectionTitle = "AI Usage"
billing.usage.sectionSubtitle = "Consumption overview across rolling windows"
billing.fiveHour.title = "5H Rolling Window"
billing.fiveHour.ariaLabel = "5h window: {pct}% — resets in {resetIn}"
billing.weekly.title = "Weekly Rolling Window"
billing.weekly.ariaLabel = "Weekly window: {pct}% — resets in {resetIn}"
billing.refresh = "Refresh"
billing.refreshing = "Refreshing..."
billing.refresh.ariaLabel = "Refresh usage data"
billing.resetsIn = "Resets in {duration}"
billing.resetsSoon = "Resets soon"
billing.resetting = "Resetting..."
billing.errors.loadFailed = "Failed to load usage data. Please try again."
billing.errors.refreshFailed = "Refresh failed. Data is from {time}."
billing.retry = "Retry"
billing.unableToLoad = "Unable to load"
billing.lastUpdated = "Last updated: {time}"
Mobile Responsiveness
Primární layout: desktop (ProfileScreen je desktop-first, min-width 1240px grid).
| Breakpoint | Layout quota windows | Refresh button | Progress bary |
|---|---|---|---|
>= md (768px) | grid grid-cols-2 gap-4 — side-by-side | Inline top-right unified boxu | Standardní výška |
< md (768px) | grid grid-cols-1 gap-4 — stacked | Inline top-right unified boxu | Plná šířka |
Minimální šířka progress barů: 280px (zajistí SpendProgressBar čitelnost).
Vazby na existující komponenty
SpendProgressBar (src/screens/admin/components/SpendProgressBar.vue):
- Existující props:
spent: number,cap: number - Nový prop:
hideAmounts?: boolean(defaultfalse) — přitruerenderuje jen bar + % bez $ hodnot - Nový prop:
earlyWarnAt?: number(defaultundefined) — pro 4stupňovou color škálu; UC-08006 volá searlyWarnAt=50
BillingSection (src/screens/profile/components/BillingSection.vue):
- Přidat import
SpendProgressBarz admin komponent - Přidat import nového
useUsageStore(pinia store pro UC-08006 API) - Refaktorovat layout do unified boxu:
AiCreditPanel+ quota windows sekce v jednom containeru s jedním Refresh buttonem
Backend
Validations
| Field | Constraints | Note |
|---|---|---|
JWT Authorization header | not_blank, valid signature, not expired | Spring Security filter — returns 401 if missing or invalid |
userId | extracted from JWT principal; must resolve to an existing user | AuthorizationService.getCurrentUserId() |
| Redis snapshot | read-only; no writes triggered by this endpoint | SNAPSHOT_LUA_SCRIPT |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Authenticated user with active 5h and weekly usage in Redis (used5h=320, limit5h=500, usedWk=1850, limitWk=3000) | GET /api/v1/users/me/quota is called | 200 OK with correct usedCents and limitCents for both windows; resetsAt is a future UTC timestamp; FE renders 5h as 64% and weekly as 62% with countdown — no $ amounts in quota window UI |
| Authenticated user with 5h window at 86 % of limit (approaching threshold) | GET /api/v1/users/me/quota is called | 200 OK; FE computes pct > 0.85; FE applies amber dark colour class to 5h progress bar; % label visible |
| Authenticated user with weekly window at 97 % of limit (danger zone) | GET /api/v1/users/me/quota is called | 200 OK; FE computes pct > 0.95; FE applies red colour class to weekly progress bar; % label visible |
Request sent without Authorization header | GET /api/v1/users/me/quota is called | 401 Unauthorized with code: AUTHENTICATION_FAILED |
| Authenticated user with no prior usage (all Redis keys absent / zero) | GET /api/v1/users/me/quota is called | 200 OK; usedCents = 0 for both windows; FE renders 0%; resetsAt = approximately now + TTL |
| User is on Billing & Usage panel and clicks the Refresh button | second GET /api/v1/users/me/quota is called | 200 OK; FE updates reactive state with new values; Refresh button re-enabled after response |
| Quota window cards rendered after successful load | UI text content is inspected | String "$" does NOT appear inside quota window progress bar elements or their labels; "$" MAY appear inside AI credit balance section |
Thanks for the feedback.