Nový endpoint pro per-projekt AI breakdown a per-namespace hosting breakdown, plus rozšíření
kredit panelů v Profile/Billing o reálná data (nahrazení mockupu USAGE konstanty).
- Stávající
GET /api/v1/users/me/usage/breakdownvrací AI + hosting součty bez per-projekt dimenze. Tato UC přidává nový endpointGET /api/v1/users/me/usage/project-breakdownvracející obě sekce v jedné odpovědi. - Rozhodnutí o tvaru endpointu: jeden kombinovaný endpoint (ne dva). Důvod: FE potřebuje
obě sekce (AI top-projekty + hosting per-namespace) najednou při otevření billing panelu,
jediný network call je efektivnější a response je logicky celistvá (stejný
from/toparametr platí pro obě sekce). - AI per-projekt:
SUM(charged_cost_usd)+ token součty zusage_eventsGROUP BYproject_id, seřazeno DESC, limit N. ProprojectNamese provede LEFT JOIN naprojectstabulku (LEFT kvůli smazaným projektům — orphan project_id se vrátí sprojectName = null). - Hosting per-namespace:
SUM(cost_usd)zhosting_cost_eventsGROUP BYnamespace. Tabulka nemáproject_id— překlad na project name není možný bez lookup tabulky namespace → project, která neexistuje. Breakdown je tedy per-namespace, bez překladu. Marže se aplikuje read-side stejným způsobem jako vGetUsageBreakdownUseCase(přesPricingService). - Agregace nativním SQL dotazem (
SUMv DB) — bez N+1 naPricingService. Marže se aplikuje jednou read-side na celkový součet per skupinu. - Endpoint je per-user (
/users/me/), autentizovaný JWT. Žádná Liquibase migrace není potřeba — read-only feature nad existujícími tabulkami. - FE fe#10:
AiCreditPanel.vuezobrazí „Top projekty” list (top-3),HostingBillingPanel.vuenahradí hardcodedUSAGEmockup reálnými per-namespace daty. Přibývá per-period přepínač (this month / last month). Vizuální cue (červená) při budgetu < 10 % funguje již nyní přesaccentColorvAiCreditPanel—HostingBillingPaneldostane analogický mechanismus. - i18n klíče
topProjects*vbudget/i18n.tsjiž existují — nemusí se přidávat. - Related: UC-10008, UC-10012 F2.
A. Sekvence — načtení per-projekt breakdown
sequenceDiagram
actor User
User->>+FE: otevře Profile → Billing
FE->>FE: BillingSection.onMounted()
FE->>+BE: GET /api/v1/users/me/usage/project-breakdown<br/>?from=2026-05-01&to=2026-05-31&aiLimit=3
BE->>BE: ověř JWT, extrahuj userId
alt JWT chybí nebo neplatný
BE-->>FE: 401 Unauthorized<br/>ErrorResponse
end
BE->>BE: validuj parametry (from ≤ to, aiLimit 1-10)
alt parametry neplatné
BE-->>FE: 400 Bad Request<br/>ErrorResponse
end
BE->>DB: SELECT project_id, p.name, SUM(charged_cost_usd),<br/>SUM(input_tokens), SUM(output_tokens), COUNT(*)<br/>FROM usage_events e LEFT JOIN projects p ON e.project_id = p.id<br/>WHERE e.user_id = :userId AND e.kind = 'AI'<br/>AND e.occurred_at >= :from AND e.occurred_at < :to<br/>GROUP BY e.project_id, p.name<br/>ORDER BY SUM(charged_cost_usd) DESC<br/>LIMIT :aiLimit
DB-->>BE: List[AiProjectRow]
BE->>DB: SELECT namespace, SUM(cost_usd), COUNT(*)<br/>FROM hosting_cost_events<br/>WHERE user_id = :userId<br/>AND window_start >= :from AND window_start < :to<br/>GROUP BY namespace<br/>ORDER BY SUM(cost_usd) DESC
DB-->>BE: List[HostingNamespaceRow]
BE->>BE: aplikuj markup read-side na hosting součty<br/>(PricingService)
BE-->>-FE: 200 OK<br/>ProjectBreakdownResponse
FE->>FE: usageStore.applyProjectBreakdown(response)
FE->>-User: zobrazí AI top-projekty + hosting per-namespace
B. Sekvence — per-period přepínač (this month / last month)
sequenceDiagram
actor User
User->>+FE: klikne na přepínač "Last month"
FE->>FE: selectedPeriod = 'lastMonth'<br/>from = 1. den minulého měsíce<br/>to = poslední den minulého měsíce
FE->>+BE: GET /api/v1/users/me/usage/project-breakdown<br/>?from=2026-04-01&to=2026-04-30&aiLimit=3
BE->>DB: (stejné dotazy jako sekvence A, jiné datum)
DB-->>BE: výsledky za duben
BE-->>-FE: 200 OK<br/>ProjectBreakdownResponse
FE->>-User: aktualizuje panely pro minulý měsíc
API kontrakt
GET /api/v1/users/me/usage/project-breakdown
Query parametry:
| Parametr | Typ | Povinný | Default | Omezení | Popis |
|---|---|---|---|---|---|
from | ISO date YYYY-MM-DD | ano | — | ≤ to | Začátek období (inclusive, UTC) |
to | ISO date YYYY-MM-DD | ano | — | ≥ from, ≤ today+1 | Konec období (inclusive, UTC) |
aiLimit | integer | ne | 3 | 1–10 | Max počet AI projektů v response |
200 OK ProjectBreakdownResponse:
{
"period": {
"from": "2026-05-01",
"to": "2026-05-31"
},
"ai": {
"topProjects": [
{
"projectId": 12,
"projectName": "todo-list",
"totalChargedCostUsd": 0.84,
"totalInputTokens": 142000,
"totalOutputTokens": 38000,
"eventCount": 47
},
{
"projectId": 7,
"projectName": null,
"totalChargedCostUsd": 0.21,
"totalInputTokens": 31000,
"totalOutputTokens": 9000,
"eventCount": 12
}
],
"totalChargedCostUsd": 1.05,
"totalEventCount": 59
},
"hosting": {
"namespaces": [
{
"namespace": "popelkam-talkide",
"environmentId": 1,
"totalCostUsd": 0.42,
"eventCount": 18
},
{
"namespace": "h-talkide",
"environmentId": 2,
"totalCostUsd": 0.11,
"eventCount": 7
}
],
"totalCostUsd": 0.53,
"totalEventCount": 25
}
}
Pozn. k
projectName = null: projekt byl smazán, ale usage events zůstávají (append-only). FE zobrazí fallback text (např.(deleted project)).environmentIdv hosting sekci je nullable —nullpro namespace bezenvironment_idattribution (orphan namespace, ADR-026 §6).Pozn. k
ai.totalChargedCostUsdaai.totalEventCount: jde o součet všech AI usage events uživatele za dané období (přes všechny projekty), ne jen součet hodnot zobrazených vtopProjects.topProjectsje pouze top-N contributors — podmnožina. Celkové součty slouží FE pro zobrazení celkové útraty a porovnání s budgetem, nezávisle na hodnotěaiLimit.
400 Bad Request (validace parametrů) ErrorResponse:
{
"code": "VALIDATION",
"message": "Bad request"
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Datový model — čtené tabulky (jen přehled, žádná migrace)
erDiagram
USAGE_EVENTS {
bigint id
bigint user_id
bigint project_id
string kind
bigint input_tokens
bigint output_tokens
numeric charged_cost_usd
timestamp occurred_at
}
PROJECTS {
bigint id
string name
string slug
}
HOSTING_COST_EVENTS {
bigint id
bigint user_id
string namespace
bigint environment_id
numeric cost_usd
timestamp recorded_at
}
USAGE_EVENTS }o--o| PROJECTS : "LEFT JOIN project_id → id"
Žádná nová tabulka ani sloupec. Endpoint je čistě read-only nad existujícím schématem. Poslední Liquibase changeset je
0045(create-hosting-enforcement-log) — tento UC nevyžaduje0046ani vyšší.
Backend
Nativní SQL dotazy (v novém ProjectBreakdownRepository)
AI per-projekt dotaz:
SELECT
e.project_id AS projectId,
p.name AS projectName,
SUM(e.charged_cost_usd) AS totalChargedCostUsd,
SUM(e.input_tokens) AS totalInputTokens,
SUM(e.output_tokens) AS totalOutputTokens,
COUNT(*) AS eventCount
FROM usage_events e
LEFT JOIN projects p ON e.project_id = p.id
WHERE e.user_id = :userId
AND e.kind = 'AI'
AND e.occurred_at >= :from
AND e.occurred_at < :to
GROUP BY e.project_id, p.name
ORDER BY SUM(e.charged_cost_usd) DESC
LIMIT :aiLimit
Hosting per-namespace dotaz:
SELECT
h.namespace AS namespace,
h.environment_id AS environmentId,
SUM(h.cost_usd) AS totalCostUsd,
COUNT(*) AS eventCount
FROM hosting_cost_events h
WHERE h.user_id = :userId
AND h.window_start >= :from
AND h.window_start < :to
GROUP BY h.namespace, h.environment_id
ORDER BY SUM(h.cost_usd) DESC
environment_idje ve GROUP BY přesnamespace— pro daný namespace je vždy jedenenvironment_id(nebo NULL pro orphan). Pokud by z historických dat existoval namespace se dvěma různýmienvironment_id, GROUP BY produkuje více řádků; FE je oba zobrazí. V praxi je tento edge-case nepravděpodobný.
Nové Kotlin třídy
features/usage/
api/
dto/
ProjectBreakdownResponse.kt (data class, API kontrakt)
domain/
GetProjectBreakdownUseCase.kt (@Service — orchestrace, markup read-side)
data/
ProjectBreakdownRepository.kt (Spring Data / native query interface)
UsageController rozšířen o:
@GetMapping("/project-breakdown")
fun getProjectBreakdown(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate,
@RequestParam(defaultValue = "3") aiLimit: Int,
): ResponseEntity<ProjectBreakdownResponse>
Markup aplikace (hosting sekce)
Marže se aplikuje read-side na totalCostUsd každého namespace záznamu stejným způsobem
jako v GetUsageBreakdownUseCase — přes PricingService (injektovat PricingService,
nikoliv PricingMarkupConfigRepository přímo):
val markupFactor = pricingService.getEffectiveMarkup()
hostingRow.totalCostUsd = pricingService.applyMarkup(hostingRow.totalCostUsd, markupFactor)
AI sekce čte charged_cost_usd přímo z DB (marže je již aplikovaná při zápisu, immutable).
Validations
| Parametr | Constraints | Note |
|---|---|---|
from | not_null, valid ISO date | 400 VALIDATION |
to | not_null, valid ISO date, ≥ from | 400 VALIDATION |
aiLimit | 1–10 (inclusive) | 400 VALIDATION při < 1 nebo > 10; default 3 bez validační chyby |
| JWT | not_null, valid, not_expired | 401 AUTHENTICATION_FAILED |
Frontend
Nový Pinia store / composable useProjectBreakdown
Store (nebo composable) src/screens/budget/composables/useProjectBreakdown.ts:
interface AiProjectItem {
projectId: number
projectName: string | null
totalChargedCostUsd: number
totalInputTokens: number
totalOutputTokens: number
eventCount: number
}
interface HostingNamespaceItem {
namespace: string
environmentId: number | null
totalCostUsd: number
eventCount: number
}
interface ProjectBreakdown {
period: { from: string; to: string }
ai: {
topProjects: AiProjectItem[]
totalChargedCostUsd: number
totalEventCount: number
}
hosting: {
namespaces: HostingNamespaceItem[]
totalCostUsd: number
totalEventCount: number
}
}
Endpoint: GET /api/v1/users/me/usage/project-breakdown?from=&to=&aiLimit=3
Per-period přepínač
BillingSection.vue (nebo nová helper komponenta) vystaví přepínač:
| Volba | from | to |
|---|---|---|
| This month (default) | 1. den aktuálního měsíce | dnes |
| Last month | 1. den minulého měsíce | poslední den minulého měsíce |
Přepnutí triggernuje nový fetch s příslušnými daty. Přepínač je sdílený pro obě sekce (AI + hosting).
Přechod přes rok-hranici (prosinec → leden): pro „Last month” v lednu je
from = 1. 12. předchozího rokuato = 31. 12. předchozího roku. Výpočet musí přecházet rok správně — použijtedate-fns(startOfMonth+subMonths+endOfMonth), nikoli ručnímonth - 1aritmetiku, která by v lednu vrátilamonth = 0.
Rozšíření AiCreditPanel.vue
Pod progress barem přibyde sekce “Top projekty” (pod stávající BudgetWarningBanner):
[ Top projects this period ]
todo-list $0.84
(deleted project) $0.21
my-app $0.09
[ loading skeleton / empty state / error state ]
- Napojeno na
useProjectBreakdown— data načítáBillingSectiona předává prop (nebo composable je sdílené). Preferovaný přístup:BillingSectionvolá fetch a předávátopProjectsjako prop doAiCreditPanel. - i18n klíče:
topProjectsTitle,topProjectsLoading,topProjectsEmpty,topProjectsError(vše již existuje vbudget/i18n.ts). projectName = null→ zobrazit(deleted project)— nový i18n klíčtopProjectsDeleted.- Vizuální cue:
AiCreditPaneljiž máaccentColor(červená při < 10 % nebo exhausted) — tato logika se nemění.
Rozšíření HostingBillingPanel.vue
Pod progress barem přibyde per-namespace breakdown (nahrazuje mockup v BillingSection):
[ Usage by namespace ]
popelkam-talkide $0.42
h-talkide $0.11
USAGEkonstanta +TODO Stopa C.4blok vBillingSection.vue(řádky 48–63 a 136–194) se nahradí reálnými daty zuseProjectBreakdown.- Vizuální cue: hosting budget < 10 % →
accentColorčervená (analogicky jako AI panel).HostingBillingPanel(přejmenováno zHostingBillingPanelv fe#38 — postpaid framing) má zatím statickéaccentColor = 'var(--teal)'— přidá secomputedstejně jako vAiCreditPanel.
Hostingový low-budget práh
| Stav | Podmínka | Vizuální efekt |
|---|---|---|
| Normal | hostingSpentPercent < 90 | teal barva |
| Low | hostingSpentPercent >= 90 | červená (var(--rose)) |
| Exhausted (zero) | hostingAccruedPercent >= 100 (hosting je postpaid — žádný hosting credit balance) | červená |
i18n klíče (nové)
V budget/i18n.ts přidat:
| Klíč | EN | CS |
|---|---|---|
topProjectsDeleted | (deleted project) | (smazaný projekt) |
hostingNamespacesTitle | Usage by namespace | Využití dle namespace |
hostingNamespacesLoading | Loading namespace usage... | Načítám využití namespace... |
hostingNamespacesEmpty | No hosting usage yet. | Zatím žádné využití hostingu. |
hostingNamespacesError | Could not load namespace usage. | Nepodařilo se načíst využití namespace. |
periodThisMonth | This month | Tento měsíc |
periodLastMonth | Last month | Minulý měsíc |
Validations (FE)
| Parametr | Constraints | Note |
|---|---|---|
| period | přepínač enum (thisMonth / lastMonth) | výpočet from/to na FE straně |
| aiLimit | fixní hodnota 3 | nekonfiguruje uživatel |
Business pravidla
- Agregace charged, ne raw. AI sekce čte
charged_cost_usd(po marži, immutable při zápisu). Hosting sekce čtecost_usd(raw) a marži aplikuje read-side. - Smazaný projekt.
project_idbez záznamu vprojects(smazán) →projectName = null. FE zobrazí fallback. BE nefiltruje tyto záznamy ven — usage zůstává auditovatelné. - Orphan namespace.
hosting_cost_events.environment_id = null→ namespace bez environment attribution (ADR-026 §6, konzervativní non-charge). V response jeenvironmentId = null. - Prázdné období. Bez usage events za dané období →
topProjects: [],namespaces: [],totalChargedCostUsd: 0. aiLimitmax 10. Ochrana před příliš velkými response; limit není pro uživatele viditelný (FE volá s fixním3).- Žádná Liquibase migrace. Read-only endpoint — žádná změna schématu. Poslední
changeset
0045(create-hosting-enforcement-log) zůstává posledním. - Marže 0 = no-op. Markup = 0 → hosting
totalCostUsdse nezmění (multiply by 1.0). - Maximální rozsah období. Shodně s existujícím endpointem
/breakdown— max 366 dní. BE vrátí 400, pokudto - from > 366 dní. ai.totalChargedCostUsdaai.totalEventCount= součet VŠECH projektů, ne jen top-N. Tyto agregáty se počítají samostatným SQL dotazem (nebo subquery) přes celý dataset uživatele za období — bezLIMIT.topProjectsje jen seřazená podmnožina top-N contributors. Garantuje, že FE vždy vidí skutečnou celkovou útratu bez ohledu na hodnotuaiLimit.
Test Cases
Backend
| ID | GIVEN | WHEN | THEN |
|---|---|---|---|
| TC-16-BE-1 | User má 5 AI usage events pro 3 projekty (project 1: $0.84, project 2: $0.21, project 3: $0.05) ve zvoleném období | GET /project-breakdown?from=2026-05-01&to=2026-05-31&aiLimit=3 | 200 OK; ai.topProjects má 3 záznamy seřazeny DESC dle totalChargedCostUsd; ai.totalChargedCostUsd = 1.10 |
| TC-16-BE-2 | User má 0 AI usage events v daném období | GET /project-breakdown?from=2026-05-01&to=2026-05-31 | 200 OK; ai.topProjects = [], ai.totalChargedCostUsd = 0, ai.totalEventCount = 0 |
| TC-16-BE-3 | User má AI usage event s project_id = 999 (projekt neexistuje v projects tabulce — smazán) | GET /project-breakdown?from=... | 200 OK; odpovídající item má projectName = null, totalChargedCostUsd správně agregováno |
| TC-16-BE-4 | User má 2 hosting cost events pro namespace popelkam-talkide (cost $0.30 + $0.12) a markup je 20 % | GET /project-breakdown?from=... | 200 OK; hosting.namespaces[0].totalCostUsd = 0.504 (0.42 × 1.20) |
| TC-16-BE-5 | User má 0 hosting cost events v daném období | GET /project-breakdown?from=... | 200 OK; hosting.namespaces = [], hosting.totalCostUsd = 0 |
| TC-16-BE-6 | aiLimit = 2, user má 4 AI projekty | GET /project-breakdown?from=...&aiLimit=2 | 200 OK; ai.topProjects má právě 2 záznamy (top-2 dle charged cost) |
| TC-16-BE-7 | aiLimit = 11 (over max) | GET /project-breakdown?from=...&aiLimit=11 | 400 VALIDATION |
| TC-16-BE-8 | aiLimit = 0 (under min) | GET /project-breakdown?from=...&aiLimit=0 | 400 VALIDATION |
| TC-16-BE-9 | from = 2026-05-31, to = 2026-05-01 (from > to) | GET /project-breakdown?... | 400 VALIDATION |
| TC-16-BE-10 | Chybí parametr from | GET /project-breakdown?to=2026-05-31 | 400 VALIDATION |
| TC-16-BE-11 | Neplatný JWT | GET /project-breakdown?from=...&to=... | 401 AUTHENTICATION_FAILED |
| TC-16-BE-12 | User A nemá přístup k datům User B | GET s JWT user A, user B má usage data | 200 OK; response obsahuje pouze data user A (WHERE user_id = :userId) |
| TC-16-BE-13 | Hosting cost events pro namespace orphan-ns s environment_id = null | GET /project-breakdown?from=... | 200 OK; namespace item má environmentId = null; totalCostUsd správně agregováno |
| TC-16-BE-14 | Markup = 0 (žádná marže) | GET /project-breakdown?from=... | 200 OK; hosting totalCostUsd = raw SUM(cost_usd) (no-op) |
| TC-16-BE-15 | from = 2025-01-01, to = 2026-05-01 (rozsah 485 dní — přesahuje 366 dní) | GET /project-breakdown?from=2025-01-01&to=2026-05-01 | 400 VALIDATION (business pravidlo 8) |
| TC-16-BE-16 | to = today+2 (např. to = 2026-05-23 při dnešním datu 2026-05-21) | GET /project-breakdown?from=2026-05-01&to=2026-05-23 | 400 VALIDATION (to nesmí přesahovat today+1) |
Frontend
| ID | GIVEN | WHEN | THEN |
|---|---|---|---|
| TC-16-FE-1 | BE vrátí 2 AI projekty (project 1: $0.84, project 2: $0.21) | AiCreditPanel se vyrendruje | Zobrazeny 2 řádky v “Top projects” sekci; todo-list $0.84, druhý (deleted project) $0.21 pokud je projectName = null |
| TC-16-FE-2 | BE vrátí prázdné ai.topProjects = [] | AiCreditPanel se vyrendruje | Zobrazí se empty state text topProjectsEmpty |
| TC-16-FE-3 | Fetch selže (BE vrátí 500) | AiCreditPanel po chybě | Zobrazí se error state text topProjectsError; zbytek panelu (balance, progress bar) funguje normálně |
| TC-16-FE-4 | BE vrátí 2 hosting namespace záznamy | HostingBillingPanel se vyrendruje | Mockup USAGE konstanta není viditelná; zobrazeny reálné namespace řádky s totalCostUsd |
| TC-16-FE-5 | hostingSpentPercent >= 90 | HostingBillingPanel se vyrendruje | accentColor = červená (var(--rose)); progress bar a velké číslo jsou červené |
| TC-16-FE-6 | User klikne “Last month” | BillingSection period switcher | Nový fetch s from/to pro minulý měsíc; oba panely se aktualizují |
| TC-16-FE-7 | User klikne “This month” po “Last month” | Period switcher | Fetch s aktuálním měsícem; data se obnoví |
| TC-16-FE-8 | Fetching (loading) stav | Panel při prvním načtení | Zobrazí se loading skeleton pro “Top projects” sekci; neukazuje prázdný stav předčasně |
UX guidelines
- Top projekty v AI panelu: seznam max 3 řádků pod progress barem a
BudgetWarningBanner. Každý řádek:projectName(nebo fallback(deleted project)) vlevo,$X.XXvpravo (font-mono). Stejná vizuální tíha jako stávající billing řádky. - Hosting per-namespace: nahrazuje celý
estimatedblok vBillingSection(project hours bars, storage bars, bandwidth bars). Nový blok: nadpisUsage by namespace, seznam namespace řádků (namespace vlevo,$X.XXvpravo). Velké číslo$USAGE.estimatedse nahradí součtemhosting.totalCostUsdz real dat. - Period switcher: diskrétní přepínač (pill tabs)
This month | Last monthumístěný nad oběma credit panely nebo ve hlavičce hosting boxu. Nezabírá zbytečný prostor — může být malý, font-mono, muted barva. - Červené skóre < 10 %: jak AI, tak hosting panel — velké číslo (remaining balance) a progress bar zčervenají. Threshold = 10 % zbývajícího kreditu z počátečního.
- Loading state: skeleton animace pro seznam projektů / namespaců (2-3 řádky obdélníků),
nikoliv spinner. Konzistentní se stávajícím skeletonem v
AiCreditPanel(řádky 54–58).
FEEDBACK
Chybělo mi přesné schema projects tabulky (konkrétně sloupce name vs slug a nullable
stav) — rozhodl jsem se na základě kontextu z jiných UC souborů, kde se název projektu
konzistentně mapuje na project.name. Dále by se hodilo vědět, zda BillingSection.vue
má přijímat breakdown data jako prop od rodiče nebo si je fetching composable spravuje
interně — zvolil jsem prop přístup jako konzervativnější (rodiče orchestruje fetching),
ale toto je design-level rozhodnutí, které by měl potvrdit FE developer. Nakonec chyběl
přesný tvar existujícího UsageBreakdownResponse DTO pro ověření konzistence pojmenování
s novou ProjectBreakdownResponse.
Opravy z implementačního review (2026-05-21):
- Hosting filtrace opravena z
recorded_atnawindow_start(sémanticky správný sloupec — kdy náklad nastal, ne kdy byl zapsán do DB; konzistentní s existujícím/usage/breakdown). ai.totalChargedCostUsdaai.totalEventCountupřesněny jako součet VŠECH projektů uživatele za období (bezLIMIT), nikoliv součet top-N zobrazených vtopProjects.
Thanks for the feedback.