feat(ts): type the shared Category API response (A4)

Adds Category (+ CategoryBillSummary) to @/types and wires categories/
createCategory/updateCategory. Category has no money field of its own (budgets
live on the spending endpoints); the nested per-bill total_paid is a raw SQL sum,
so it's deliberately left unbranded. With categories now typed, useSpending
Categories drops its `any` cast and dead d.categories branch.

Category, Bill, Payment, and the Tracker envelope are the cross-cutting types
used by many components — worth defining upfront. The remaining single-consumer
page responses (summary, analytics, spending, snowball, subscriptions, bank
ledger) are typed alongside their page conversions in phase B, where the exact
fields consumed are visible (more accurate than a speculative interface).

typecheck 0, build green, 48 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 22:27:08 -05:00
parent 337ad95a3e
commit 4af738f947
3 changed files with 39 additions and 6 deletions

View File

@ -1,4 +1,4 @@
import type { Bill, Payment, TrackerResponse } from '@/types';
import type { Bill, Category, Payment, TrackerResponse } from '@/types';
// Fetch CSRF token from the server once and cache in memory.
// The cookie is httpOnly so document.cookie cannot access it directly.
@ -332,10 +332,10 @@ export const api = {
abandonSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/abandon`, {}),
// Categories
categories: () => get('/categories'),
createCategory: (data: Body) => post('/categories', data),
categories: () => get<Category[]>('/categories'),
createCategory: (data: Body) => post<Category>('/categories', data),
reorderCategories: (order: Body) => put('/categories/reorder', order),
updateCategory: (id: Id, data: Body) => put(`/categories/${id}`, data),
updateCategory: (id: Id, data: Body) => put<Category>(`/categories/${id}`, data),
toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
deleteCategory: (id: Id) => del(`/categories/${id}`),
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),

View File

@ -184,8 +184,8 @@ export function useSpendingCategories() {
return useQuery({
queryKey: ['spending-categories'],
queryFn: async () => {
const d = await api.categories() as any;
return (d.categories || d || []).filter((c: any) => !c.deleted_at && c.spending_enabled);
const cats = await api.categories();
return cats.filter(c => !c.deleted_at && c.spending_enabled);
},
staleTime: 1000 * 60 * 5,
});

View File

@ -30,6 +30,39 @@ export interface Payment {
[key: string]: unknown;
}
// A category's per-bill rollup (categories list endpoint). `total_paid` here is a
// raw SQL sum (not run through fromCents), so it is deliberately NOT branded.
export interface CategoryBillSummary {
id: number;
name: string;
active: boolean;
payment_count: number;
total_paid: number;
last_paid_date: string | null;
[key: string]: unknown;
}
// A spending/bill category. Has no money field of its own (budgets live on the
// spending endpoints). The list endpoint adds bill rollups; create/update return
// the bare row — so the rollup fields are optional.
export interface Category {
id: number;
user_id?: number;
name: string;
sort_order?: number;
spending_enabled: boolean;
group_id: number | null;
created_at?: string;
updated_at?: string;
bill_count?: number;
active_bill_count?: number;
inactive_bill_count?: number;
payment_count?: number;
bill_names?: string[];
bills?: CategoryBillSummary[];
[key: string]: unknown;
}
// A bill as serialized by the server (billsService.serializeBill spreads the DB
// row and converts the three money columns to dollars). SQLite booleans come back
// as 0/1 integers. Extra joined columns (sparkline, has_merchant_rule, …) pass