Compare commits

...

45 Commits

Author SHA1 Message Date
Carlos Escalante
3d0547743f Log client details for API and agent requests
All checks were successful
Deploy to VPS / test (push) Successful in 1m42s
Deploy to VPS / deploy (push) Successful in 14s
2026-07-14 22:55:50 -06:00
Carlos Escalante
8ceed0ab2e Add individual salary deposit agent tool
All checks were successful
Deploy to VPS / test (push) Successful in 1m43s
Deploy to VPS / deploy (push) Successful in 27s
2026-07-14 22:41:53 -06:00
Carlos Escalante
ca3115e99c Add retained chat and API request diagnostics 2026-07-14 22:34:33 -06:00
Carlos Escalante
3518ff58c4 Read assistant model from deployment secret
All checks were successful
Deploy to VPS / test (push) Successful in 1m49s
Deploy to VPS / deploy (push) Successful in 8s
2026-07-14 22:03:53 -06:00
Carlos Escalante
352b77ea07 Regenerate API types for MAF upgrade
All checks were successful
Deploy to VPS / test (push) Successful in 1m44s
Deploy to VPS / deploy (push) Successful in 2m29s
2026-07-14 21:52:50 -06:00
Carlos Escalante
fbc0816be6 Add Playwright assistant regression coverage
Some checks failed
Deploy to VPS / test (push) Failing after 1m37s
Deploy to VPS / deploy (push) Has been skipped
2026-07-14 21:48:59 -06:00
Carlos Escalante
301a0b687a Use Responses API with Luna by default 2026-07-14 21:48:54 -06:00
Carlos Escalante
b57a899634 Upgrade agent and CopilotKit dependencies 2026-07-14 21:48:40 -06:00
Carlos Escalante
2d6a3f83eb Rollback assistant model to GPT-5.4 mini
All checks were successful
Deploy to VPS / test (push) Successful in 1m45s
Deploy to VPS / deploy (push) Successful in 15s
2026-07-14 19:03:27 -06:00
Carlos Escalante
00e841ad3f Use unique backend alias for assistant
All checks were successful
Deploy to VPS / test (push) Successful in 1m45s
Deploy to VPS / deploy (push) Successful in 15s
2026-07-14 18:50:28 -06:00
Carlos Escalante
c527b3d6d0 Fix production assistant backend DNS
All checks were successful
Deploy to VPS / test (push) Successful in 1m43s
Deploy to VPS / deploy (push) Successful in 8s
2026-07-14 18:46:04 -06:00
Carlos Escalante
1e6ebab2b2 Upgrade assistant model to GPT-5.6 Luna
All checks were successful
Deploy to VPS / test (push) Successful in 1m44s
Deploy to VPS / deploy (push) Successful in 16s
2026-07-14 18:42:06 -06:00
Carlos Escalante
17a0c63abe Add manual salary entry
All checks were successful
Deploy to VPS / test (push) Successful in 1m50s
Deploy to VPS / deploy (push) Successful in 1m37s
2026-07-14 18:25:13 -06:00
Carlos Escalante
47cb1826a8 Assistant: migrate to v2 runtime handler with Postgres-backed AgentRunner
All checks were successful
Deploy to VPS / test (push) Successful in 1m39s
Deploy to VPS / deploy (push) Successful in 22s
Replace the deprecated copilotRuntimeNextJSAppRouterEndpoint wrapper
(which was already the v2 single-route handler underneath) with
createCopilotRuntimeHandler from @copilotkit/runtime/v2, built once at
module scope, and plug in a custom PostgresAgentRunner:

- run() proxies the agent's event stream untouched — MAF persists every
  turn in chatthreadsnapshot, so the BFF records nothing
- connect() forwards to MAF's snapshot hydration (empty-messages run →
  RUN_STARTED → MESSAGES_SNAPSHOT → RUN_FINISHED, no LLM call), making
  the DB the only conversation store — correct across clears, restarts
  and future instances
- stop() aborts via a process-local live-run map (same pattern as the
  official sqlite-runner); isRunning() has no caller in the v2 SSE path

This deletes the whole workaround layer the in-memory runner forced:
the connect intercepts (URL + envelope forms) and Asistente's
client-side hydration run. The ask-box flow now awaits connectAgent
instead. Auth moves to a single Request rewrite (ws_token cookie →
Authorization Bearer) before the handler — the v2 path forwards
authorization + x-* headers to both run clones and runner.connect.
Stays in single-route mode: same envelope protocol the client already
auto-detected, no exposure to CopilotKit#4953, /threads* REST not
needed. Telemetry disabled via COPILOTKIT_TELEMETRY_DISABLED.

Verified full matrix in dev: fresh ask, reload replay via runner,
3 cleared convos + double navigation (only last survives), card renders
once and survives follow-up + reload, Inicio ask-box with history,
stale-tab persists only its new turn. 126 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:09:24 -06:00
Carlos Escalante
8595e74566 Assistant: document why the connect intercept is load-bearing
All checks were successful
Deploy to VPS / test (push) Successful in 1m35s
Deploy to VPS / deploy (push) Successful in 20s
Investigated whether CopilotKit offers an official thread flush instead
of the BFF connect intercept. It does have one (POST /threads/clear ->
runner.clearThreads()), but only on the fetch-router entrypoint — the
single-route adapter we mount (copilotRuntimeNextJSAppRouterEndpoint)
accepts exactly agent/run|connect|stop, info, transcribe at 1.62, so
threads/* is unreachable from our deployment (verified: 400 Unsupported
method). A custom DB-backed AgentRunner is the clean end-state, but the
AgentRunner base class isn't exported at 1.62. Note left in clearThread;
revisit both on the next CopilotKit upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:28:45 -06:00
Carlos Escalante
0d0d60600d Assistant: intercept connect on the single-route transport too
All checks were successful
Deploy to VPS / test (push) Successful in 1m35s
Deploy to VPS / deploy (push) Successful in 22s
The connect intercept only matched POST …/connect, but CopilotKit talks
to the runtime in single-route mode: everything POSTs to
/api/copilotkit with the operation in the body envelope
({method:"agent/connect"}). So the in-memory runner kept replaying the
BFF process's full event history on every CopilotChat mount — every
conversation since server start, across clears — which is exactly the
stacked resurrected conversations seen in prod after navigating away
and back. Now both transports get the immediately-completed stream.

Verified with the reported repro: three conversations with clears
between, navigate away and back → only the third remains; reload
hydration unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:23:05 -06:00
Carlos Escalante
700e1edbb4 Assistant: stop stale clients from resurrecting cleared threads
All checks were successful
Deploy to VPS / test (push) Successful in 1m37s
Deploy to VPS / deploy (push) Successful in 28s
Root cause of the "chunks" clear bug: conversation history is
server-owned, but MAF seeds a thread from whatever the client sends
when no snapshot exists. Any stale tab (open across a clear or from an
older session) still holds the old conversation in memory, and its next
question re-persisted the whole thing — clears appeared to peel off one
layer at a time.

The agent middleware now drops client-sent history for threads with no
stored snapshot, keeping only the last user turn (fresh conversations
and empty-messages hydration requests pass through untouched). Verified
live: simulated a cleared-thread stale tab, asked from it, and only the
new turn was persisted.

Also: clearThread surfaces DELETE failures as a toast instead of
silently reloading, and conftest sets a dummy OPENAI_API_KEY so tests
can import app.main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:06:35 -06:00
Carlos Escalante
15fa18411a Assistant: block "Nueva conversación" while a run is streaming
All checks were successful
Deploy to VPS / test (push) Successful in 1m28s
Deploy to VPS / deploy (push) Successful in 21s
MAF persists the thread at run END, so clearing mid-stream deleted the
row only for the finishing run to write the conversation right back —
the thread reappeared after the reload ("chunks" needing a second
clear). The button is now disabled while agent.isRunning.

(The chunked-clear reports on the prior build had a second cause fixed
in 0f34a64: clearing left the client's message copy alive and the next
question re-persisted it.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:53:15 -06:00
Carlos Escalante
3f4df6f16b Agent: answer like an analyst, not a calculator
All checks were successful
Deploy to VPS / test (push) Successful in 1m34s
Deploy to VPS / deploy (push) Successful in 14s
Aggregate answers were bare numbers ("Tu saldo neto hoy es -X."). The
prompt now requires showing the composition unprompted: net worth gets
the assets/liabilities split plus per-account table, day spend gets the
purchases behind the total, cycle/category totals get their top
components — one headline sentence, a compact table, and a closing
observation only when the data supports one. Also bans filler offers
("si quieres te puedo mostrar…") in place of just showing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:42:50 -06:00
Carlos Escalante
0f34a64e51 Assistant: reliable "Nueva conversación" reset
All checks were successful
Deploy to VPS / test (push) Successful in 1m33s
Deploy to VPS / deploy (push) Successful in 29s
Clearing in place (setMessages + remount) left the conversation
rendered — CopilotKit keeps chat state beyond agent.messages — so the
button now reloads the page after deleting the thread: a fresh boot
hydrates from the empty store and shows the welcome screen with
suggestions, which previously only appeared after a manual refresh.

Supporting changes:
- CopilotChat gets an explicit agentId="wealthysmart" (without it, it
  resolves DEFAULT_AGENT_ID and can bind a different client agent
  instance than useAgent's).
- The BFF answers CopilotChat's POST …/connect with an
  immediately-completed stream: the runtime's in-memory runner replays
  this PROCESS's event history, which resurrects cleared conversations
  and double-feeds hydration. The empty-messages hydration run against
  the DB store is the single restore path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:33:41 -06:00
Carlos Escalante
7b7c741ba2 Agent: resolve "today" in the browser's timezone, not hardcoded CR
The CR-hardcoded today broke two scenarios: the owner traveling, and
any future non-CR user. Now the provider sends the browser's IANA
timezone (X-Client-Timezone) with every CopilotKit request — the
runtime forwards x-* headers to the agent on its own — and the backend
binds it to a per-request ContextVar next to the DB session.
get_current_date and the future-cuota bounds use it; the tool also
reports the timezone so the model can echo it. Unknown or absent zones
(n8n posts, tests) fall back to Costa Rica; comma-joined duplicate
header values are tolerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:33:27 -06:00
Carlos Escalante
8f51e33fe0 Assistant: drop mid-run MESSAGES_SNAPSHOT instead of reconciling it
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 20s
Prod bug: earlier assistant replies vanished as new turns completed and
only came back on refresh. Debug capture showed why: with persistence
on, the run-end snapshot rebuilds history under STORE ids while the
client holds it under STREAMED ids (MAF re-mints post-tool-call text
ids, their #3619). ag-ui apply treats the snapshot as authoritative —
matching ids get replaced (streamed text wiped by the store's
empty-content toolCall entry) and the store's text copies were being
filtered out by ReconcileSnapshotMiddleware as presumed duplicates, so
prior answers had no surviving representation.

Reconciling id-mismatched snapshots is unwinnable; skip them instead.
During a normal run the client already built the turn from streamed
events and the snapshot adds nothing. Hydration replays (empty
input.messages) still pass through — there the snapshot IS the
conversation, and every reload converges the client to store ids.
ReconcileSnapshotMiddleware is replaced by the much simpler
DropRunMessagesSnapshotMiddleware.

Verified in dev: multi-turn history persists across runs, one card that
survives follow-ups, reload replays everything, no duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:56:37 -06:00
Carlos Escalante
965e30a2d2 Agent: add get_daily_spending for day-scoped questions
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 14s
Prod showed the failure mode after the date fix: get_current_date
returned the right day, but the model answered "¿cuánto he gastado
hoy?" from cycle-level aggregates (the only spend tools it had) and
concluded ₡0. Give it a day-grained tool — per-day CRC totals mirroring
/analytics/daily-spending (COMPRA only, no installment anchors, no
future cuotas) — and a prompt rule to match tool to time grain.
Verified: fresh thread now calls get_current_date →
get_daily_spending(2026-07-04, 2026-07-05) and answers correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:36:48 -06:00
Carlos Escalante
334d41bd9a Agent: fix date handling — Costa Rica timezone and future cuotas
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 14s
Two bugs from prod (server clock is UTC, user is UTC-6):

- get_recent_transactions returned future-dated Tasa Cero cuotas; it now
  excludes anything dated after the user's today, same rule as
  /transactions/recent.
- "¿Cuánto he gastado hoy?" answered for the wrong day: the prompt baked
  date.today() in at boot — frozen from startup AND in server-UTC (8 pm
  in Costa Rica is already tomorrow in UTC). The prompt no longer carries
  a date; a new get_current_date tool returns today in CR time plus the
  active billing cycle, and the prompt directs the model to call it for
  any relative date reference. today_cr() lives in timeutil (CR has no
  DST, fixed UTC-6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:27:46 -06:00
Carlos Escalante
eb400ab1e9 Assistant: suppress repeated render-tool calls
All checks were successful
Deploy to VPS / test (push) Successful in 1m31s
Deploy to VPS / deploy (push) Successful in 2m1s
After the client returns a render tool's result, the model sometimes
re-issues the identical call with a fresh id on the follow-up run,
painting a second copy of the spending-summary card (observed live on
MAF 1.10 / CK 1.62). DeduplicateToolCallMiddleware now tracks which
render tools already ran in the current user turn and drops repeats —
their streamed TOOL_CALL_* events and their entries in the run's
MESSAGES_SNAPSHOT. The system prompt also tells the model the card is
already displayed once its "ok" result is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:10:51 -06:00
Carlos Escalante
bbcfaa7808 Assistant: hydrate the persisted thread in the UI
- Asistente.tsx pins threadId "main" on CopilotChat (an explicit stable
  id stops the per-mount message wipe), runs a hydration pass on page
  load — an empty-messages run makes MAF replay the stored snapshot as
  MESSAGES_SNAPSHOT with no LLM call — and sends the Inicio ask-box
  question only after hydration so every normal run carries the full
  client-known history. "Nueva conversación" clears the stored thread
  behind a ConfirmDialog.
- server.ts: the BFF no longer injects context into empty-messages
  requests (that would defeat MAF's hydration trigger), and the snapshot
  reconciliation passes hydration replays through untouched and treats
  ids the client already sent as history rather than duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:10:35 -06:00
Carlos Escalante
6bce5539f7 Assistant: persist the chat thread in Postgres
Back MAF's opt-in AG-UI snapshot persistence with a DB store so the
conversation survives page reloads and restarts:

- ChatThreadSnapshot: one upserted row per (scope, thread_id), JSONB
  messages/state/interrupt (migration 794baf50635b)
- PostgresSnapshotStore implements the AGUIThreadSnapshotStore protocol.
  It opens its own sessions (MAF saves during SSE streaming, after the
  request middleware has closed the ContextVar session) and sanitizes on
  save: camelCase toolCalls/toolCallId (the @ag-ui/client Zod schema
  strips snake_case, which would lose cards on replay), drop BFF ctx-*
  context messages, dedupe MAF's re-recorded multi-run turns, and drop
  orphaned tool results
- Endpoint mount gains snapshot_store + a constant scope resolver
  (single-user app; auth already enforced by agent_auth_and_session)
- DELETE /api/v1/chat/thread resets the conversation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:09:55 -06:00
Carlos Escalante
0e03284c95 Assistant: restore ReconcileSnapshotMiddleware
Retiring it in the middleware audit was premature: MAF 1.10 reuses the
streamed id for plain-text snapshots but deliberately mints a fresh UUID
for post-tool-call assistant text (their #3619), so tool+text turns
still rendered a duplicate assistant bubble (re-verified live). The
user-message duplication that motivated the retirement only fired on
a2ui retry runs, which no longer exist now that a2ui is disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:10:12 -06:00
Carlos Escalante
4687fa9669 Assistant: drop a2ui surfaces, fall back to markdown tables
CK 1.60+ validates generated a2ui ops against a component catalog before
painting, and our agent's ops fail validation — surfaces hang at
"Building interface" forever (MAF also drops RunAgentInput.context, so
the catalog/guidelines never even reach the model). Disable a2ui end to
end until the pipeline matures:

- server.ts: a2ui { enabled: false }; RENDER_TOOLS no longer includes
  render_a2ui
- App.tsx: remove the a2ui provider prop
- agent.py: prompt now asks for clean markdown tables for lists and
  structured data; render_spending_summary card guidance unchanged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:09:56 -06:00
Carlos Escalante
bbae121fec Middleware audit for CK 1.62 / MAF 1.10: retire ReconcileSnapshot, inject dropped AG-UI context
- ReconcileSnapshotMiddleware removed: MAF now reuses the streamed
  message id in MESSAGES_SNAPSHOT (verified in _agent_run.py at
  python-1.10.0 and live event logs); keeping the surgery duplicated
  user messages on a2ui follow-up runs.
- beforeRequestMiddleware now injects RunAgentInput.context as a system
  message: MAF declares the field but never feeds it to the model,
  which starves the model of CopilotKit's A2UI catalog/guidelines.
  Remove when MAF consumes context upstream.
- Workaround chain env-gated: CK_MIDDLEWARES=off runs bare for audits,
  CK_DEBUG_EVENTS=1 enables the event logger.
- Kept: SuppressRenderToolText, StripModelArtifacts,
  DeduplicateToolCall, pairOrphanToolCalls (behavior-verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:55:29 -06:00
Carlos Escalante
033a920746 Upgrade CopilotKit 1.56.4 -> 1.62.2, @ag-ui/client 0.0.52 -> 0.0.57
No API drift: v2 hooks, v1 useCopilotAction interop, runtime endpoint
and a2ui config all verified against the v1.62.2 source. Brings the
1.60.2 chat scroll stability fixes and 1.57.2 thread-state fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:30:54 -06:00
Carlos Escalante
0d24c7f9be Upgrade MAF 1.2.1 -> 1.10.0 (ag-ui rc7), pin instrumentation off
Agent/OpenAIChatCompletionClient/add_agent_framework_fastapi_endpoint
APIs verified unchanged against the python-1.10.0 source; 115 tests
green, pip check clean. MAF 1.6.0 turns OTel on by default —
ENABLE_INSTRUMENTATION=false in both compose files keeps prod logs
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:24:54 -06:00
Carlos Escalante
00f059ee91 Fix chat scroll: cap the shell at the viewport on /asistente
The shell revamp left SidebarInset unbounded (wrapper is min-h-svh), so
Asistente's flex chain resolved to content height — the chat's internal
scroller never engaged and long answers hid behind the composer.
Asistente now gets h-svh + overflow-hidden on the inset; every other
page keeps body scroll. Verified: inset == viewport on /asistente,
body scroll intact elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:16:05 -06:00
Carlos Escalante
254b4c751e Swap plugin-react-swc for plugin-react-oxc
All checks were successful
Deploy to VPS / test (push) Successful in 1m30s
Deploy to VPS / deploy (push) Successful in 1m32s
Unifies the transform pipeline on Oxc now that Rolldown does the
bundling. Dev server boot 353ms -> 188ms; build parity (2.03s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:52:45 -06:00
Carlos Escalante
f2cacedc20 Swap Rollup for Rolldown (rolldown-vite) in the production build
The last JS-based stage of the toolchain (dev was already SWC+esbuild).
Local build: 18.75s/1.83GB peak RSS -> 1.97s/1.27GB, output verified
identical in the browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:48:15 -06:00
Carlos Escalante
a9adb410d0 Raise frontend build heap cap to 2048MB
All checks were successful
Deploy to VPS / test (push) Successful in 1m27s
Deploy to VPS / deploy (push) Successful in 1m49s
The UI revamp pushed rollup's peak past the 1536MB cap and the prod
image build OOM'd. Verified locally: fails at 1536, builds at 2048.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:42:38 -06:00
Carlos Escalante
37c2b18300 Dev: derive AGENT_URL from BACKEND_URL so the assistant works locally
Some checks failed
Deploy to VPS / test (push) Successful in 1m31s
Deploy to VPS / deploy (push) Failing after 1m54s
The default pointed at the compose-internal 'backend' hostname, which
never resolves when the Hono runtime runs on the host — every local
agent run died with fetch failed/ENOTFOUND before reaching FastAPI.
Prod is unaffected (sets both URLs explicitly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:16:25 -06:00
Carlos Escalante
ce7073cf24 Analytics: date-range filter + fix category endpoint Decimal crash
by-category and daily-spending accept start_date/end_date (range wins
over cycle); Analytics gets a two-month calendar range picker beside
the cycle selector. Fixes a latent float/Decimal TypeError that 500'd
spending_by_category whenever it had data — the category donut had
never rendered. Regression + range tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:06:58 -06:00
Carlos Escalante
5033348320 Inicio: hover-cards on stat tiles
Gasto del ciclo reveals the by-source breakdown, Próximas cuotas the
full active-plan list, Pensión last-period rendimientos per fund.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:00:59 -06:00
Carlos Escalante
061a49f3b1 Command palette (cmd+K): page nav, quick actions, ask-the-assistant
Header search trigger + global hotkey. Typing a question surfaces a
'Preguntar' item that lands in Asistente via the same auto-send flow
as the Inicio ask box. Also installs hover-card and calendar for the
remaining Phase 3 work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:58:33 -06:00
Carlos Escalante
3a258537fc P2 tail: cuota progress bars, Sync item rows, dialog polish
All checks were successful
Deploy to VPS / test (push) Successful in 1m29s
Deploy to VPS / deploy (push) Successful in 2m19s
Financiamientos shows cuota progress as Progress bars; Sincronización
uses a single Card of Item rows; Items Recurrentes table wrapped in a
Card like every other table; both window.confirm sites replaced with
the app's ConfirmDialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:22:15 -06:00
Carlos Escalante
41d4306e86 Standardize page headers and chart tokens across all pages
PageHeader on Budget, Proyecciones, Pensiones, Planificador,
Municipalidad, Sincronización, Financiamientos, Asistente. Pensiones
fund identity and Municipalidad meter/charge series now use the
validated chart tokens (color-mix for translucent fills). Planificador
stat cards on StatTile; deterministic chart rendering everywhere.
Municipalidad page title now matches its nav/breadcrumb label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:53:30 -06:00
Carlos Escalante
9907ff265e Budget donuts: sequential ramps + categorical slots, Paleta toggle retired
lib/charts.ts centralizes the color system: fixed categorical slots
(never cycled, >5 folds into Otros, 'Sin categoría' reads neutral) and
color-mix ramps that stay correct in dark mode. Animations off so
charts render deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:47:20 -06:00
Carlos Escalante
2cf19f1880 Analytics: validated palette, bars for daily spend, Empty states, es-CR
Categories fold into 'Otros' past 5 slots (fixed hue order, never
cycled); daily spending excludes future-dated Tasa Cero cuotas; rate
chart gets a legend; PageHeader with the cycle selector as action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:43:57 -06:00
Carlos Escalante
a95486481b UI foundations: PageHeader, StatTile, validated chart palette, registry primitives
- PageHeader + StatTile standardize the four header and three stat-tile
  variants found in the audit; Inicio and Salarios migrated as proof.
- chart tokens replaced with a teal-anchored categorical scale, CVD-
  ordered and validated (six checks, light + dark surfaces).
- Registry adds: empty, field, item, spinner, progress, toggle-group,
  input-group, popover, kbd (+ toggle dep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:23:21 -06:00
78 changed files with 6943 additions and 2434 deletions

View File

@@ -23,4 +23,4 @@ VAPID_PUBLIC_KEY=
# ── AI agent (optional in dev; Asistente won't work without it) ────────────── # ── AI agent (optional in dev; Asistente won't work without it) ──────────────
OPENAI_API_KEY= OPENAI_API_KEY=
AGENT_MODEL=gpt-5.4-mini AGENT_MODEL=gpt-5.6-luna

5
.gitignore vendored
View File

@@ -16,3 +16,8 @@ tech_docs/
.claude/ .claude/
.venv/ .venv/
frontend/openapi.json frontend/openapi.json
# playwright (demo artifacts are generated, not committed)
frontend/test-results/
frontend/playwright-report/
frontend/e2e-docs/

View File

@@ -0,0 +1,35 @@
"""track agui run id in chat logs
Revision ID: 10f4a4f71964
Revises: ecb99a158fbf
Create Date: 2026-07-14 22:32:16.771655
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '10f4a4f71964'
down_revision: Union[str, Sequence[str], None] = 'ecb99a158fbf'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('chatrunlog', sa.Column('agui_run_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.create_index(op.f('ix_chatrunlog_agui_run_id'), 'chatrunlog', ['agui_run_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_chatrunlog_agui_run_id'), table_name='chatrunlog')
op.drop_column('chatrunlog', 'agui_run_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,45 @@
"""chat thread snapshot
Revision ID: 794baf50635b
Revises: 961802a2f50d
Create Date: 2026-07-04 17:49:14.914103
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '794baf50635b'
down_revision: Union[str, Sequence[str], None] = '961802a2f50d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chatthreadsnapshot',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('scope', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('messages', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
sa.Column('state', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('interrupt', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope', 'thread_id')
)
op.create_index(op.f('ix_chatthreadsnapshot_scope'), 'chatthreadsnapshot', ['scope'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_chatthreadsnapshot_scope'), table_name='chatthreadsnapshot')
op.drop_table('chatthreadsnapshot')
# ### end Alembic commands ###

View File

@@ -0,0 +1,37 @@
"""record chat client details
Revision ID: 86104b4ff6ae
Revises: 10f4a4f71964
Create Date: 2026-07-14 22:54:55.519380
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '86104b4ff6ae'
down_revision: Union[str, Sequence[str], None] = '10f4a4f71964'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('chatrunlog', sa.Column('client_ip', sa.Text(), nullable=True))
op.add_column('chatrunlog', sa.Column('user_agent', sa.Text(), nullable=True))
op.add_column('chatrunlog', sa.Column('browser', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('chatrunlog', 'browser')
op.drop_column('chatrunlog', 'user_agent')
op.drop_column('chatrunlog', 'client_ip')
# ### end Alembic commands ###

View File

@@ -0,0 +1,58 @@
"""add chat run audit logs
Revision ID: ecb99a158fbf
Revises: 794baf50635b
Create Date: 2026-07-14 22:22:06.035643
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'ecb99a158fbf'
down_revision: Union[str, Sequence[str], None] = '794baf50635b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chatrunlog',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('request_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('requested_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('response_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_prompt', sa.Text(), nullable=True),
sa.Column('tool_calls', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
sa.Column('assistant_response', sa.Text(), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chatrunlog_expires_at'), 'chatrunlog', ['expires_at'], unique=False)
op.create_index(op.f('ix_chatrunlog_request_id'), 'chatrunlog', ['request_id'], unique=True)
op.create_index(op.f('ix_chatrunlog_requested_thread_id'), 'chatrunlog', ['requested_thread_id'], unique=False)
op.create_index(op.f('ix_chatrunlog_response_thread_id'), 'chatrunlog', ['response_thread_id'], unique=False)
op.create_index(op.f('ix_chatrunlog_status'), 'chatrunlog', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_chatrunlog_status'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_response_thread_id'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_requested_thread_id'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_request_id'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_expires_at'), table_name='chatrunlog')
op.drop_table('chatrunlog')
# ### end Alembic commands ###

View File

@@ -2,10 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from agent_framework import Agent from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.openai import OpenAIChatClient
from app.config import settings from app.config import settings
from app.agent.tools import TOOLS from app.agent.tools import TOOLS
@@ -22,19 +20,46 @@ Context you can rely on:
following month. When the user says "this month" or "last month" without following month. When the user says "this month" or "last month" without
qualifiers, assume they mean the calendar month unless they mention qualifiers, assume they mean the calendar month unless they mention
"cycle", "corte", or their credit card. "cycle", "corte", or their credit card.
- Today's date is {today}. Use it when the user says "this month", "last - You do NOT have a reliable clock, and the server runs in UTC while the
month", "last year", etc. user may be anywhere (their browser reports their timezone per request).
Whenever the question involves a relative date — "hoy", "ayer", "este
mes", "este ciclo", "el año pasado" — call get_current_date FIRST and
derive ranges from its answer.
- Amounts are stored as raw numbers in their native currency (see `currency` - Amounts are stored as raw numbers in their native currency (see `currency`
field on transactions/accounts). Tools that return `total_crc` are already field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT. converted; tools that return per-transaction amounts are NOT.
How to answer: How to answer:
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants. - ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
- Match the tool to the question's time grain: day-scoped questions ("hoy",
"ayer", "el martes", a specific date) need get_daily_spending with that
day's bounds — cycle or category summaries have NO per-day data, so never
answer a day question from them.
- For a question about a specific salary deposit or its amount (including
"cuánto recibí hoy"), call get_salary_deposits with the relevant date range;
get_salary_summary is aggregate-only and cannot answer it.
- Answer like an analyst, not a calculator. An aggregate number on its own
is not an answer — show what it is made of, right away, without waiting
to be asked:
· saldo neto / net worth → headline, then the assets-vs-liabilities
split and the accounts behind each side (get_accounts + get_net_worth).
· "cuánto gasté hoy/ayer/el martes" → the total, then the purchases
behind it (get_recent_transactions with the same date bounds:
merchant, amount, source).
· cycle or category totals → the top components and how much each
contributes.
- Structure: one headline sentence with the number, a compact markdown
table with the breakdown, and — only when the data genuinely supports
it — one closing observation (an unusually large item, a comparison to
the previous day/cycle). Never pad with filler or generic offers like
"si quieres te puedo mostrar…" when you could just show it.
- Call multiple tools in parallel when the question spans domains - Call multiple tools in parallel when the question spans domains
(e.g. net worth + recent transactions). (e.g. net worth + recent transactions).
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for - Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
USD (two decimals), € for EUR (two decimals). USD (two decimals), € for EUR (two decimals).
- When showing lists, prefer markdown tables over prose. - When showing lists or structured data (transactions, breakdowns, funds),
format them as clean markdown tables: right-align amounts, include a total
row when summing, keep merchant names as-is.
- If a tool returns no data, say so explicitly — do not fill in zeros. - If a tool returns no data, say so explicitly — do not fill in zeros.
- You are read-only in this version. If asked to create, edit or delete - You are read-only in this version. If asked to create, edit or delete
anything, explain that write actions aren't available yet and offer to anything, explain that write actions aren't available yet and offer to
@@ -47,28 +72,32 @@ Generative UI — render tools:
- When showing spending totals, cycle summaries, or category breakdowns → - When showing spending totals, cycle summaries, or category breakdowns →
call render_spending_summary. Source data: get_cycle_summary (by_source, call render_spending_summary. Source data: get_cycle_summary (by_source,
grand_total_crc) + get_analytics_by_category (by_category). grand_total_crc) + get_analytics_by_category (by_category).
- When showing transaction lists or other structured data → - Do NOT use markdown tables for data render_spending_summary can display.
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching - CRITICAL RULE: When you call render_spending_summary, that tool call MUST
calls have returned. NEVER call render_a2ui in the same batch as any other be the ONLY content in your message. Do NOT include any text content
tool. alongside the tool call — no introduction, no list, no explanation,
- Do NOT use markdown tables for data a render tool can display. nothing. The rendered card IS the complete response. Any text you write in
- CRITICAL RULE: When you call a render tool (render_spending_summary or the same message as a render call will appear as a duplicate below the
render_a2ui), that tool call MUST be the ONLY content in your message. card, which is wrong.
Do NOT include any text content alongside the tool call — no introduction, - When a render_spending_summary tool result ("ok") is already present for
no list, no explanation, nothing. The rendered card IS the complete the current question, the card is already on screen. Do NOT call the tool
response. Any text you write in the same message as a render call will again and do NOT add text — end your response with no further output.
appear as a duplicate below the card, which is wrong.
""" """
def build_agent() -> Agent: def build_agent() -> Agent:
client = OpenAIChatCompletionClient( # Use the Responses API for every configured model. It preserves one tool
# transport across model changes and supports Luna's tool-result turns.
client = OpenAIChatClient(
api_key=settings.OPENAI_API_KEY, api_key=settings.OPENAI_API_KEY,
model=settings.AGENT_MODEL, model=settings.AGENT_MODEL,
) )
return Agent( return Agent(
name="wealthysmart", name="wealthysmart",
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()), # No date baked in: build_agent() runs once at startup, so anything
# substituted here freezes at boot (and in server-UTC). The model
# gets "today" from the get_current_date tool instead.
instructions=SYSTEM_PROMPT,
client=client, client=client,
tools=TOOLS, tools=TOOLS,
) )

View File

@@ -0,0 +1,153 @@
"""Durable diagnostics for individual AG-UI agent runs."""
from __future__ import annotations
import asyncio
import contextvars
from datetime import timedelta
from typing import Any
from sqlmodel import Session, delete, select
from app.config import settings
from app.db import engine
from app.models.models import ChatRunLog
from app.timeutil import utcnow
CHAT_LOG_RETENTION_DAYS = 90
_chat_run_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"chat_run_id", default=None
)
def set_current_chat_run(run_id: int) -> contextvars.Token:
return _chat_run_id.set(run_id)
def reset_current_chat_run(token: contextvars.Token) -> None:
_chat_run_id.reset(token)
def current_chat_run_id() -> int | None:
return _chat_run_id.get()
def _last_user_prompt(messages: list[dict[str, Any]]) -> str | None:
for message in reversed(messages):
if message.get("role") == "user" and isinstance(message.get("content"), str):
return message["content"]
return None
def create_chat_run(
*,
request_id: str,
agui_run_id: str | None,
thread_id: str | None,
messages: list[dict[str, Any]],
client_ip: str | None = None,
user_agent: str | None = None,
browser: str | None = None,
) -> int:
now = utcnow()
with Session(engine) as session:
row = ChatRunLog(
request_id=request_id,
agui_run_id=agui_run_id,
requested_thread_id=thread_id,
model=settings.AGENT_MODEL,
client_ip=client_ip,
user_agent=user_agent,
browser=browser,
user_prompt=_last_user_prompt(messages),
expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS),
)
session.add(row)
session.commit()
session.refresh(row)
assert row.id is not None
return row.id
def _tool_activity(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
calls: dict[str, dict[str, Any]] = {}
order: list[str] = []
for message in messages:
if message.get("role") == "assistant":
for call in message.get("toolCalls") or message.get("tool_calls") or []:
call_id = str(call.get("id") or "")
if not call_id or call_id in calls:
continue
function = call.get("function") or {}
calls[call_id] = {
"id": call_id,
"name": function.get("name"),
"arguments": function.get("arguments"),
}
order.append(call_id)
elif message.get("role") == "tool":
call_id = str(message.get("toolCallId") or message.get("tool_call_id") or "")
if call_id in calls:
calls[call_id]["result"] = message.get("content")
if message.get("error") is not None:
calls[call_id]["error"] = message["error"]
return [calls[call_id] for call_id in order]
def _last_assistant_response(messages: list[dict[str, Any]]) -> str | None:
for message in reversed(messages):
if message.get("role") == "assistant" and isinstance(message.get("content"), str):
return message["content"]
return None
def finalize_chat_run_in_session(
session: Session, *, run_id: int, response_thread_id: str, messages: list[dict[str, Any]]
) -> None:
row = session.get(ChatRunLog, run_id)
if row is None:
return
row.response_thread_id = response_thread_id
row.tool_calls = _tool_activity(messages)
row.assistant_response = _last_assistant_response(messages)
row.status = "completed"
row.completed_at = utcnow()
session.add(row)
def mark_chat_run_failed(run_id: int, error: str) -> None:
with Session(engine) as session:
row = session.get(ChatRunLog, run_id)
if row is None or row.status == "completed":
return
row.status = "failed"
row.error = error[:4000]
row.completed_at = utcnow()
session.add(row)
session.commit()
def finish_pending_chat_run(run_id: int) -> None:
with Session(engine) as session:
row = session.get(ChatRunLog, run_id)
if row is None or row.status != "started":
return
row.status = "completed"
row.completed_at = utcnow()
session.add(row)
session.commit()
def purge_expired_chat_runs() -> int:
with Session(engine) as session:
result = session.exec(delete(ChatRunLog).where(ChatRunLog.expires_at < utcnow()))
session.commit()
return result.rowcount or 0
async def purge_expired_chat_runs_periodically() -> None:
"""Purge at startup and then once per day for a bounded audit footprint."""
while True:
await asyncio.to_thread(purge_expired_chat_runs)
await asyncio.sleep(24 * 60 * 60)

View File

@@ -0,0 +1,194 @@
"""Postgres-backed AG-UI thread snapshot store.
Implements the AGUIThreadSnapshotStore protocol from agent-framework-ag-ui so
the assistant conversation survives page reloads: MAF calls `save` with the
full cumulative message list at each run end, and `get` on the next request
(server-side context rebuild, or a client hydration run with empty messages).
Two deliberate choices:
- Own sessions per call: MAF saves during SSE streaming, after the
agent_auth_and_session middleware has already closed the request-bound
ContextVar session. Sync SQLModel work runs in a thread so the event loop
keeps streaming.
- `save` normalizes assistant `tool_calls` -> `toolCalls`: @ag-ui/client's
Zod schema silently strips the snake_case field, so a replayed snapshot
would lose tool calls (and the spending-summary card) on the way to the
browser. MAF reads both casings back, so storing camelCase is safe.
"""
from __future__ import annotations
import asyncio
from typing import Any
from agent_framework_ag_ui import AGUIThreadSnapshot
from sqlmodel import Session, select
from app.db import engine
from app.agent.chat_audit import current_chat_run_id, finalize_chat_run_in_session
from app.models.models import ChatThreadSnapshot
from app.timeutil import utcnow
def _normalize_message(msg: dict[str, Any]) -> dict[str, Any]:
out = dict(msg)
tool_calls = out.pop("tool_calls", None)
if tool_calls is None:
tool_calls = out.pop("toolCalls", None)
else:
out.pop("toolCalls", None)
# MAF writes tool_calls: null on plain-text assistant messages; only a
# non-empty list is meaningful downstream.
if isinstance(tool_calls, list) and tool_calls:
out["toolCalls"] = tool_calls
if "tool_call_id" in out:
out.setdefault("toolCallId", out["tool_call_id"])
del out["tool_call_id"]
return out
def _clean_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sanitize the cumulative snapshot before storing it.
MAF's history merge re-records the resent suffix of multi-run turns
(frontend-tool roundtrips) without deduplicating, and the model
occasionally re-issues an identical render tool call on the follow-up
run. Left as-is, the snapshot grows duplicates that replay as repeated
bubbles/cards after a reload.
"""
# Pass 1: drop BFF-injected ctx-* system messages, duplicate message
# ids, and repeated identical tool calls (same function + arguments)
# within one user turn.
seen_ids: set[str] = set()
turn_calls: set[tuple[Any, Any]] = set()
kept_call_ids: set[str] = set()
first_pass: list[dict[str, Any]] = []
for raw in messages:
m = _normalize_message(raw)
mid = str(m.get("id", ""))
if mid.startswith("ctx-"):
continue
if mid and mid in seen_ids:
continue
role = m.get("role")
if role == "user":
turn_calls = set()
if role == "assistant" and isinstance(m.get("toolCalls"), list):
kept: list[dict[str, Any]] = []
for tc in m["toolCalls"]:
fn = tc.get("function") or {}
key = (fn.get("name"), fn.get("arguments"))
if key in turn_calls:
continue
turn_calls.add(key)
kept.append(tc)
if tc.get("id"):
kept_call_ids.add(str(tc["id"]))
if kept:
m["toolCalls"] = kept
else:
m.pop("toolCalls", None)
if not m.get("content"):
continue # nothing left worth replaying
if mid:
seen_ids.add(mid)
first_pass.append(m)
# Pass 2: one tool result per surviving call id. Results whose call was
# deduplicated away must go too — an orphan tool message in the stored
# history would 400 the next OpenAI request when MAF rebuilds context.
answered: set[str] = set()
out: list[dict[str, Any]] = []
for m in first_pass:
if m.get("role") == "tool":
tcid = str(m.get("toolCallId") or "")
if tcid and (tcid not in kept_call_ids or tcid in answered):
continue
if tcid:
answered.add(tcid)
out.append(m)
return out
class PostgresSnapshotStore:
"""One upserted row per (scope, thread_id); latest snapshot only."""
async def save(
self, *, scope: str, thread_id: str, snapshot: AGUIThreadSnapshot
) -> None:
def _save() -> None:
with Session(engine) as session:
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == scope,
ChatThreadSnapshot.thread_id == thread_id,
)
).first()
if row is None:
row = ChatThreadSnapshot(scope=scope, thread_id=thread_id)
row.messages = _clean_messages(snapshot.messages)
row.state = snapshot.state
row.interrupt = snapshot.interrupt
row.updated_at = utcnow()
session.add(row)
run_id = current_chat_run_id()
if run_id is not None:
finalize_chat_run_in_session(
session,
run_id=run_id,
response_thread_id=thread_id,
messages=row.messages,
)
session.commit()
await asyncio.to_thread(_save)
async def get(
self, *, scope: str, thread_id: str
) -> AGUIThreadSnapshot | None:
def _get() -> AGUIThreadSnapshot | None:
with Session(engine) as session:
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == scope,
ChatThreadSnapshot.thread_id == thread_id,
)
).first()
if row is None:
return None
return AGUIThreadSnapshot(
messages=row.messages or [],
state=row.state,
interrupt=row.interrupt,
)
return await asyncio.to_thread(_get)
async def delete(self, *, scope: str, thread_id: str) -> bool:
def _delete() -> bool:
with Session(engine) as session:
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == scope,
ChatThreadSnapshot.thread_id == thread_id,
)
).first()
if row is None:
return False
session.delete(row)
session.commit()
return True
return await asyncio.to_thread(_delete)
async def clear(self, *, scope: str | None = None) -> None:
def _clear() -> None:
with Session(engine) as session:
stmt = select(ChatThreadSnapshot)
if scope is not None:
stmt = stmt.where(ChatThreadSnapshot.scope == scope)
for row in session.exec(stmt).all():
session.delete(row)
session.commit()
await asyncio.to_thread(_clear)

View File

@@ -13,7 +13,7 @@ write tool requires an explicit user-confirmation step in the UI.
from __future__ import annotations from __future__ import annotations
import contextvars import contextvars
from datetime import datetime from datetime import datetime, time, timedelta
from typing import Annotated, Optional from typing import Annotated, Optional
from pydantic import Field from pydantic import Field
@@ -35,17 +35,20 @@ from app.models.models import (
from app.services.budget_projection import ( from app.services.budget_projection import (
MAX_YEAR, MAX_YEAR,
MIN_YEAR, MIN_YEAR,
NOT_INSTALLMENT_ANCHOR,
compute_monthly_projection, compute_monthly_projection,
compute_yearly_projection_with_cumulative, compute_yearly_projection_with_cumulative,
get_cycle_range, get_cycle_range,
) )
from app.services import exchange_rate as fx from app.services import exchange_rate as fx
from app.timeutil import client_tz, today_client
from app.services.exchange_rate import ( from app.services.exchange_rate import (
get_converted_amount_expr, get_converted_amount_expr,
get_current_rate, get_current_rate,
) )
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session") _session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
SALARY_TRANSACTION_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
def set_session(session: Session) -> contextvars.Token: def set_session(session: Session) -> contextvars.Token:
@@ -140,7 +143,11 @@ def get_recent_transactions(
q = select(Transaction).where( q = select(Transaction).where(
col(Transaction.transaction_type).notin_( col(Transaction.transaction_type).notin_(
[TransactionType.SALARY, TransactionType.DEPOSITO] [TransactionType.SALARY, TransactionType.DEPOSITO]
) ),
# Tasa Cero generates future-dated cuotas; "recent" means already
# billed (same rule as /transactions/recent). Bound is end of the
# user's today in their request timezone (CR fallback).
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
) )
if source: if source:
q = q.where(Transaction.source == TransactionSource(source)) q = q.where(Transaction.source == TransactionSource(source))
@@ -333,7 +340,7 @@ def get_salary_summary() -> dict:
func.count(), func.count(),
func.coalesce(func.sum(amount_crc), 0), func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date), func.max(Transaction.date),
).where(Transaction.transaction_type == TransactionType.SALARY) ).where(col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES))
).first() ).first()
count = row[0] if row else 0 count = row[0] if row else 0
total = float(row[1]) if row else 0.0 total = float(row[1]) if row else 0.0
@@ -341,6 +348,44 @@ def get_salary_summary() -> dict:
return {"count": count, "total_crc": total, "latest_date": latest} return {"count": count, "total_crc": total, "latest_date": latest}
def get_salary_deposits(
limit: Annotated[int, Field(ge=1, le=100, description="How many deposits to return")] = 20,
start_date: Annotated[
Optional[str], Field(description="ISO date lower bound, inclusive")
] = None,
end_date: Annotated[
Optional[str], Field(description="ISO date upper bound, exclusive")
] = None,
) -> list[dict]:
"""Individual salary deposits, newest first. Use this for questions about
a specific salary or a day/range (for example, "cuánto recibí hoy").
Amounts remain in their recorded currency; use the currency field when
presenting them."""
q = select(Transaction).where(
col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES)
)
if start_date:
q = q.where(Transaction.date >= datetime.fromisoformat(start_date))
if end_date:
q = q.where(Transaction.date < datetime.fromisoformat(end_date))
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).limit(limit)
return [
{
"id": t.id,
"date": t.date.isoformat(),
"amount": float(t.amount),
"currency": t.currency.value,
"merchant": t.merchant,
"source": t.source.value,
"bank": t.bank.value,
"transaction_type": t.transaction_type.value,
"reference": t.reference,
"notes": t.notes,
}
for t in _s().exec(q).all()
]
def get_municipal_receipts( def get_municipal_receipts(
limit: Annotated[int, Field(ge=1, le=50)] = 12, limit: Annotated[int, Field(ge=1, le=50)] = 12,
offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0, offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0,
@@ -491,7 +536,68 @@ def list_categories() -> list[dict]:
# Registered with the agent in agent.py # Registered with the agent in agent.py
def get_daily_spending(
start_date: Annotated[str, Field(description="ISO date lower bound, inclusive")],
end_date: Annotated[str, Field(description="ISO date upper bound, exclusive")],
) -> list[dict]:
"""Spending per day in CRC (converted), COMPRA only, excluding future
Tasa Cero cuotas and installment anchors. THE tool for day-scoped
questions: 'cuánto gasté hoy / ayer / el martes'. Days with no spending
are simply absent from the result."""
session = _s()
amount_crc = get_converted_amount_expr(session)
rows = session.exec(
select(
func.date(Transaction.date).label("day"),
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
# Future-dated Tasa Cero cuotas are not money already spent.
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
)
.group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date))
).all()
return [
{"date": str(day), "total_crc": float(total), "count": count}
for day, total, count in rows
]
def get_current_date() -> dict:
"""Today's date in the user's own timezone (sent by their browser;
Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
call this before resolving any relative date reference — 'hoy', 'ayer',
'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
own assumptions about today are unreliable."""
today = today_client()
# get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before
# the 18th we are still in the cycle that started last month.
if today.day >= 18:
cycle_year, cycle_month = today.year, today.month
elif today.month == 1:
cycle_year, cycle_month = today.year - 1, 12
else:
cycle_year, cycle_month = today.year, today.month - 1
start, end = get_cycle_range(cycle_year, cycle_month)
return {
"date": today.isoformat(),
"weekday": today.strftime("%A"),
"timezone": str(client_tz()),
"cycle_year": cycle_year,
"cycle_month": cycle_month,
"cycle_range": [start.date().isoformat(), end.date().isoformat()],
}
TOOLS = [ TOOLS = [
get_current_date,
get_daily_spending,
get_accounts, get_accounts,
get_net_worth, get_net_worth,
get_recent_transactions, get_recent_transactions,
@@ -500,6 +606,7 @@ TOOLS = [
list_recurring_items, list_recurring_items,
get_pension_snapshots, get_pension_snapshots,
get_salary_summary, get_salary_summary,
get_salary_deposits,
get_municipal_receipts, get_municipal_receipts,
get_analytics_by_category, get_analytics_by_category,
get_monthly_trend, get_monthly_trend,

View File

@@ -10,6 +10,7 @@ from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.models.models import Category, Transaction, TransactionType from app.models.models import Category, Transaction, TransactionType
from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range
from app.timeutil import utcnow
from app.services.exchange_rate import get_converted_amount_expr from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"]) router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -42,6 +43,8 @@ class DailySpending(BaseModel):
def spending_by_category( def spending_by_category(
cycle_year: Optional[int] = None, cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None, cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
@@ -60,13 +63,19 @@ def spending_by_category(
.group_by(Transaction.category_id) .group_by(Transaction.category_id)
) )
if cycle_year and cycle_month: # Arbitrary date range takes precedence over the cycle filter.
if start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
elif cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month) start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end) query = query.where(Transaction.date >= start, Transaction.date < end)
rows = session.exec(query).all() rows = session.exec(query).all()
grand_total = sum(r[1] for r in rows) or 1 grand_total = float(sum(float(r[1]) for r in rows)) or 1.0
results = [] results = []
for category_id, total, count in rows: for category_id, total, count in rows:
@@ -164,6 +173,8 @@ def monthly_trend(
def daily_spending( def daily_spending(
cycle_year: Optional[int] = None, cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None, cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
@@ -178,12 +189,20 @@ def daily_spending(
.where( .where(
Transaction.transaction_type == TransactionType.COMPRA, Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR, NOT_INSTALLMENT_ANCHOR,
# Tasa Cero generates future-dated cuotas; daily spending is
# about money already spent.
Transaction.date <= utcnow(),
) )
.group_by(func.date(Transaction.date)) .group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date)) .order_by(func.date(Transaction.date))
) )
if cycle_year and cycle_month: if start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
elif cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month) start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end) query = query.where(Transaction.date >= start, Transaction.date < end)

View File

@@ -0,0 +1,41 @@
"""Assistant chat thread management.
The conversation itself is persisted by the AG-UI snapshot store
(app/agent/snapshot_store.py) under a single ("default", "main") thread;
this router only exposes the reset used by "Nueva conversación".
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import ChatThreadSnapshot
router = APIRouter(prefix="/chat", tags=["chat"])
CHAT_SCOPE = "default"
CHAT_THREAD_ID = "main"
class ClearThreadResponse(BaseModel):
cleared: bool
@router.delete("/thread", response_model=ClearThreadResponse)
def clear_thread(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == CHAT_SCOPE,
ChatThreadSnapshot.thread_id == CHAT_THREAD_ID,
)
).first()
if row is None:
return {"cleared": False}
session.delete(row)
session.commit()
return {"cleared": True}

View File

@@ -6,6 +6,7 @@ from app.api.v1.endpoints import (
auth, auth,
budget, budget,
categories, categories,
chat,
exchange_rate, exchange_rate,
import_transactions, import_transactions,
installments, installments,
@@ -38,3 +39,4 @@ api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router) api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router) api_router.include_router(savings_accrual.router)
api_router.include_router(sync_status.router) api_router.include_router(sync_status.router)
api_router.include_router(chat.router)

View File

@@ -20,7 +20,7 @@ class Settings(BaseSettings):
VAPID_PUBLIC_KEY: str = "" VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev" VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
OPENAI_API_KEY: str = "" OPENAI_API_KEY: str = ""
AGENT_MODEL: str = "gpt-5.4-mini" AGENT_MODEL: str = "gpt-5.6-luna"
@property @property
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:

31
backend/app/logging.py Normal file
View File

@@ -0,0 +1,31 @@
"""Structured, redacted application logging for container stdout."""
from __future__ import annotations
import json
from datetime import date, datetime
from enum import Enum
from typing import Any
class _JSONEncoder(json.JSONEncoder):
def default(self, value: Any) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Enum):
return value.value
return super().default(value)
def configure_structured_logging() -> None:
"""Reserved lifecycle hook for the structured stdout audit channel."""
def log_event(event: str, **fields: Any) -> None:
"""Write a single JSON line; callers must not pass credentials or bodies."""
# stdout is Docker's durable capture point. `flush=True` also makes this
# visible immediately in `docker compose logs` during an active SSE run.
print(
json.dumps({"event": event, **fields}, cls=_JSONEncoder, separators=(",", ":")),
flush=True,
)

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import uuid import uuid
from time import perf_counter
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -11,6 +12,15 @@ from jose import JWTError, jwt
from pydantic import BaseModel from pydantic import BaseModel
from app.agent.agent import build_agent from app.agent.agent import build_agent
from app.agent.chat_audit import (
create_chat_run,
finish_pending_chat_run,
mark_chat_run_failed,
purge_expired_chat_runs_periodically,
reset_current_chat_run,
set_current_chat_run,
)
from app.agent.snapshot_store import PostgresSnapshotStore
from app.agent.tools import reset_session, set_session from app.agent.tools import reset_session, set_session
from app.api.v1.router import api_router from app.api.v1.router import api_router
from app.auth import ( from app.auth import (
@@ -22,10 +32,47 @@ from app.auth import (
from app.config import settings from app.config import settings
from app.db import get_session, run_alembic_upgrade from app.db import get_session, run_alembic_upgrade
from app.seed import seed_db from app.seed import seed_db
from app.timeutil import reset_client_timezone, set_client_timezone
from app.services.exchange_rate import refresh_rates_periodically from app.services.exchange_rate import refresh_rates_periodically
from app.logging import configure_structured_logging, log_event
AGENT_PATH = "/api/v1/agent/agui" AGENT_PATH = "/api/v1/agent/agui"
MAX_USER_AGENT_LENGTH = 512
def _request_client_details(request: Request) -> tuple[str, str | None, str | None]:
"""Return the browser client details propagated through the frontend BFF.
Production nginx-proxy supplies X-Real-IP and the frontend forwards it to
FastAPI. For direct local development, fall back to X-Forwarded-For and
then the socket peer. The BFF is the only production path to the backend,
which is not publicly exposed.
"""
client_ip = request.headers.get("x-real-ip", "").strip()
if not client_ip:
forwarded = request.headers.get("x-forwarded-for", "")
client_ip = forwarded.split(",", 1)[0].strip()
if not client_ip:
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "").strip()[:MAX_USER_AGENT_LENGTH] or None
if not user_agent:
return client_ip, None, None
ua = user_agent.lower()
if "edg/" in ua or "edgios/" in ua:
browser = "Edge"
elif "opr/" in ua or "opera" in ua:
browser = "Opera"
elif "firefox/" in ua or "fxios/" in ua:
browser = "Firefox"
elif "chrome/" in ua or "crios/" in ua:
browser = "Chrome"
elif "safari/" in ua:
browser = "Safari"
else:
browser = "Other"
return client_ip, user_agent, browser
def _pair_orphan_tool_calls(messages: list) -> list: def _pair_orphan_tool_calls(messages: list) -> list:
@@ -63,19 +110,60 @@ def _pair_orphan_tool_calls(messages: list) -> list:
return out return out
def _drop_stale_client_history(session, thread_id, messages: list) -> list:
"""Conversation history is server-owned (the AG-UI snapshot store).
A request that carries assistant/tool history for a thread with NO
stored snapshot comes from a stale client — typically a second tab that
was open when 'Nueva conversación' cleared the thread. Trusting it
would re-persist the cleared conversation ('chunks' resurrecting after
every clear). Keep only the last user turn; MAF starts the thread
fresh from there.
"""
if not thread_id or not messages:
return messages
has_history = any(m.get("role") in ("assistant", "tool") for m in messages)
if not has_history:
return messages
from sqlmodel import select
from app.models.models import ChatThreadSnapshot
row = session.exec(
select(ChatThreadSnapshot.id).where(
ChatThreadSnapshot.thread_id == str(thread_id)
)
).first()
if row is not None:
return messages # thread exists; history is legitimate
last_user = None
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
last_user = i
break
return messages[last_user:] if last_user is not None else []
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
configure_structured_logging()
run_alembic_upgrade() run_alembic_upgrade()
seed_db() seed_db()
rate_refresh_task = asyncio.create_task(refresh_rates_periodically()) rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
chat_audit_cleanup_task = asyncio.create_task(purge_expired_chat_runs_periodically())
try: try:
yield yield
finally: finally:
rate_refresh_task.cancel() rate_refresh_task.cancel()
chat_audit_cleanup_task.cancel()
try: try:
await rate_refresh_task await rate_refresh_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
try:
await chat_audit_cleanup_task
except asyncio.CancelledError:
pass
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan) app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
@@ -89,6 +177,67 @@ app.add_middleware(
) )
async def log_api_request(request: Request, call_next):
"""Emit redacted, one-line diagnostics for API requests only.
This deliberately records no request/response bodies, headers, tokens,
passwords, or financial payloads. Full assistant content is retained only
in the bounded chat-run audit table.
"""
request_id = uuid.uuid4().hex
request.state.request_id = request_id
client_ip, user_agent, browser = _request_client_details(request)
request.state.client_ip = client_ip
request.state.user_agent = user_agent
request.state.browser = browser
started = perf_counter()
status_code = 500
is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health"
def emit(error_type: str | None = None) -> None:
if not is_logged_api:
return
route = request.scope.get("route")
log_event(
"api_request",
request_id=request_id,
method=request.method,
path=getattr(route, "path", request.url.path),
status_code=status_code,
duration_ms=round((perf_counter() - started) * 1000, 1),
error_type=error_type,
client_ip=client_ip,
user_agent=user_agent,
browser=browser,
)
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
original_iterator = getattr(response, "body_iterator", None)
if original_iterator is None:
emit()
return response
async def logged_stream():
error_type: str | None = None
try:
async for chunk in original_iterator:
yield chunk
except Exception as exc:
error_type = type(exc).__name__
raise
finally:
emit(error_type)
response.body_iterator = logged_stream()
return response
except Exception as exc:
emit(type(exc).__name__)
raise
@app.middleware("http") @app.middleware("http")
async def agent_auth_and_session(request: Request, call_next): async def agent_auth_and_session(request: Request, call_next):
"""For the AG-UI route, validate the JWT, repair message history, and """For the AG-UI route, validate the JWT, repair message history, and
@@ -119,25 +268,94 @@ async def agent_auth_and_session(request: Request, call_next):
except JWTError: except JWTError:
return Response(status_code=401, content="Invalid token") return Response(status_code=401, content="Invalid token")
session_gen = get_session()
session = next(session_gen)
token_var = set_session(session)
chat_run_id: int | None = None
# Repair orphan tool_calls before the MAF agent sees the message history. # Repair orphan tool_calls before the MAF agent sees the message history.
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""): if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
raw = await request.body() raw = await request.body()
try: try:
body = json.loads(raw) body = json.loads(raw)
if isinstance(body.get("messages"), list): if isinstance(body.get("messages"), list):
body["messages"] = _drop_stale_client_history(
session,
body.get("threadId") or body.get("thread_id"),
body["messages"],
)
body["messages"] = _pair_orphan_tool_calls(body["messages"]) body["messages"] = _pair_orphan_tool_calls(body["messages"])
raw = json.dumps(body).encode() raw = json.dumps(body).encode()
except Exception: log_event(
pass "agent_request",
request_id=request.state.request_id,
agui_run_id=body.get("runId"),
thread_id=body.get("threadId") or body.get("thread_id"),
message_count=len(body.get("messages") or []),
has_user_message=any(
message.get("role") == "user" for message in body.get("messages") or []
),
client_ip=request.state.client_ip,
user_agent=request.state.user_agent,
browser=request.state.browser,
)
# The BFF removes its `method: agent/run` envelope before
# forwarding to MAF. A non-empty user message is the reliable
# backend-side distinction between an agent run and hydration.
if any(message.get("role") == "user" for message in body.get("messages") or []):
chat_run_id = create_chat_run(
request_id=request.state.request_id,
agui_run_id=body.get("runId"),
thread_id=body.get("threadId") or body.get("thread_id"),
messages=body.get("messages") or [],
client_ip=request.state.client_ip,
user_agent=request.state.user_agent,
browser=request.state.browser,
)
except Exception as exc:
# Parsing/repair must not prevent the agent endpoint from serving;
# the access log will still capture a resulting error response.
log_event(
"chat_audit_start_failed",
request_id=request.state.request_id,
error_type=type(exc).__name__,
)
body = None
# Starlette caches the body; replace it so call_next sees the fixed bytes. # Starlette caches the body; replace it so call_next sees the fixed bytes.
request._body = raw # type: ignore[attr-defined] request._body = raw # type: ignore[attr-defined]
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
session_gen = get_session() # "today" wherever the user is (falls back to Costa Rica).
session = next(session_gen) tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
token_var = set_session(session) audit_token = set_current_chat_run(chat_run_id) if chat_run_id is not None else None
try: try:
return await call_next(request) response = await call_next(request)
if chat_run_id is None:
return response
original_iterator = response.body_iterator
async def audit_stream():
try:
async for chunk in original_iterator:
yield chunk
except Exception as exc:
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
raise
else:
# MAF normally finalizes via PostgresSnapshotStore.save(). If
# it emitted no snapshot, do not leave a permanently-open row.
finish_pending_chat_run(chat_run_id)
response.body_iterator = audit_stream()
return response
except Exception as exc:
if chat_run_id is not None:
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
raise
finally: finally:
if audit_token is not None:
reset_current_chat_run(audit_token)
reset_client_timezone(tz_token)
reset_session(token_var) reset_session(token_var)
try: try:
next(session_gen) next(session_gen)
@@ -145,11 +363,26 @@ async def agent_auth_and_session(request: Request, call_next):
pass pass
# FastAPI wraps function middlewares in reverse registration order. Register
# this after the AG-UI auth middleware so it observes every API response,
# including auth failures that return before calling the inner application.
app.middleware("http")(log_api_request)
# Register app routes # Register app routes
app.include_router(api_router) app.include_router(api_router)
# Mount the AG-UI agent endpoint. # Mount the AG-UI agent endpoint. The snapshot store persists the chat
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH) # thread across page reloads; scope is a constant because this is a
# single-user app and auth is already enforced by agent_auth_and_session
# (the scope resolver only sees the AGUIRequest, which carries no identity).
add_agent_framework_fastapi_endpoint(
app,
build_agent(),
AGENT_PATH,
snapshot_store=PostgresSnapshotStore(),
snapshot_scope_resolver=lambda _request: "default",
)
@app.get("/") @app.get("/")

View File

@@ -4,7 +4,7 @@ from decimal import Decimal
from typing import Annotated, Optional from typing import Annotated, Optional
from pydantic import PlainSerializer from pydantic import PlainSerializer
from sqlalchemy import JSON, Column, UniqueConstraint from sqlalchemy import JSON, Column, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
@@ -537,3 +537,67 @@ class WaterMeterReading(WaterMeterReadingBase, table=True):
class WaterMeterReadingRead(WaterMeterReadingBase): class WaterMeterReadingRead(WaterMeterReadingBase):
id: int id: int
created_at: datetime created_at: datetime
# --- Assistant Chat Thread ---
class ChatThreadSnapshot(SQLModel, table=True):
"""Latest AG-UI thread snapshot per (scope, thread_id).
Written by app/agent/snapshot_store.py: MAF overwrites the full
cumulative message list at each run end — one row per thread, not an
append log.
"""
__table_args__ = (UniqueConstraint("scope", "thread_id"),)
id: Optional[int] = Field(default=None, primary_key=True)
scope: str = Field(index=True)
thread_id: str
messages: list = Field(
default_factory=list,
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
)
state: Optional[dict] = Field(
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
)
interrupt: Optional[list] = Field(
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
)
updated_at: datetime = Field(default_factory=utcnow)
# --- Assistant Chat Run Audit ---
class ChatRunLog(SQLModel, table=True):
"""Append-only diagnostic record for an AG-UI agent/run request.
Thread snapshots are mutable conversation state. This table instead keeps
the request, model, tool activity, and final emitted answer for a bounded
retention period so individual assistant runs can be investigated later.
"""
id: Optional[int] = Field(default=None, primary_key=True)
request_id: str = Field(index=True, unique=True)
agui_run_id: Optional[str] = Field(default=None, index=True)
requested_thread_id: Optional[str] = Field(default=None, index=True)
response_thread_id: Optional[str] = Field(default=None, index=True)
model: str
client_ip: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
user_agent: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
browser: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
tool_calls: list = Field(
default_factory=list,
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
)
assistant_response: Optional[str] = Field(
default=None, sa_column=Column(Text, nullable=True)
)
status: str = Field(default="started", index=True)
error: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
started_at: datetime = Field(default_factory=utcnow)
completed_at: Optional[datetime] = Field(default=None)
expires_at: datetime = Field(index=True)

View File

@@ -1,4 +1,55 @@
from datetime import datetime, timezone import contextvars
from datetime import date, datetime, timedelta, timezone, tzinfo
from zoneinfo import ZoneInfo
# Costa Rica has no DST; a fixed offset is exact year-round. This is the
# FALLBACK when the request doesn't declare a timezone (n8n posts, tests).
CR_TZ = timezone(timedelta(hours=-6))
# Per-request client timezone, set by the agent middleware from the
# X-Client-Timezone header (IANA name sent by the browser). Falls back to
# Costa Rica so headless callers keep the owner's local semantics.
_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar(
"client_tz", default=CR_TZ
)
def set_client_timezone(name: str | None) -> contextvars.Token | None:
"""Bind the request's IANA timezone; ignores unknown/absent names.
Proxies that see the header twice merge the values into a comma list
("Zone, Zone") — take the first entry.
"""
if not name:
return None
try:
return _client_tz.set(ZoneInfo(name.split(",")[0].strip()))
except (KeyError, ValueError):
return None
def reset_client_timezone(token: contextvars.Token | None) -> None:
if token is not None:
_client_tz.reset(token)
def client_tz() -> tzinfo:
return _client_tz.get()
def today_client() -> date:
"""Current date where the USER is (request timezone, CR fallback).
The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow"
in UTC. Anything user-facing that means "today" (agent answers, date
defaults) must come from here, never date.today().
"""
return datetime.now(client_tz()).date()
def today_cr() -> date:
"""Current date in Costa Rica (UTC-6) — timezone-independent fallback."""
return datetime.now(CR_TZ).date()
def utcnow() -> datetime: def utcnow() -> datetime:

View File

@@ -1,8 +1,8 @@
a2a-sdk==0.3.23 a2a-sdk==0.3.23
ag-ui-protocol==0.1.19 ag-ui-protocol==0.1.19
agent-framework==1.2.1 agent-framework==1.11.0
agent-framework-a2a==1.0.0b260428 agent-framework-a2a==1.0.0b260428
agent-framework-ag-ui==1.0.0b260428 agent-framework-ag-ui==1.0.0rc8
agent-framework-anthropic==1.0.0b260428 agent-framework-anthropic==1.0.0b260428
agent-framework-azure-ai-search==1.0.0b260428 agent-framework-azure-ai-search==1.0.0b260428
agent-framework-azure-cosmos==1.0.0b260428 agent-framework-azure-cosmos==1.0.0b260428
@@ -11,7 +11,7 @@ agent-framework-bedrock==1.0.0b260428
agent-framework-chatkit==1.0.0b260428 agent-framework-chatkit==1.0.0b260428
agent-framework-claude==1.0.0b260428 agent-framework-claude==1.0.0b260428
agent-framework-copilotstudio==1.0.0b260428 agent-framework-copilotstudio==1.0.0b260428
agent-framework-core==1.2.1 agent-framework-core==1.11.0
agent-framework-declarative==1.0.0b260428 agent-framework-declarative==1.0.0b260428
agent-framework-devui==1.0.0b260428 agent-framework-devui==1.0.0b260428
agent-framework-durabletask==1.0.0b260428 agent-framework-durabletask==1.0.0b260428
@@ -21,7 +21,7 @@ agent-framework-github-copilot==1.0.0b260402
agent-framework-lab==1.0.0b251024 agent-framework-lab==1.0.0b251024
agent-framework-mem0==1.0.0b260428 agent-framework-mem0==1.0.0b260428
agent-framework-ollama==1.0.0b260428 agent-framework-ollama==1.0.0b260428
agent-framework-openai==1.2.1 agent-framework-openai==1.10.1
agent-framework-orchestrations==1.0.0b260428 agent-framework-orchestrations==1.0.0b260428
agent-framework-purview==1.0.0b260428 agent-framework-purview==1.0.0b260428
agent-framework-redis==1.0.0b260428 agent-framework-redis==1.0.0b260428
@@ -165,7 +165,7 @@ six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.50 SQLAlchemy==2.0.50
sqlmodel==0.0.38 sqlmodel==0.0.38
sse-starlette==3.4.4 sse-starlette==3.4.5
starlette==1.2.1 starlette==1.2.1
tenacity==9.1.4 tenacity==9.1.4
tqdm==4.68.2 tqdm==4.68.2

View File

@@ -11,6 +11,6 @@ httpx
pywebpush pywebpush
py-vapid py-vapid
python-dateutil python-dateutil
agent-framework==1.2.1 agent-framework==1.11.0
agent-framework-ag-ui==1.0.0b260428 agent-framework-ag-ui==1.0.0rc8
agent-framework-openai==1.2.1 agent-framework-openai==1.10.1

View File

@@ -10,6 +10,9 @@ os.environ.setdefault(
os.environ.setdefault("ADMIN_USERNAME", "testadmin") os.environ.setdefault("ADMIN_USERNAME", "testadmin")
os.environ.setdefault("ADMIN_PASSWORD", "test-password") os.environ.setdefault("ADMIN_PASSWORD", "test-password")
os.environ.setdefault("DATABASE_URL", "sqlite://") os.environ.setdefault("DATABASE_URL", "sqlite://")
# app.main builds the MAF agent at import; the OpenAI client only needs a
# syntactically-present key.
os.environ.setdefault("OPENAI_API_KEY", "test-dummy-key")
import pytest import pytest
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool

View File

@@ -0,0 +1,14 @@
"""Assistant transport configuration."""
from agent_framework.openai import OpenAIChatClient
from app.agent import agent as agent_module
def test_agent_uses_responses_client_for_the_configured_model(monkeypatch):
monkeypatch.setattr(agent_module.settings, "AGENT_MODEL", "gpt-5.6-luna")
agent = agent_module.build_agent()
assert isinstance(agent.client, OpenAIChatClient)
assert agent.client.model == "gpt-5.6-luna"

View File

@@ -17,6 +17,7 @@ from app.models.models import (
MunicipalReceipt, MunicipalReceipt,
PensionSnapshot, PensionSnapshot,
Transaction, Transaction,
TransactionType,
WaterMeterReading, WaterMeterReading,
) )
@@ -149,3 +150,137 @@ def test_recent_transactions_json_safe(bound_session):
out = tools.get_recent_transactions() out = tools.get_recent_transactions()
json.dumps(out) json.dumps(out)
assert out[0]["amount"] == 42.42 assert out[0]["amount"] == 42.42
def test_recent_transactions_exclude_future_cuotas(bound_session):
from datetime import timedelta
from app.timeutil import today_cr
today = datetime.combine(today_cr(), datetime.min.time())
bound_session.add(
Transaction(amount=Decimal("10"), merchant="BILLED", date=today)
)
bound_session.add(
Transaction(
amount=Decimal("20"),
merchant="FUTURE CUOTA:03/06",
date=today + timedelta(days=45),
)
)
bound_session.commit()
merchants = [t["merchant"] for t in tools.get_recent_transactions()]
assert "BILLED" in merchants
assert "FUTURE CUOTA:03/06" not in merchants
def test_salary_deposits_returns_individual_filtered_rows(bound_session):
bound_session.add_all(
[
Transaction(
amount=Decimal("1500"),
merchant="SALARY JULY",
date=datetime(2026, 7, 14, 9),
transaction_type=TransactionType.SALARY,
reference="PAY-2026-07",
),
Transaction(
amount=Decimal("1400"),
merchant="SALARY JUNE",
date=datetime(2026, 6, 14, 9),
transaction_type=TransactionType.SALARY,
),
Transaction(
amount=Decimal("999"),
merchant="PURCHASE",
date=datetime(2026, 7, 14, 10),
),
]
)
bound_session.commit()
out = tools.get_salary_deposits(
start_date="2026-07-14", end_date="2026-07-15"
)
json.dumps(out)
assert out == [
{
"id": out[0]["id"],
"date": "2026-07-14T09:00:00",
"amount": 1500.0,
"currency": "CRC",
"merchant": "SALARY JULY",
"source": "CREDIT_CARD",
"bank": "BAC",
"transaction_type": "SALARY",
"reference": "PAY-2026-07",
"notes": None,
}
]
def test_get_current_date_is_costa_rica_and_cycle_consistent():
from app.timeutil import today_cr
out = tools.get_current_date()
today = today_cr()
assert out["date"] == today.isoformat()
# The active cycle must contain today: [start, end)
start, end = out["cycle_range"]
assert start <= out["date"] < end
assert start.endswith("-18") and end.endswith("-18")
def test_daily_spending_day_scoped(bound_session):
from datetime import timedelta
from app.timeutil import today_cr
today = datetime.combine(today_cr(), datetime.min.time())
bound_session.add(
Transaction(amount=Decimal("100"), merchant="TODAY-A", date=today.replace(hour=9))
)
bound_session.add(
Transaction(amount=Decimal("50"), merchant="TODAY-B", date=today.replace(hour=15))
)
bound_session.add(
Transaction(
amount=Decimal("999"),
merchant="FUTURE CUOTA",
date=today + timedelta(days=40),
)
)
bound_session.commit()
out = tools.get_daily_spending(
start_date=today.date().isoformat(),
end_date=(today.date() + timedelta(days=60)).isoformat(),
)
assert len(out) == 1
assert out[0]["date"] == today.date().isoformat()
assert out[0]["total_crc"] == 150.0
assert out[0]["count"] == 2
def test_get_current_date_respects_client_timezone():
from datetime import datetime, timezone as tz
from app.timeutil import (
client_tz,
reset_client_timezone,
set_client_timezone,
)
token = set_client_timezone("Asia/Tokyo")
try:
out = tools.get_current_date()
assert out["timezone"] == "Asia/Tokyo"
assert out["date"] == datetime.now(client_tz()).date().isoformat()
finally:
reset_client_timezone(token)
# Unknown names are ignored — CR fallback stays active.
assert set_client_timezone("Not/AZone") is None
assert set_client_timezone(None) is None
assert tools.get_current_date()["timezone"] == "UTC-06:00"

View File

@@ -0,0 +1,62 @@
"""Analytics endpoints: category aggregation (Decimal regression) and the
arbitrary date-range filters added for the Analytics range picker."""
from datetime import datetime
from decimal import Decimal
from app.api.v1.endpoints.analytics import daily_spending, spending_by_category
from app.models.models import Category, Transaction
from tests.test_installments import make_cc_tx
def seed(session):
cat = Category(name="Groceries")
session.add(cat)
session.commit()
make_cc_tx(session, merchant="A", amount="1000.50", reference="a1",
date=datetime(2026, 6, 2), category_id=cat.id)
make_cc_tx(session, merchant="B", amount="2000.00", reference="b1",
date=datetime(2026, 6, 20))
return cat
class TestSpendingByCategory:
def test_decimal_sum_does_not_crash_and_percentages_add_up(self, session):
"""Regression: Decimal grand total vs float row total raised
TypeError, 500ing the endpoint whenever it had data."""
seed(session)
rows = spending_by_category(session=session, _user="t")
assert {r.category_name for r in rows} == {"Groceries", "Uncategorized"}
assert sum(r.percentage for r in rows) == 100.0
def test_date_range_overrides_cycle(self, session):
seed(session)
rows = spending_by_category(
start_date="2026-06-01",
end_date="2026-06-10",
cycle_year=2026,
cycle_month=1, # would match nothing; range must win
session=session,
_user="t",
)
assert len(rows) == 1
assert rows[0].category_name == "Groceries"
assert rows[0].total == 1000.50
class TestDailySpending:
def test_date_range_filter(self, session):
seed(session)
rows = daily_spending(
start_date="2026-06-15", end_date="2026-07-01",
session=session, _user="t",
)
assert [r.date for r in rows] == ["2026-06-20"]
assert rows[0].total == 2000.00
def test_future_cuotas_excluded(self, session):
make_cc_tx(session, merchant="FUTURE", amount="500.00",
reference="f1", date=datetime(2030, 1, 19))
rows = daily_spending(session=session, _user="t")
assert rows == []

View File

@@ -0,0 +1,139 @@
"""Chat-run audit persistence and redacted HTTP access logs."""
import asyncio
from datetime import timedelta
from agent_framework_ag_ui import AGUIThreadSnapshot
from starlette.requests import Request
from starlette.responses import Response
from sqlmodel import select
import app.agent.chat_audit as chat_audit
import app.agent.snapshot_store as snapshot_store_module
from app.agent.snapshot_store import PostgresSnapshotStore
from app.models.models import ChatRunLog
from app.timeutil import utcnow
def _audit_store(monkeypatch, engine):
monkeypatch.setattr(chat_audit, "engine", engine)
monkeypatch.setattr(snapshot_store_module, "engine", engine)
return PostgresSnapshotStore()
def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine, session):
store = _audit_store(monkeypatch, engine)
run_id = chat_audit.create_chat_run(
request_id="req-1",
agui_run_id="run-1",
thread_id="main",
messages=[{"role": "user", "content": "¿Cuál es mi saldo?"}],
client_ip="203.0.113.7",
user_agent="Mozilla/5.0 TestBrowser/1.0",
browser="TestBrowser",
)
token = chat_audit.set_current_chat_run(run_id)
try:
asyncio.run(
store.save(
scope="default",
thread_id="resp_123",
snapshot=AGUIThreadSnapshot(
messages=[
{"id": "u1", "role": "user", "content": "¿Cuál es mi saldo?"},
{
"id": "a1",
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "get_net_worth", "arguments": "{}"},
}
],
},
{"id": "t1", "role": "tool", "tool_call_id": "call-1", "content": "{\"net_crc\": 1}"},
{"id": "a2", "role": "assistant", "content": "Tu saldo es ₡1."},
]
),
)
)
finally:
chat_audit.reset_current_chat_run(token)
row = session.get(ChatRunLog, run_id)
assert row is not None
assert row.status == "completed"
assert row.agui_run_id == "run-1"
assert row.response_thread_id == "resp_123"
assert row.user_prompt == "¿Cuál es mi saldo?"
assert row.client_ip == "203.0.113.7"
assert row.user_agent == "Mozilla/5.0 TestBrowser/1.0"
assert row.browser == "TestBrowser"
assert row.assistant_response == "Tu saldo es ₡1."
assert row.tool_calls == [
{
"id": "call-1",
"name": "get_net_worth",
"arguments": "{}",
"result": '{"net_crc": 1}',
}
]
def test_failure_and_retention_cleanup(monkeypatch, engine, session):
_audit_store(monkeypatch, engine)
failed_id = chat_audit.create_chat_run(
request_id="req-failed", agui_run_id="run-failed", thread_id="main", messages=[]
)
chat_audit.mark_chat_run_failed(failed_id, "RuntimeError: provider unavailable")
assert session.get(ChatRunLog, failed_id).status == "failed"
expired = ChatRunLog(
request_id="req-expired",
model="gpt-5.6-luna",
expires_at=utcnow() - timedelta(seconds=1),
)
session.add(expired)
session.commit()
assert chat_audit.purge_expired_chat_runs() == 1
assert session.exec(select(ChatRunLog).where(ChatRunLog.request_id == "req-expired")).first() is None
def test_api_access_log_never_contains_request_body(monkeypatch):
from app.main import log_api_request
logged = []
monkeypatch.setattr("app.main.log_event", lambda event, **fields: logged.append((event, fields)))
scope = {
"type": "http",
"method": "POST",
"path": "/api/auth/login",
"raw_path": b"/api/auth/login",
"query_string": b"",
"headers": [
(b"x-real-ip", b"203.0.113.9"),
(b"user-agent", b"Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"),
],
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 1234),
}
request = Request(scope)
async def call_next(_request):
return Response(status_code=201)
response = asyncio.run(log_api_request(request, call_next))
assert response.headers["X-Request-ID"]
assert logged[0][0] == "api_request"
fields = logged[0][1]
assert fields["path"] == "/api/auth/login"
assert fields["status_code"] == 201
assert fields["client_ip"] == "203.0.113.9"
assert fields["browser"] == "Chrome"
assert fields["user_agent"] == "Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"
assert "password" not in fields
assert "body" not in fields

View File

@@ -0,0 +1,167 @@
"""Chat thread persistence: snapshot store round-trip + clear endpoint."""
import asyncio
from agent_framework_ag_ui import AGUIThreadSnapshot
from sqlmodel import select
import app.agent.snapshot_store as snapshot_store_module
from app.agent.snapshot_store import PostgresSnapshotStore
from app.api.v1.endpoints.chat import clear_thread
from app.models.models import ChatThreadSnapshot
def _store(monkeypatch, engine):
# The store opens its own sessions from app.db.engine; point it at the
# test engine instead.
monkeypatch.setattr(snapshot_store_module, "engine", engine)
return PostgresSnapshotStore()
def test_save_get_roundtrip_normalizes_and_filters(monkeypatch, engine):
store = _store(monkeypatch, engine)
snapshot = AGUIThreadSnapshot(
messages=[
{"id": "u1", "role": "user", "content": "hola"},
{
"id": "a1",
"role": "assistant",
"content": "",
# MAF emits snake_case; the store must return camelCase so
# @ag-ui/client's Zod schema doesn't strip it.
"tool_calls": [{"id": "t1", "type": "function"}],
},
{"id": "t1r", "role": "tool", "content": "ok", "tool_call_id": "t1"},
# MAF writes tool_calls: null on plain-text assistant messages —
# must not survive as toolCalls: null in the replay.
{"id": "a2", "role": "assistant", "content": "done", "tool_calls": None},
{"id": "ctx-123", "role": "system", "content": "run context"},
],
state={"k": "v"},
)
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
assert loaded is not None
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1r", "a2"]
assistant = loaded.messages[1]
assert "tool_calls" not in assistant
assert assistant["toolCalls"] == [{"id": "t1", "type": "function"}]
tool_msg = loaded.messages[2]
assert "tool_call_id" not in tool_msg
assert tool_msg["toolCallId"] == "t1"
text_msg = loaded.messages[3]
assert "toolCalls" not in text_msg and "tool_calls" not in text_msg
assert loaded.state == {"k": "v"}
def test_save_upserts_single_row(monkeypatch, engine, session):
store = _store(monkeypatch, engine)
for n in range(3):
snap = AGUIThreadSnapshot(
messages=[{"id": f"u{i}", "role": "user", "content": str(i)} for i in range(n + 1)]
)
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
rows = session.exec(select(ChatThreadSnapshot)).all()
assert len(rows) == 1
assert len(rows[0].messages) == 3
def test_get_unknown_thread_returns_none(monkeypatch, engine):
store = _store(monkeypatch, engine)
assert asyncio.run(store.get(scope="default", thread_id="nope")) is None
def test_delete_and_scope_isolation(monkeypatch, engine):
store = _store(monkeypatch, engine)
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
assert asyncio.run(store.get(scope="other", thread_id="main")) is None
assert asyncio.run(store.delete(scope="default", thread_id="main")) is True
assert asyncio.run(store.delete(scope="default", thread_id="main")) is False
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
def test_save_dedupes_multi_run_turn(monkeypatch, engine):
"""Mirror of MAF's duplicate merge on frontend-tool roundtrips: repeated
message ids, a re-issued identical render call, and extra tool results."""
store = _store(monkeypatch, engine)
def call(cid, name, args='{"a":1}'):
return {"id": cid, "type": "function", "function": {"name": name, "arguments": args}}
snapshot = AGUIThreadSnapshot(
messages=[
{"id": "u1", "role": "user", "content": "resumen"},
{"id": "a1", "role": "assistant", "content": "",
"tool_calls": [call("c1", "get_cycle_summary"), call("c2", "render_spending_summary")]},
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
# MAF re-records the resent suffix verbatim…
{"id": "u1", "role": "user", "content": "resumen"},
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
{"id": "t2", "role": "tool", "content": "ok", "tool_call_id": "c2"},
# …plus a synthetic second result for an answered call…
{"id": "t3", "role": "tool", "content": "", "tool_call_id": "c1"},
# …and the model re-issues the same render call with a new id.
{"id": "a2", "role": "assistant", "content": "",
"tool_calls": [call("c3", "render_spending_summary")]},
{"id": "t4", "role": "tool", "content": "ok", "tool_call_id": "c3"},
]
)
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1", "t2"]
# The duplicate render call (c3) and its result are gone; each surviving
# call has exactly one result.
assert [tc["id"] for tc in loaded.messages[1]["toolCalls"]] == ["c1", "c2"]
assert [(m["toolCallId"]) for m in loaded.messages if m["role"] == "tool"] == ["c1", "c2"]
def test_clear_thread_endpoint(monkeypatch, engine, session):
store = _store(monkeypatch, engine)
assert clear_thread(session=session, _user="testadmin") == {"cleared": False}
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
assert clear_thread(session=session, _user="testadmin") == {"cleared": True}
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
def test_drop_stale_client_history(session, monkeypatch, engine):
from app.main import _drop_stale_client_history
history = [
{"role": "user", "content": "vieja pregunta"},
{"role": "assistant", "content": "vieja respuesta"},
{"role": "tool", "content": "x", "toolCallId": "t1"},
{"role": "user", "content": "nueva pregunta"},
]
# No stored snapshot for the thread → stale client: keep last user turn.
assert _drop_stale_client_history(session, "main", list(history)) == [
{"role": "user", "content": "nueva pregunta"}
]
# Fresh conversation (no assistant/tool yet) passes through.
fresh = [{"role": "user", "content": "hola"}]
assert _drop_stale_client_history(session, "main", list(fresh)) == fresh
# Empty messages (hydration trigger) pass through.
assert _drop_stale_client_history(session, "main", []) == []
# With a stored snapshot the full history is legitimate.
store = _store(monkeypatch, engine)
asyncio.run(
store.save(
scope="default",
thread_id="main",
snapshot=AGUIThreadSnapshot(
messages=[{"id": "u1", "role": "user", "content": "hola"}]
),
)
)
assert _drop_stale_client_history(session, "main", list(history)) == history

View File

@@ -16,6 +16,11 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
backend: backend:
build: build:
@@ -31,11 +36,17 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini} AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false"
expose: expose:
- "8000" - "8000"
networks: networks:
- wealthysmart-network wealthysmart-network:
# Avoid the generic `backend` name, which is also used on the shared
# nginx network by another application.
aliases:
- wealthysmart-api
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -45,6 +56,11 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
frontend: frontend:
build: build:
@@ -57,8 +73,8 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
BACKEND_URL: http://wealthysmart-backend-prod:8000 BACKEND_URL: http://wealthysmart-api:8000
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui AGENT_URL: http://wealthysmart-api:8000/api/v1/agent/agui
JWT_SECRET: ${SECRET_KEY} JWT_SECRET: ${SECRET_KEY}
COOKIE_DOMAIN: wealth.cescalante.dev COOKIE_DOMAIN: wealth.cescalante.dev
COOKIE_SECURE: "true" COOKIE_SECURE: "true"
@@ -78,6 +94,11 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks: networks:
wealthysmart-network: wealthysmart-network:

View File

@@ -15,6 +15,11 @@ services:
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
backend: backend:
build: build:
@@ -30,7 +35,9 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini} AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false"
ports: ports:
- "8001:8000" - "8001:8000"
volumes: volumes:
@@ -38,6 +45,11 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
develop: develop:
watch: watch:
- path: ./backend/app - path: ./backend/app
@@ -58,9 +70,17 @@ services:
- "5175:3000" - "5175:3000"
environment: environment:
NODE_ENV: development NODE_ENV: development
# Vite runs inside this container, so localhost would refer to the
# frontend rather than the Python service.
BACKEND_URL: http://backend:8000
AGENT_URL: http://backend:8000/api/v1/agent/agui AGENT_URL: http://backend:8000/api/v1/agent/agui
depends_on: depends_on:
- backend - backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
develop: develop:
watch: watch:
- path: ./frontend/src - path: ./frontend/src

View File

@@ -22,7 +22,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Cap node heap so a build on a small VPS can't OOM-kill neighbours. # Cap node heap so a build on a small VPS can't OOM-kill neighbours.
ENV NODE_OPTIONS=--max-old-space-size=1536 # 1536 stopped being enough at the 2026-07 UI revamp (cmdk, day-picker,
# chart wrappers); rollup now peaks between 1.5G and 2G.
ENV NODE_OPTIONS=--max-old-space-size=2048
RUN corepack enable && pnpm build RUN corepack enable && pnpm build
# Production: Hono serves dist/ + /api/copilotkit on port 3000 # Production: Hono serves dist/ + /api/copilotkit on port 3000

View File

@@ -0,0 +1,113 @@
import { expect, test, type Page } from "@playwright/test";
test.setTimeout(150_000);
async function startFreshAssistant(page: Page) {
await page.goto("/login");
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/");
const initialConnect = page.waitForResponse((response) => {
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
return false;
}
try {
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
} catch {
return false;
}
});
await page.goto("/asistente");
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
await (await initialConnect).finished();
const newConversation = page.getByRole("button", { name: "Nueva conversación" });
if (await newConversation.isEnabled()) {
await newConversation.click();
const reconnect = page.waitForResponse((response) => {
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
return false;
}
try {
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
} catch {
return false;
}
});
await page.getByRole("button", { name: "Borrar historial" }).click();
await (await reconnect).finished();
await expect(
page.getByRole("textbox", { name: "Escribe tu pregunta…" }),
).toBeEnabled({ timeout: 30_000 });
}
}
async function ask(page: Page, question: string, expectedTool: string) {
// The BFF response is the exact CopilotKit stream consumed by the browser.
// Checking it catches server-side RUN_ERROR events even if the UI changes.
const stream = page.waitForResponse(
(response) => {
if (
!response.url().includes("/api/copilotkit") ||
response.request().method() !== "POST"
) {
return false;
}
try {
return JSON.parse(response.request().postData() ?? "{}").method === "agent/run";
} catch {
return false;
}
},
);
const composer = page.getByRole("textbox", { name: "Escribe tu pregunta…" });
await expect(composer).toBeEnabled({ timeout: 30_000 });
await composer.fill(question);
const sendButton = page.getByTestId("copilot-send-button");
await expect(sendButton).toBeEnabled({ timeout: 30_000 });
await sendButton.click();
await expect(page.getByText(question, { exact: true }).last()).toBeVisible();
const response = await stream;
await response.finished();
const events = await response.text();
expect(events).not.toContain("RUN_ERROR");
expect(events).toContain(expectedTool);
// The HTTP stream may finish before CopilotKit has committed the tool run
// and released its client-side running state. Waiting for the page action
// avoids losing the next Enter keypress during that short interval.
await expect(page.getByRole("button", { name: "Nueva conversación" })).toBeEnabled({
timeout: 30_000,
});
}
test("Asistente completes Luna's read-only tool flows", async ({ page }) => {
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
await startFreshAssistant(page);
await ask(page, "What date is it today?", "get_current_date");
await ask(page, "List my last 10 transactions.", "get_recent_transactions");
await ask(page, "What is my net worth?", "get_net_worth");
await ask(page, "How much did I spend today?", "get_daily_spending");
await ask(page, "Show my spending by category this month.", "render_spending_summary");
const spendingCard = page.getByTestId("spending-summary-card");
await expect(spendingCard).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Escribe tu pregunta…" })).toBeEnabled({
timeout: 30_000,
});
await page.reload();
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
await expect(page.getByText("Show my spending by category this month.", { exact: true })).toBeVisible();
await expect(page.getByTestId("spending-summary-card")).toBeVisible();
expect(consoleErrors.join("\n")).not.toContain("ChatClientException");
});

127
frontend/e2e/feature-doc.ts Normal file
View File

@@ -0,0 +1,127 @@
import fs from "node:fs";
import path from "node:path";
import { test, type Page, type TestInfo } from "@playwright/test";
import * as narration from "./narration";
import { renderFinalVideo } from "./video-render";
import type { Clip } from "./subtitles";
const DEMO = !!process.env.PW_DEMO;
/**
* Wraps test.step() so every step of a demo spec produces a captioned
* screenshot, and — in PW_DEMO mode — a native Playwright screencast
* recording with cursor annotations, chapter-title cards, burned-in
* subtitles and (when `say` is available) spoken Spanish narration.
* Screenshots/video/subtitles land in e2e-docs/<slug>/ and are also
* attached to the Playwright HTML report; save() stitches everything into
* a Markdown walkthrough that can be published as-is.
*/
export class FeatureDoc {
private steps: { title: string; caption: string; file: string }[] = [];
private clips: Clip[] = [];
private readonly dir: string;
private recordingStartedAt = 0;
constructor(
private page: Page,
private testInfo: TestInfo,
private slug: string,
private title: string,
private intro: string,
) {
this.dir = path.resolve("e2e-docs", slug);
fs.rmSync(this.dir, { recursive: true, force: true });
fs.mkdirSync(this.dir, { recursive: true });
}
/** Starts the native screencast recording. Call before the first step(). */
async start() {
if (!DEMO) return;
this.recordingStartedAt = Date.now();
await this.page.screencast.start({
path: path.join(this.dir, "raw.webm"),
size: { width: 1920, height: 1080 },
});
await this.page.screencast.showActions({ cursor: "pointer" });
}
async step(title: string, caption: string, fn: () => Promise<void>) {
const narrationPromise = DEMO
? narration.synthesize(caption, this.testInfo.outputDir, this.clips.length)
: Promise.resolve(null);
const stepStart = Date.now();
if (DEMO) {
await this.page.screencast.showChapter(title, { description: caption, duration: 1500 });
}
await test.step(title, fn);
const narrationResult = await narrationPromise;
if (DEMO) {
const elapsed = Date.now() - stepStart;
const target = (narrationResult?.durationMs ?? 0) + 300;
if (target > elapsed) await this.page.waitForTimeout(target - elapsed);
}
const file = `${String(this.steps.length + 1).padStart(2, "0")}-${title
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]+/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")}.png`;
const body = await this.page.screenshot({
path: path.join(this.dir, file),
animations: "disabled",
});
await this.testInfo.attach(title, { body, contentType: "image/png" });
this.steps.push({ title, caption, file });
if (DEMO) {
this.clips.push({
title,
caption,
file,
startMs: stepStart - this.recordingStartedAt,
endMs: Date.now() - this.recordingStartedAt,
narration: narrationResult,
});
}
}
async save() {
let videoName = "video.mp4";
if (DEMO) {
await this.page.screencast.stop();
const { videoFile, degraded } = await renderFinalVideo({
rawWebmPath: path.join(this.dir, "raw.webm"),
clips: this.clips,
outDir: this.dir,
});
for (const warning of degraded) console.warn(`[${this.slug}] ${warning}`);
videoName = path.basename(videoFile);
await this.testInfo.attach("video", {
path: videoFile,
contentType: videoName.endsWith(".mp4") ? "video/mp4" : "video/webm",
});
}
const md = [
`# ${this.title}`,
"",
this.intro,
"",
`> Generado automáticamente por \`${path.basename(this.testInfo.file)}\`${this.steps.length} pasos. El video completo del recorrido está en [${videoName}](${videoName}).`,
"",
...this.steps.flatMap((s, i) => [
`## ${i + 1}. ${s.title}`,
"",
s.caption,
"",
`![${s.title}](${s.file})`,
"",
]),
].join("\n");
fs.writeFileSync(path.join(this.dir, "index.md"), md);
}
}

View File

@@ -0,0 +1,102 @@
import { test, expect } from "@playwright/test";
import { FeatureDoc } from "./feature-doc";
/**
* Demo walkthrough of the Financiamientos (Tasa Cero) feature. Doubles as an
* e2e smoke test and as the generator for e2e-docs/financiamientos/ — a
* captioned, screenshot-per-step Markdown doc plus the full video (native
* cursor/chapters, burned-in subtitles, spoken narration) in the Playwright
* HTML report.
*/
// NOTE: do not `test.use({ video: "off" })` here — disabling the fixture
// video breaks page.screencast's internal sizing (verified: it then records
// a shrunken frame letterboxed inside the requested canvas). The fixture's
// own video ends up redundant/unused but harmless.
test("@demo Financiamientos — compras Tasa Cero en cuotas", async ({ page }, testInfo) => {
const doc = new FeatureDoc(
page,
testInfo,
"financiamientos",
"Financiamientos — Tasa Cero",
"WealthySmart trackea las compras BAC Tasa Cero como planes de cuotas mensuales: cada cuota futura ya cuenta en el presupuesto del mes que le toca, en lugar de registrar la compra como un solo pago.",
);
await doc.start();
await doc.step(
"Iniciar sesión",
"El acceso está protegido con usuario y contraseña; la sesión vive en una cookie HTTP-only.",
async () => {
await page.goto("/login");
// CopilotKit mounts its dev web-inspector in dev builds; keep it out of
// the recording — it isn't part of the product.
await page.addStyleTag({ content: "cpk-web-inspector { display: none !important; }" });
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
await expect(page.getByRole("button", { name: /sign in/i })).toBeEnabled();
},
);
await doc.step(
"Panel de inicio",
"Después del login se llega al dashboard Inicio, con el resumen del mes y la navegación lateral por módulos.",
async () => {
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/");
await expect(page.getByRole("link", { name: "Financiamientos", exact: true })).toBeVisible();
await page.waitForLoadState("networkidle");
},
);
await doc.step(
"Modo privacidad",
"El botón de privacidad difumina todos los montos sensibles — útil para compartir pantalla (y para este demo).",
async () => {
await page.getByRole("button", { name: "Toggle privacy mode" }).click();
await expect(page.locator("html")).toHaveClass(/privacy/);
},
);
await doc.step(
"Página de Financiamientos",
"El módulo resume todos los planes Tasa Cero: saldo faltante total, cuánto suman las cuotas de los planes activos cada mes, y cuántos planes siguen vivos.",
async () => {
await page.getByRole("link", { name: "Financiamientos", exact: true }).click();
await page.waitForURL("/financiamientos");
await expect(page.getByText("Saldo faltante total")).toBeVisible();
await expect(page.getByText("Cuotas por mes (activos)")).toBeVisible();
await page.waitForLoadState("networkidle");
},
);
await doc.step(
"Tabla de planes",
"Cada plan muestra el comercio, el avance de cuotas (facturadas vs. totales), el monto de la cuota, el saldo que falta y cuándo cae la próxima cuota.",
async () => {
const rows = page.getByRole("button", { name: /editar plan de/i });
await expect(rows.first()).toBeVisible();
expect(await rows.count()).toBeGreaterThan(0);
await expect(page.getByRole("columnheader", { name: "Próxima cuota" })).toBeVisible();
},
);
await doc.step(
"Detalle de un plan",
"Al hacer clic en un plan se abre su detalle editable: fecha ancla, número de cuotas y monto. Desde aquí también se puede deshacer la conversión a Tasa Cero.",
async () => {
await page.getByRole("button", { name: /editar plan de/i }).first().click();
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeVisible();
},
);
await doc.step(
"Cerrar el detalle",
"El plan queda igual que estaba — el recorrido es de solo lectura.",
async () => {
await page.keyboard.press("Escape");
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeHidden();
},
);
await doc.save();
});

59
frontend/e2e/narration.ts Normal file
View File

@@ -0,0 +1,59 @@
import { execFile, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
let sayChecked = false;
let sayAvailable = false;
function hasSay() {
if (!sayChecked) {
sayChecked = true;
try {
execFileSync("say", ["-v", "?"], { stdio: "ignore" });
sayAvailable = true;
} catch {
console.warn("`say` not found — skipping TTS narration (subtitles still work).");
}
}
return sayAvailable;
}
const cache = new Map<string, Promise<{ path: string; durationMs: number } | null>>();
/** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */
export function synthesize(
text: string,
outDir: string,
index: number,
voice = "Paulina",
): Promise<{ path: string; durationMs: number } | null> {
const key = `${voice}:${text}`;
const cached = cache.get(key);
if (cached) return cached;
const promise = (async () => {
if (!hasSay()) return null;
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`);
try {
await execFileAsync("say", ["-v", voice, "-o", file, text]);
const { stdout } = await execFileAsync("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "csv=p=0",
file,
]);
const durationMs = Math.round(parseFloat(stdout.trim()) * 1000);
return { path: file, durationMs };
} catch (err) {
console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`);
fs.rmSync(file, { force: true });
return null;
}
})();
cache.set(key, promise);
return promise;
}

33
frontend/e2e/subtitles.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface Clip {
title: string;
caption: string;
file: string;
startMs: number;
endMs: number;
narration: { path: string; durationMs: number } | null;
}
export function msToSrtTimestamp(ms: number): string {
const clamped = Math.max(0, Math.round(ms));
const hours = Math.floor(clamped / 3_600_000);
const minutes = Math.floor((clamped % 3_600_000) / 60_000);
const seconds = Math.floor((clamped % 60_000) / 1000);
const millis = clamped % 1000;
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
}
/** One subtitle cue per step, spanning its full on-screen duration. */
export function buildSrt(clips: Clip[]): string {
return clips
.map((clip, i) => {
const index = i + 1;
return [
String(index),
`${msToSrtTimestamp(clip.startMs)} --> ${msToSrtTimestamp(clip.endMs)}`,
clip.caption,
"",
].join("\n");
})
.join("\n");
}

View File

@@ -0,0 +1,88 @@
import { execFile, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { buildSrt, type Clip } from "./subtitles";
const execFileAsync = promisify(execFile);
function hasFfmpeg() {
try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const SUBTITLE_STYLE =
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
function escapeForFilter(p: string) {
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
}
export async function renderFinalVideo({
rawWebmPath,
clips,
outDir,
}: {
rawWebmPath: string;
clips: Clip[];
outDir: string;
}): Promise<{ videoFile: string; degraded: string[] }> {
const degraded: string[] = [];
const srtPath = path.join(outDir, "captions.srt");
fs.writeFileSync(srtPath, buildSrt(clips));
if (!hasFfmpeg()) {
const dest = path.join(outDir, "video.webm");
fs.renameSync(rawWebmPath, dest);
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
return { videoFile: dest, degraded };
}
const narrated = clips.filter((c) => c.narration);
const mp4Path = path.join(outDir, "video.mp4");
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
if (narrated.length === 0) {
degraded.push("No narration available — burned-in subtitles only.");
await execFileAsync("ffmpeg", [
"-y", "-i", rawWebmPath,
"-vf", srtArg,
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-an",
mp4Path,
]);
} else {
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
const adelayParts = narrated.map(
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
);
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
const filterComplex = [
`[0:v]${srtArg}[vout]`,
...adelayParts,
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
].join(";");
await execFileAsync("ffmpeg", [
"-y", "-i", rawWebmPath, ...audioInputs,
"-filter_complex", filterComplex,
"-map", "[vout]", "-map", "[aout]",
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
"-c:a", "aac",
"-shortest",
"-movflags", "+faststart",
mp4Path,
]);
}
fs.rmSync(rawWebmPath, { force: true });
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
return { videoFile: mp4Path, degraded };
}

View File

@@ -9,14 +9,16 @@
"build": "vite build", "build": "vite build",
"preview": "tsx server.ts", "preview": "tsx server.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"e2e": "playwright test",
"demo": "PW_DEMO=1 playwright test --grep @demo",
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts" "gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
}, },
"dependencies": { "dependencies": {
"@ag-ui/client": "0.0.52", "@ag-ui/client": "0.0.57",
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",
"@copilotkit/react-core": "1.56.4", "@copilotkit/react-core": "1.62.3",
"@copilotkit/react-ui": "1.56.4", "@copilotkit/react-ui": "1.62.3",
"@copilotkit/runtime": "1.56.4", "@copilotkit/runtime": "1.62.3",
"@fontsource-variable/ibm-plex-sans": "^5.2.8", "@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10", "@fontsource-variable/noto-sans": "^5.2.10",
"@hono/node-server": "^1.14.4", "@hono/node-server": "^1.14.4",
@@ -24,13 +26,16 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"date-fns": "^4.4.0",
"hono": "^4.12.15", "hono": "^4.12.15",
"lucide-react": "^1.12.0", "lucide-react": "^1.12.0",
"react": "19.2.5", "react": "19.2.5",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"recharts": "^3.8.1", "recharts": "^3.8.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
@@ -38,14 +43,15 @@
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4", "@tailwindcss/vite": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-oxc": "^0.4.3",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"vite": "^6.3.5" "vite": "npm:rolldown-vite@latest"
} }
} }

View File

@@ -0,0 +1,52 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "@playwright/test";
// Demo specs log in with the real dev credentials; pull them from the repo
// .env so they never live in the test source.
const repoEnv = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env");
if (fs.existsSync(repoEnv)) {
for (const line of fs.readFileSync(repoEnv, "utf8").split("\n")) {
const m = line.match(/^([A-Z_]+)=["']?(.*?)["']?$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
export default defineConfig({
testDir: "./e2e",
outputDir: "./test-results",
fullyParallel: false,
// Demo runs synthesize narration and run several ffmpeg passes in save();
// that can exceed the 30s default, especially with many steps.
timeout: process.env.PW_DEMO ? 120_000 : 30_000,
reporter: [
["list"],
["html", { outputFolder: "playwright-report", open: "never" }],
],
use: {
baseURL: "http://localhost:3000",
// Demo runs (pnpm demo) pace every action so the recorded video is
// watchable by a human; functional e2e runs stay at full speed.
launchOptions: {
slowMo: process.env.PW_DEMO ? 500 : 0,
},
locale: "es-CR",
timezoneId: "America/Costa_Rica",
viewport: { width: 1920, height: 1080 },
// Retina-density rendering so doc screenshots stay crisp when zoomed.
deviceScaleFactor: 2,
// Full recordings are only useful for the paced demo. Keeping them off
// for regular e2e avoids making test success depend on artifact uploads.
video: process.env.PW_DEMO ? { mode: "on", size: { width: 1920, height: 1080 } } : "off",
screenshot: "only-on-failure",
trace: process.env.PW_DEMO ? "on" : "on-first-retry",
},
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
stdout: "ignore",
timeout: 60_000,
},
});

3100
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,20 @@
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { import {
AgentRunner,
CopilotRuntime, CopilotRuntime,
ExperimentalEmptyAdapter, createCopilotRuntimeHandler,
copilotRuntimeNextJSAppRouterEndpoint, } from "@copilotkit/runtime/v2";
} from "@copilotkit/runtime";
import { HttpAgent, Middleware, EventType } from "@ag-ui/client"; import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
import { Observable } from "rxjs"; import { Observable, ReplaySubject } from "rxjs";
import { Hono } from "hono"; import { Hono } from "hono";
const AGENT_URL =
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
const BACKEND_URL = const BACKEND_URL =
process.env.BACKEND_URL ?? "http://localhost:8001"; process.env.BACKEND_URL ?? "http://localhost:8001";
// Prod sets AGENT_URL explicitly (compose network); the default derives from
// BACKEND_URL so local dev reaches the docker backend published on :8001.
const AGENT_URL =
process.env.AGENT_URL ?? `${BACKEND_URL}/api/v1/agent/agui`;
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env.NODE_ENV === "production";
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001")); const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
@@ -118,6 +120,27 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
const open = new Set<string>(); // tool call IDs currently in progress const open = new Set<string>(); // tool call IDs currently in progress
const closed = new Set<string>(); // tool call IDs that already received END const closed = new Set<string>(); // tool call IDs that already received END
// Render tools are display-once: after the client returns their result,
// the model sometimes re-issues the same call (fresh id) on the
// follow-up run, painting a second identical card. Track which render
// tools already ran in the current user turn (from the request's own
// message history) and suppress repeats — both their streamed events
// and their entries in this run's MESSAGES_SNAPSHOT.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : [];
let lastUserIdx = -1;
inputMessages.forEach((m, i) => { if (m?.role === "user") lastUserIdx = i; });
const renderedThisTurn = new Set<string>();
for (const m of inputMessages.slice(lastUserIdx + 1)) {
const calls = m?.toolCalls ?? m?.tool_calls;
if (m?.role !== "assistant" || !Array.isArray(calls)) continue;
for (const tc of calls) {
const name = tc?.function?.name;
if (name && RENDER_TOOLS.has(name)) renderedThisTurn.add(name);
}
}
const suppressed = new Set<string>(); // toolCallIds of dropped repeats
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => { return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -130,13 +153,51 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
if (type === EventType.TOOL_CALL_START) { if (type === EventType.TOOL_CALL_START) {
if (!id || closed.has(id) || open.has(id)) return; // duplicate if (!id || closed.has(id) || open.has(id)) return; // duplicate
const name: string | undefined = event?.toolCallName;
if (name && RENDER_TOOLS.has(name)) {
if (renderedThisTurn.has(name)) {
suppressed.add(id);
return; // repeat render call — drop the whole call
}
renderedThisTurn.add(name);
}
open.add(id); open.add(id);
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) { } else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
if (id && suppressed.has(id)) return;
if (id && closed.has(id)) return; // already completed, drop if (id && closed.has(id)) return; // already completed, drop
if (type === EventType.TOOL_CALL_END && id) { if (type === EventType.TOOL_CALL_END && id) {
open.delete(id); open.delete(id);
closed.add(id); closed.add(id);
} }
} else if (type === EventType.MESSAGES_SNAPSHOT && suppressed.size > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const cleaned = msgs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((m: any) => {
const calls = m?.toolCalls ?? m?.tool_calls;
if (m?.role !== "assistant" || !Array.isArray(calls)) return m;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const kept = calls.filter((tc: any) => !suppressed.has(String(tc?.id ?? "")));
if (kept.length === calls.length) return m;
const { toolCalls: _a, tool_calls: _b, ...rest } = m;
return kept.length > 0 ? { ...rest, toolCalls: kept } : rest;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((m: any) => {
if (m?.role === "tool" && suppressed.has(String(m?.toolCallId ?? m?.tool_call_id ?? ""))) {
return false;
}
if (m?.role === "assistant") {
const calls = m?.toolCalls ?? m?.tool_calls;
const hasCalls = Array.isArray(calls) && calls.length > 0;
const hasText = typeof m?.content === "string" && m.content.length > 0;
if (!hasCalls && !hasText) return false;
}
return true;
});
observer.next({ ...event, messages: cleaned });
return;
} }
observer.next(event); // emit raw BaseEvent observer.next(event); // emit raw BaseEvent
@@ -189,24 +250,25 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
} }
} }
// ── ReconcileSnapshotMiddleware ────────────────────────────────────────────── // ── DropRunMessagesSnapshotMiddleware ────────────────────────────────────────
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686) // MAF's run-end MESSAGES_SNAPSHOT re-mints ids for post-tool-call text (their
// mints a fresh UUID for the post-tool-call assistant text instead of reusing // #3619) and, with thread persistence on, rebuilds history under STORE ids.
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then // ag-ui's apply treats the snapshot as authoritative: client messages absent
// contains TWO assistant entries: the streamed id (holding the toolCalls) and // from it are dropped, matching ids are replaced. Because streamed ids and
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces // snapshot ids never agree for tool+text turns, every mid-session snapshot
// by id then APPENDS unknown ids, so the browser ends up with two assistant // either duplicates the current answer (fresh-id append) or wipes earlier
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe // ones (store-id replace with empty content) — both observed live. During a
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep // normal run the client already built the exact turn from streamed events,
// the assistant-with-toolCalls message in state past the run). The right fix // so the snapshot adds nothing: swallow it. Hydration replays (empty
// is to drop just the orphan text-only assistant message that has no streamed // input.messages) pass through untouched — there the snapshot IS the
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`. // conversation, and the client converges to store ids on every reload.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
class ReconcileSnapshotMiddleware extends (Middleware as any) { class DropRunMessagesSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> { run(input: any, next: any): Observable<any> {
const streamedTextIds = new Set<string>(); const isHydration =
!Array.isArray(input?.messages) || input.messages.length === 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => { return new Observable<any>((observer) => {
@@ -215,33 +277,9 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => { next: (eventWithState: any) => {
const event = eventWithState.event; const event = eventWithState.event;
const type: string = event?.type ?? ""; if (event?.type === EventType.MESSAGES_SNAPSHOT && !isHydration) {
return; // client state from streaming is already correct
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
streamedTextIds.add(String(event.messageId));
} }
if (type === EventType.MESSAGES_SNAPSHOT) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const filtered = msgs.filter((m) => {
if (m?.role !== "assistant") return true;
const id = String(m?.id ?? "");
const hasText = typeof m?.content === "string" && m.content.length > 0;
const hasToolCalls =
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
return false; // drop orphan text-only assistant duplicate
}
return true;
});
if (filtered.length !== msgs.length) {
observer.next({ ...event, messages: filtered });
return;
}
}
observer.next(event); observer.next(event);
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -259,7 +297,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards // below the card. This middleware buffers TEXT_MESSAGE_* events and discards
// them if any render tool call is detected in the same turn. // them if any render tool call is detected in the same turn.
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]); const RENDER_TOOLS = new Set(["render_spending_summary"]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
class SuppressRenderToolTextMiddleware extends (Middleware as any) { class SuppressRenderToolTextMiddleware extends (Middleware as any) {
@@ -345,61 +383,194 @@ app.use("*", async (c, next) => {
}); });
}); });
app.all("/api/copilotkit/*", async (c) => { // ── CopilotKit v2 runtime (module scope, built once) ─────────────────────────
const cookieHeader = c.req.header("cookie") ?? ""; // Migrated 2026-07-04 off the deprecated copilotRuntimeNextJSAppRouterEndpoint
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/); // wrapper (which was already the v2 single-route handler underneath) onto
const token = match?.[1]; // createCopilotRuntimeHandler directly, with a Postgres-backed AgentRunner:
const agentHeaders: Record<string, string> = token // run() is a pure proxy to the MAF backend (which persists every turn in
? { Authorization: `Bearer ${token}` } // chatthreadsnapshot), and connect() forwards to MAF's snapshot hydration —
: {}; // so the ONLY conversation store is the DB. This deletes the in-memory
// runner (whose process-local replay resurrected cleared conversations) and
// the client-side hydration hack that compensated for it.
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders }); // The v2 handler nags about anonymous telemetry unless disabled.
// eslint-disable-next-line @typescript-eslint/no-explicit-any process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
function makeWealthysmartAgent(headers: Record<string, string> = {}): HttpAgent {
const agent = new HttpAgent({ url: AGENT_URL, headers });
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
// workarounds are still needed after CopilotKit/MAF upgrades.
/* eslint-disable @typescript-eslint/no-explicit-any */
if (process.env.CK_MIDDLEWARES !== "off") {
agent.use(new SuppressRenderToolTextMiddleware() as any); agent.use(new SuppressRenderToolTextMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new StripModelArtifactsMiddleware() as any); agent.use(new StripModelArtifactsMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any agent.use(new DropRunMessagesSnapshotMiddleware() as any);
agent.use(new ReconcileSnapshotMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new DeduplicateToolCallMiddleware() as any); agent.use(new DeduplicateToolCallMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any }
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {
agent.use(new DebugLogMiddleware() as any); agent.use(new DebugLogMiddleware() as any);
}
/* eslint-enable @typescript-eslint/no-explicit-any */
return agent;
}
const runtime = new CopilotRuntime({ // ── PostgresAgentRunner ───────────────────────────────────────────────────────
agents: { wealthysmart: agent }, // The conversation store is Postgres (chatthreadsnapshot), written by MAF at
a2ui: { injectA2UITool: true }, // every run end. So the runner records NOTHING:
// - run() proxies the agent's event stream (the per-request clone the v2
// handler prepared, auth headers already forwarded onto it).
// - connect() asks MAF for its snapshot hydration — an empty-messages run
// returns RUN_STARTED → [STATE_SNAPSHOT] → MESSAGES_SNAPSHOT → RUN_FINISHED
// with no LLM call (unknown thread: RUN_STARTED → RUN_FINISHED). The same
// stream shape the in-memory runner's replay approximated, but sourced
// from the DB, so it is correct across clears, restarts and instances.
// - Live runs are tracked in-process only for stop(); the official
// sqlite-runner does the same (single-instance limitation acknowledged).
// isRunning() has no caller in the v2 SSE path (verified at 1.62.2).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const liveRuns = new Map<string, any>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function proxyAgentRun(agent: any, input: any, threadId: string): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subject = new ReplaySubject<any>(Infinity);
liveRuns.set(threadId, agent);
agent
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.runAgent(input, { onEvent: ({ event }: any) => subject.next(event) })
.then(() => subject.complete())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch((err: any) => subject.error(err))
.finally(() => {
if (liveRuns.get(threadId) === agent) liveRuns.delete(threadId);
});
return subject.asObservable();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class PostgresAgentRunner extends (AgentRunner as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(request: any): Observable<any> {
return proxyAgentRun(request.agent, request.input, request.threadId);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
connect(request: any): Observable<any> {
// request.headers = authorization + x-* from the incoming browser
// request (extractForwardableHeaders) — exactly what MAF needs.
const agent = makeWealthysmartAgent(request.headers ?? {});
agent.threadId = request.threadId;
// No messages + known threadId = MAF's hydration trigger.
return proxyAgentRun(agent, {}, `connect:${request.threadId}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isRunning(request: any): Promise<boolean> {
return Promise.resolve(liveRuns.has(request.threadId));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async stop(request: any): Promise<boolean | undefined> {
const agent = liveRuns.get(request.threadId);
if (!agent) return false;
try {
agent.abortRun();
liveRuns.delete(request.threadId);
return true;
} catch {
return false;
}
}
}
const runtime = new CopilotRuntime({
agents: { wealthysmart: makeWealthysmartAgent() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runner: new PostgresAgentRunner() as any,
// a2ui disabled 2026-07-04: CK 1.60+ validates generated ops against a
// catalog and our agent's ops fail validation (surface never paints).
// Markdown tables cover the use case until the pipeline matures.
a2ui: { enabled: false },
beforeRequestMiddleware: async ({ request: outbound }) => { beforeRequestMiddleware: async ({ request: outbound }) => {
if (outbound.method !== "POST") return; if (outbound.method !== "POST") return;
const ct = outbound.headers.get("content-type") ?? ""; const ct = outbound.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return; if (!ct.includes("application/json")) return;
try { try {
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] }; const body = (await outbound.clone().json()) as {
messages?: AGUIMessage[];
context?: { description?: string; value?: string }[];
};
if (!Array.isArray(body.messages)) return; if (!Array.isArray(body.messages)) return;
const paired = pairOrphanToolCalls(body.messages); // An empty messages array is MAF's hydration trigger (known threadId
if (paired.length === body.messages.length) return; // + no messages → replay the stored snapshot without invoking the
// LLM). Injecting anything would turn it into a normal run.
if (body.messages.length === 0) return;
let messages = pairOrphanToolCalls(body.messages);
// MAF's AG-UI endpoint declares `context` on its request model but
// never feeds it to the model (agent_framework_ag_ui, checked at
// python-1.10.0). CopilotKit 1.60+ ships the A2UI catalog schema and
// guidelines in that context — without them the model emits ops the
// A2UI validator rejects and the surface never paints. Inject the
// context as a system message ourselves. Remove once MAF consumes
// RunAgentInput.context.
if (Array.isArray(body.context) && body.context.length > 0) {
const contextText = body.context
.map((c) => `## ${c.description ?? "Context"}\n${c.value ?? ""}`)
.join("\n\n");
messages = [
{
id: `ctx-${Date.now()}`,
role: "system",
content: `Additional run context:\n\n${contextText}`,
} as AGUIMessage,
...messages,
];
}
if (messages === body.messages) return;
return new Request(outbound.url, { return new Request(outbound.url, {
method: outbound.method, method: outbound.method,
headers: outbound.headers, headers: outbound.headers,
body: JSON.stringify({ ...body, messages: paired }), body: JSON.stringify({ ...body, messages }),
}); });
} catch (err) { } catch (err) {
// Skipping the repair is survivable; doing it silently is not — // Skipping the repair is survivable; doing it silently is not —
// orphan tool calls then fail downstream with no trace of why. // orphan tool calls then fail downstream with no trace of why.
console.error("[copilotkit] orphan-tool-call repair skipped:", err); console.error("[copilotkit] outbound request repair skipped:", err);
return; return;
} }
}, },
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
});
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
}); });
const ckHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
// Single-route mode: same envelope protocol the client already resolved
// via auto-detect (its GET /info probe 405s here, so it stays "single").
// Multi-route would only add /threads* REST endpoints we don't use and is
// exposed to CopilotKit#4953 (frontend tools under multi-route).
mode: "single-route",
cors: false,
});
// The v2 handler forwards only `authorization` and `x-*` headers to agents
// and to runner.connect — cookies never pass. Translate the auth cookie into
// a Bearer header before the handler sees the request.
function withBearerFromCookie(req: Request): Request {
if (req.headers.get("authorization")) return req;
const cookie = req.headers.get("cookie") ?? "";
const match = cookie.match(/(?:^|;\s*)ws_token=([^;]+)/);
if (!match) return req;
const headers = new Headers(req.headers);
headers.set("Authorization", `Bearer ${match[1]}`);
return new Request(req, { headers });
}
app.all("/api/copilotkit/*", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
app.all("/api/copilotkit", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
app.get("/api/health", (c) => c.json({ ok: true })); app.get("/api/health", (c) => c.json({ ok: true }));
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly, // Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,

View File

@@ -77,7 +77,17 @@ export default function App() {
<PrivacyProvider> <PrivacyProvider>
<AuthProvider> <AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}> <CopilotKit
runtimeUrl="/api/copilotkit"
agent="wealthysmart"
headers={{
// Lets the agent resolve "hoy"/"este mes" in the user's
// local time wherever they are; backend falls back to
// Costa Rica when absent.
"X-Client-Timezone":
Intl.DateTimeFormat().resolvedOptions().timeZone,
}}
>
<AppRoutes /> <AppRoutes />
<Toaster richColors position="top-right" closeButton /> <Toaster richColors position="top-right" closeButton />
</CopilotKit> </CopilotKit>

View File

@@ -5,6 +5,7 @@ import { titleFor } from "@/lib/navigation";
import { useTheme } from "@/contexts/theme-context"; import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context"; import { usePrivacy } from "@/contexts/privacy-context";
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { CommandMenu } from "@/components/command-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Breadcrumb, Breadcrumb,
@@ -26,11 +27,14 @@ export default function Layout() {
const { privacyMode, togglePrivacy } = usePrivacy(); const { privacyMode, togglePrivacy } = usePrivacy();
const { pathname } = useLocation(); const { pathname } = useLocation();
const title = titleFor(pathname); const title = titleFor(pathname);
// Asistente hosts its own scroll container (the chat), so its route caps
// the shell at the viewport; every other page keeps normal body scroll.
const fullHeight = pathname === "/asistente";
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset className={fullHeight ? "h-svh overflow-hidden" : undefined}>
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm"> <header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator <Separator
@@ -59,6 +63,7 @@ export default function Layout() {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<CommandMenu />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -0,0 +1,177 @@
import { useState } from 'react';
import { toast } from 'sonner';
import api from '@/lib/api';
import { formatLocalDatetime } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
interface Props {
onClose: () => void;
onSaved: () => void;
}
export default function SalaryEntryDialog({ onClose, onSaved }: Props) {
const [form, setForm] = useState({
amount: '',
currency: 'CRC',
date: formatLocalDatetime(new Date()),
bank: 'BAC',
reference: '',
notes: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number(form.amount);
if (!Number.isFinite(amount) || amount <= 0) {
setError('Ingresá un monto mayor que cero.');
return;
}
setSaving(true);
setError('');
try {
await api.post('/transactions/', {
amount,
currency: form.currency,
date: form.date,
bank: form.bank,
merchant: 'Salario',
transaction_type: 'SALARY',
source: 'TRANSFER',
reference: form.reference.trim() || null,
notes: form.notes.trim() || null,
});
toast.success('Salario registrado');
onSaved();
onClose();
} catch (err) {
const status = err && typeof err === 'object' && 'response' in err
? (err as { response: { status: number } }).response.status
: null;
setError(
status === 409
? 'Ya existe una transacción con ese comprobante.'
: 'No se pudo registrar el salario. Intentá de nuevo.',
);
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Registrar salario</DialogTitle>
<DialogDescription>
Guardá el depósito recibido mientras configurás una nueva automatización.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="salary-amount">Monto</Label>
<Input
id="salary-amount"
type="number"
min="0.01"
step="0.01"
inputMode="decimal"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: e.target.value })}
placeholder="0.00"
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label>Moneda</Label>
<Select value={form.currency} onValueChange={(value) => value && setForm({ ...form, currency: value })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="CRC">CRC ()</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-2">
<Label htmlFor="salary-date">Fecha del depósito</Label>
<Input
id="salary-date"
type="datetime-local"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label>Banco</Label>
<Select value={form.bank} onValueChange={(value) => value && setForm({ ...form, bank: value })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="BAC">BAC</SelectItem>
<SelectItem value="BCR">BCR</SelectItem>
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="salary-reference">Comprobante</Label>
<Input
id="salary-reference"
value={form.reference}
onChange={(e) => setForm({ ...form, reference: e.target.value })}
placeholder="Opcional"
/>
</div>
<div className="col-span-2 space-y-2">
<Label htmlFor="salary-notes">Nota</Label>
<Input
id="salary-notes"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Ej. Quincena de julio"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Registrar salario'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,12 +1,11 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts'; import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api'; import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { categoricalColor, rampColor, MAX_SLICES } from '@/lib/charts';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { import {
ChartContainer, ChartContainer,
@@ -23,29 +22,6 @@ import {
Info, Info,
} from 'lucide-react'; } from 'lucide-react';
type PaletteMode = 'chatgpt' | 'gemini';
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
chatgpt: {
// Pure green scale, darkest → lightest (assigned by rank)
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
// Pure amber scale, darkest → lightest (assigned by rank)
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
// Warm-to-cool alternating for CC categories
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
},
gemini: {
// Qualitative greens: dark green, mint, pale green, forest
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
// Terracotta, slate blue, sage, sand — diverse hues
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
// Pastel/muted diverse for CC categories
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
},
};
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = { const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
CASH: { label: 'Efectivo', icon: Banknote }, CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight }, TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
@@ -91,16 +67,9 @@ function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof Tr
} }
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) { export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading || !detail) { if (loading || !detail) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" /> <PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" /> <PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
@@ -172,32 +141,20 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
); );
} }
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount })); const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount })); const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest) // Sequential ramps: one hue per concept, largest slice = full token,
// For Gemini mode: assign colors by position (qualitative) // smaller slices fade toward the surface (rank-ordered).
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> { function buildRampMap(data: { name: string; value: number }[], baseVar: string): Map<string, string> {
if (paletteMode === 'chatgpt') {
const sorted = [...data].sort((a, b) => b.value - a.value); const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>(); const map = new Map<string, string>();
sorted.forEach((item, i) => { sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
});
return map;
}
// Gemini: positional
const map = new Map<string, string>();
data.forEach((item, i) => {
map.set(item.name, colors[i % colors.length]);
});
return map; return map;
} }
const incomeColorMap = buildColorMap(incomeData, incomeColors); const incomeColorMap = buildRampMap(incomeData, 'var(--chart-1)');
const expenseColorMap = buildColorMap(expenseData, expenseColors); const expenseColorMap = buildRampMap(expenseData, 'var(--chart-2)');
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => { const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! }; acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
@@ -209,16 +166,28 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return acc; return acc;
}, {}); }, {});
// CC spending by category // CC by category: identity → categorical slots; past the 5th fold into "Otros".
const ccData = (detail.cc_by_category ?? []).map((item) => ({ const ccRaw = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name, name: item.category_name,
value: item.amount, value: item.amount,
})); }));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => { const ccData =
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] }; ccRaw.length <= MAX_SLICES
? ccRaw
: [
...ccRaw.slice(0, MAX_SLICES),
{
name: 'Otros',
value: ccRaw.slice(MAX_SLICES).reduce((s, c) => s + c.value, 0),
},
];
const ccColorOf = (name: string) =>
categoricalColor(ccData.findIndex((c) => c.name === name), name);
const ccConfig = ccData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: ccColorOf(item.name) };
return acc; return acc;
}, {}); }, {});
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0); const ccTotal = ccRaw.reduce((sum, item) => sum + item.value, 0);
// Filter actuals to only cash and transfer (no credit card) // Filter actuals to only cash and transfer (no credit card)
const cashTransferActuals = detail.actuals_by_source.filter( const cashTransferActuals = detail.actuals_by_source.filter(
@@ -227,27 +196,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Palette Toggle */}
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Button
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('chatgpt')}
>
ChatGPT
</Button>
<Button
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('gemini')}
>
Gemini
</Button>
</div>
{/* Pie Charts */} {/* Pie Charts */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */} {/* Income Pie */}
@@ -274,6 +222,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2} paddingAngle={2}
strokeWidth={2} strokeWidth={2}
stroke="var(--card)" stroke="var(--card)"
isAnimationActive={false}
> >
{incomeData.map((item, i) => ( {incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} /> <Cell key={i} fill={incomeColorMap.get(item.name)!} />
@@ -340,6 +289,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2} paddingAngle={2}
strokeWidth={2} strokeWidth={2}
stroke="var(--card)" stroke="var(--card)"
isAnimationActive={false}
> >
{expenseData.map((item, i) => ( {expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} /> <Cell key={i} fill={expenseColorMap.get(item.name)!} />
@@ -406,9 +356,10 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
paddingAngle={2} paddingAngle={2}
strokeWidth={2} strokeWidth={2}
stroke="var(--card)" stroke="var(--card)"
isAnimationActive={false}
> >
{ccData.map((_, i) => ( {ccData.map((item) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} /> <Cell key={item.name} fill={ccColorOf(item.name)} />
))} ))}
</Pie> </Pie>
<ChartTooltip <ChartTooltip
@@ -424,11 +375,11 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2"> <div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{ccData.map((item, i) => ( {ccData.map((item) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs"> <div key={item.name} className="flex items-center gap-1.5 text-xs">
<div <div
className="w-2 h-2 rounded-full shrink-0" className="w-2 h-2 rounded-full shrink-0"
style={{ background: ccColors[i % ccColors.length] }} style={{ background: ccColorOf(item.name) }}
/> />
<span className="truncate text-muted-foreground">{item.name}</span> <span className="truncate text-muted-foreground">{item.name}</span>
</div> </div>

View File

@@ -25,6 +25,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [ const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
{ value: 'INCOME', label: 'Ingreso' }, { value: 'INCOME', label: 'Ingreso' },
@@ -73,11 +74,13 @@ export default function RecurringItemDialog({
const snapshot = () => const snapshot = () =>
JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]); JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const handleOpenChange = (next: boolean) => { const handleOpenChange = (next: boolean) => {
if (!next && !saving && snapshot() !== baseline) { if (!next && !saving && snapshot() !== baseline) {
// Closing with unsaved edits — confirm before discarding (UX-06). // Closing with unsaved edits — confirm before discarding (UX-06).
const discard = window.confirm('Hay cambios sin guardar. ¿Descartarlos?'); setConfirmDiscard(true);
if (!discard) return; return;
} }
onOpenChange(next); onOpenChange(next);
}; };
@@ -338,6 +341,19 @@ export default function RecurringItemDialog({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{confirmDiscard && (
<ConfirmDialog
title="Cambios sin guardar"
message="Hay cambios sin guardar. ¿Descartarlos?"
confirmLabel="Descartar"
onConfirm={() => {
setConfirmDiscard(false);
onOpenChange(false);
}}
onCancel={() => setConfirmDiscard(false)}
/>
)}
</Dialog> </Dialog>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Pencil, Plus, Trash2 } from 'lucide-react'; import { Pencil, Plus, Trash2 } from 'lucide-react';
@@ -153,6 +154,8 @@ export default function RecurringItemsManager({
</Button> </Button>
</div> </div>
<Card>
<CardContent className="p-0">
<DataTable <DataTable
columns={columns} columns={columns}
data={items} data={items}
@@ -161,6 +164,8 @@ export default function RecurringItemsManager({
initialSorting={[{ id: 'item_type', desc: false }]} initialSorting={[{ id: 'item_type', desc: false }]}
emptyMessage="No hay items recurrentes." emptyMessage="No hay items recurrentes."
/> />
</CardContent>
</Card>
<RecurringItemDialog <RecurringItemDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -76,7 +76,10 @@ export function SpendingSummaryCard({
]; ];
return ( return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm"> <div
data-testid="spending-summary-card"
className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm"
>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Eye, EyeOff, Moon, Search, Sparkles, Sun } from "lucide-react";
import { navSections } from "@/lib/navigation";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
/** Global ⌘K palette: jump to any page, quick-toggle privacy/theme, or send
* what you typed straight to the Asistente (reuses the Inicio ask flow). */
export function CommandMenu() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((o) => !o);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const run = (fn: () => void) => {
setOpen(false);
setQuery("");
fn();
};
const ask = query.trim();
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => setOpen(true)}
className="hidden sm:flex w-44 justify-start gap-2 text-muted-foreground font-normal"
aria-label="Abrir buscador (Cmd+K)"
>
<Search className="w-3.5 h-3.5" aria-hidden />
Buscar
<Kbd className="ml-auto">K</Kbd>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setOpen(true)}
className="sm:hidden"
title="Buscar"
aria-label="Abrir buscador"
>
<Search className="w-4 h-4" aria-hidden />
</Button>
<CommandDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
title="Buscador"
description="Ir a una página o preguntarle al asistente"
>
<Command>
<CommandInput
placeholder="Ir a una página o preguntar…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>Sin resultados.</CommandEmpty>
{navSections.map((section) => (
<CommandGroup key={section.label} heading={section.label}>
{section.items.map(({ to, icon: Icon, label }) => (
<CommandItem
key={to}
value={`${section.label} ${label}`}
onSelect={() => run(() => navigate(to))}
>
<Icon aria-hidden />
{label}
</CommandItem>
))}
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading="Acciones">
<CommandItem
value="modo privado privacidad"
onSelect={() => run(togglePrivacy)}
>
{privacyMode ? <EyeOff aria-hidden /> : <Eye aria-hidden />}
{privacyMode ? "Desactivar modo privado" : "Activar modo privado"}
</CommandItem>
<CommandItem
value="tema oscuro claro"
onSelect={() => run(toggleTheme)}
>
{theme === "dark" ? <Sun aria-hidden /> : <Moon aria-hidden />}
{theme === "dark" ? "Tema claro" : "Tema oscuro"}
</CommandItem>
</CommandGroup>
{ask.length > 2 && (
<>
<CommandSeparator />
<CommandGroup heading="Asistente">
{/* value includes the query so cmdk never filters it out */}
<CommandItem
value={`preguntar ${ask}`}
onSelect={() =>
run(() => navigate("/asistente", { state: { ask } }))
}
>
<Sparkles className="text-primary" aria-hidden />
Preguntar: {ask}
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</CommandDialog>
</>
);
}

View File

@@ -0,0 +1,41 @@
import type { LucideIcon } from "lucide-react";
/** Standard page header: icon tile + title + optional subtitle on the left,
* page-level actions (period navigators, CSV, refresh…) on the right.
* Every page uses this — the breadcrumb in the shell header stays the only
* other place a page identifies itself. */
export function PageHeader({
icon: Icon,
title,
subtitle,
actions,
}: {
icon: LucideIcon;
title: string;
subtitle?: string;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-5" aria-hidden />
</div>
<div>
<h1
className="text-2xl font-bold tracking-tight"
style={{ fontFamily: "var(--font-heading)" }}
>
{title}
</h1>
{subtitle && (
<p className="text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Skeleton } from "@/components/ui/skeleton";
/** Stat-tile contract (dataviz skill): label · value · optional delta ·
* optional context/detail rows. Values are pre-formatted by the caller
* (formatAmount etc.) and blur under privacy mode via data-sensitive. */
export interface StatTileProps {
label: string;
/** Small line under the label, e.g. "Ciclo al 18 jul". */
description?: string;
/** Pre-formatted headline value. null renders an em dash. */
value: string | null;
loading?: boolean;
/** Signed, pre-formatted delta plus whether it's good news (colors it). */
delta?: { text: string; positive: boolean };
/** Muted line(s) or detail rows under the value. */
children?: React.ReactNode;
/** Makes the whole tile a link (adds hover border + focus ring). */
to?: string;
ariaLabel?: string;
/** Extra detail revealed on hover (HoverCard). */
hoverContent?: React.ReactNode;
/** Set false for non-monetary values that shouldn't blur (counts, dates). */
sensitive?: boolean;
className?: string;
}
export function StatTile({
label,
description,
value,
loading = false,
delta,
children,
to,
ariaLabel,
hoverContent,
sensitive = true,
className,
}: StatTileProps) {
const card = (
<Card
className={cn(
"h-full",
to && "transition-colors hover:border-primary/40 cursor-pointer",
className,
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{description && (
<p className="text-xs text-muted-foreground/80">{description}</p>
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline gap-2">
{loading ? (
<Skeleton className="h-8 w-32" />
) : (
<span
{...(sensitive ? { "data-sensitive": true } : {})}
className="text-2xl font-bold font-mono"
>
{value ?? "—"}
</span>
)}
{!loading && delta && (
<span
data-sensitive
className={cn(
"text-xs font-mono font-medium",
delta.positive ? "text-primary" : "text-destructive",
)}
>
{delta.text}
</span>
)}
</div>
{children}
</CardContent>
</Card>
);
const wrapped = to ? (
<Link
to={to}
aria-label={ariaLabel ?? label}
className="block h-full rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{card}
</Link>
) : (
card
);
if (!hoverContent) return wrapped;
return (
<HoverCard>
<HoverCardTrigger render={<div className="h-full" />}>
{wrapped}
</HoverCardTrigger>
<HoverCardContent className="w-80">{hoverContent}</HoverCardContent>
</HoverCard>
);
}

View File

@@ -20,6 +20,7 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import ConfirmDialog from '@/components/ConfirmDialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
/** Mirror of the backend cuota split (services/installments.py): truncate to /** Mirror of the backend cuota split (services/installments.py): truncate to
@@ -92,6 +93,7 @@ export default function ConvertToInstallmentsDialog({
: tx?.date ?? new Date().toISOString(); : tx?.date ?? new Date().toISOString();
const [numStr, setNumStr] = useState('3'); const [numStr, setNumStr] = useState('3');
const [confirmUnconvert, setConfirmUnconvert] = useState(false);
const [firstDate, setFirstDate] = useState(''); const [firstDate, setFirstDate] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -142,10 +144,7 @@ export default function ConvertToInstallmentsDialog({
const handleUnconvert = async () => { const handleUnconvert = async () => {
if (!effectivePlan) return; if (!effectivePlan) return;
const confirmed = window.confirm( setConfirmUnconvert(false);
'Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. ¿Continuar?',
);
if (!confirmed) return;
setSaving(true); setSaving(true);
try { try {
await deleteInstallmentPlan(effectivePlan.id); await deleteInstallmentPlan(effectivePlan.id);
@@ -248,7 +247,7 @@ export default function ConvertToInstallmentsDialog({
<Button <Button
variant="outline" variant="outline"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
onClick={handleUnconvert} onClick={() => setConfirmUnconvert(true)}
disabled={saving} disabled={saving}
> >
Deshacer Tasa Cero Deshacer Tasa Cero
@@ -273,6 +272,17 @@ export default function ConvertToInstallmentsDialog({
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{confirmUnconvert && (
<ConfirmDialog
title="Deshacer Tasa Cero"
message="Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. Esta acción no se puede deshacer."
confirmLabel="Deshacer plan"
loading={saving}
onConfirm={handleUnconvert}
onCancel={() => setConfirmUnconvert(false)}
/>
)}
</Dialog> </Dialog>
); );
} }

View File

@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,194 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View File

@@ -0,0 +1,236 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,49 @@
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "@/lib/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,201 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,81 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -28,11 +28,11 @@
--border: oklch(0.92 0.004 286.32); --border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067); --ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.55 0.16 145); --chart-1: oklch(0.55 0.15 187);
--chart-2: oklch(0.62 0.19 25); --chart-2: oklch(0.62 0.14 78);
--chart-3: oklch(0.58 0.14 250); --chart-3: oklch(0.50 0.14 300);
--chart-4: oklch(0.68 0.15 80); --chart-4: oklch(0.56 0.18 27);
--chart-5: oklch(0.52 0.13 320); --chart-5: oklch(0.55 0.13 252);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.141 0.005 285.823);
@@ -65,11 +65,11 @@
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938); --ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.60 0.16 145); --chart-1: oklch(0.62 0.11 187);
--chart-2: oklch(0.67 0.19 25); --chart-2: oklch(0.66 0.13 78);
--chart-3: oklch(0.63 0.14 250); --chart-3: oklch(0.60 0.14 300);
--chart-4: oklch(0.73 0.15 80); --chart-4: oklch(0.64 0.16 27);
--chart-5: oklch(0.57 0.13 320); --chart-5: oklch(0.64 0.13 252);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503); --sidebar-primary: oklch(0.704 0.14 182.503);

View File

@@ -345,6 +345,23 @@ export interface paths {
patch: operations["update_category_api_v1_categories__category_id__patch"]; patch: operations["update_category_api_v1_categories__category_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/chat/thread": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Clear Thread */
delete: operations["clear_thread_api_v1_chat_thread_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/exchange-rate/": { "/api/v1/exchange-rate/": {
parameters: { parameters: {
query?: never; query?: never;
@@ -898,11 +915,9 @@ export interface components {
AGUIRequest: { AGUIRequest: {
/** /**
* Availableinterrupts * Availableinterrupts
* @description List of interrupts that can be resumed by the server * @description Canonical AG-UI interrupts that can be resumed by the server
*/ */
availableInterrupts?: { availableInterrupts?: components["schemas"]["Interrupt"][] | null;
[key: string]: unknown;
}[] | null;
/** /**
* Context * Context
* @description List of context objects provided to the agent * @description List of context objects provided to the agent
@@ -931,11 +946,9 @@ export interface components {
parent_run_id?: string | null; parent_run_id?: string | null;
/** /**
* Resume * Resume
* @description Resume payload containing interrupt responses * @description Resume payload for continuing interrupted runs
*/ */
resume?: { resume?: components["schemas"]["ResumeEntry"][] | null;
[key: string]: unknown;
} | null;
/** /**
* Run Id * Run Id
* @description Optional run identifier for tracking * @description Optional run identifier for tracking
@@ -1200,6 +1213,11 @@ export interface components {
/** Name */ /** Name */
name?: string | null; name?: string | null;
}; };
/** ClearThreadResponse */
ClearThreadResponse: {
/** Cleared */
cleared: boolean;
};
/** /**
* Currency * Currency
* @enum {string} * @enum {string}
@@ -1351,6 +1369,34 @@ export interface components {
/** Num Installments */ /** Num Installments */
num_installments?: number | null; num_installments?: number | null;
}; };
/**
* Interrupt
* @description A pause carried inside ``RunFinishedEvent.outcome`` when the outcome is
* ``RunFinishedInterruptOutcome``. The client resumes
* by addressing this interrupt in the resume array of the next RunAgentInput.
*/
Interrupt: {
/** Expiresat */
expiresAt?: string | null;
/** Id */
id: string;
/** Message */
message?: string | null;
/** Metadata */
metadata?: {
[key: string]: unknown;
} | null;
/** Reason */
reason: string;
/** Responseschema */
responseSchema?: {
[key: string]: unknown;
} | null;
/** Toolcallid */
toolCallId?: string | null;
} & {
[key: string]: unknown;
};
/** LoginRequest */ /** LoginRequest */
LoginRequest: { LoginRequest: {
/** Password */ /** Password */
@@ -1809,6 +1855,23 @@ export interface components {
[key: string]: unknown; [key: string]: unknown;
} | null; } | null;
}; };
/**
* ResumeEntry
* @description A per-interrupt response in the resume array of a RunAgentInput.
*/
ResumeEntry: {
/** Interruptid */
interruptId: string;
/** Payload */
payload?: unknown | null;
/**
* Status
* @enum {string}
*/
status: "resolved" | "cancelled";
} & {
[key: string]: unknown;
};
/** SalariosSummary */ /** SalariosSummary */
SalariosSummary: { SalariosSummary: {
/** Count */ /** Count */
@@ -2419,6 +2482,8 @@ export interface operations {
query?: { query?: {
cycle_year?: number | null; cycle_year?: number | null;
cycle_month?: number | null; cycle_month?: number | null;
start_date?: string | null;
end_date?: string | null;
}; };
header?: { header?: {
authorization?: string | null; authorization?: string | null;
@@ -2455,6 +2520,8 @@ export interface operations {
query?: { query?: {
cycle_year?: number | null; cycle_year?: number | null;
cycle_month?: number | null; cycle_month?: number | null;
start_date?: string | null;
end_date?: string | null;
}; };
header?: { header?: {
authorization?: string | null; authorization?: string | null;
@@ -3019,6 +3086,39 @@ export interface operations {
}; };
}; };
}; };
clear_thread_api_v1_chat_thread_delete: {
parameters: {
query?: never;
header?: {
authorization?: string | null;
};
path?: never;
cookie?: {
ws_token?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClearThreadResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
current_rate_api_v1_exchange_rate__get: { current_rate_api_v1_exchange_rate__get: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -0,0 +1,32 @@
/** Shared chart color system (see dataviz standard).
*
* - Categorical (identity): the five validated --chart-N slots, assigned in
* fixed order, NEVER cycled — anything past the 5th folds into "Otros".
* - Sequential (magnitude within one concept, e.g. income sources ranked by
* size): one hue ramped toward the surface via color-mix, which stays
* correct in both light and dark mode.
*/
export const SERIES_VARS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
] as const;
export const OTROS_COLOR = "var(--muted-foreground)";
export const MAX_SLICES = SERIES_VARS.length;
export function categoricalColor(index: number, name?: string): string {
if (name === "Otros" || name === "Sin categoría") return OTROS_COLOR;
return SERIES_VARS[Math.min(index, SERIES_VARS.length - 1)];
}
/** Rank-ordered shade of one hue: rank 0 (largest) is the full token, later
* ranks fade toward the surface but never below 35% so they keep chroma. */
export function rampColor(baseVar: string, rank: number, count: number): string {
if (count <= 1 || rank <= 0) return baseVar;
const pct = Math.round(100 - (rank / Math.max(count - 1, 1)) * 65);
return `color-mix(in oklab, ${baseVar} ${pct}%, var(--background))`;
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { type DateRange } from 'react-day-picker';
import { import {
PieChart, PieChart,
Pie, Pie,
@@ -11,14 +12,32 @@ import {
LineChart, LineChart,
Line, Line,
} from 'recharts'; } from 'recharts';
import { BarChart3 } from 'lucide-react'; import { BarChart3, CalendarRange, ChartPie, X } from 'lucide-react';
import api, { getRateHistory } from '@/lib/api'; import api, { getRateHistory } from '@/lib/api';
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import BillingCycleSelector from '@/components/BillingCycleSelector'; import BillingCycleSelector from '@/components/BillingCycleSelector';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { import {
ChartContainer, ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
type ChartConfig, type ChartConfig,
@@ -47,45 +66,78 @@ interface DailySpending {
count: number; count: number;
} }
const COLORS = [
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
];
function formatCRC(value: number) { function formatCRC(value: number) {
return `${Math.round(value).toLocaleString('es-CR')}`; return `${Math.round(value).toLocaleString('es-CR')}`;
} }
const trendChartConfig = { /** Top 5 categories keep their own slice; the rest aggregate into "Otros". */
total_crc: { function foldCategories(cats: CategorySpending[]) {
label: 'Total CRC', if (cats.length <= MAX_SLICES) return cats;
color: 'var(--chart-1)', const head = cats.slice(0, MAX_SLICES);
const rest = cats.slice(MAX_SLICES);
return [
...head,
{
category_id: null,
category_name: 'Otros',
total: rest.reduce((s, c) => s + c.total, 0),
count: rest.reduce((s, c) => s + c.count, 0),
percentage: +rest.reduce((s, c) => s + c.percentage, 0).toFixed(1),
}, },
];
}
function ChartEmpty({ description }: { description: string }) {
return (
<Empty className="h-56">
<EmptyHeader>
<EmptyMedia variant="icon">
<ChartPie />
</EmptyMedia>
<EmptyTitle>Sin datos</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
const trendChartConfig = {
total_crc: { label: 'Total CRC', color: 'var(--chart-1)' },
} satisfies ChartConfig; } satisfies ChartConfig;
const rateChartConfig = { const rateChartConfig = {
sell_rate: { label: 'Venta', color: 'var(--chart-1)' }, sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
buy_rate: { label: 'Compra', color: 'var(--chart-2)' }, buy_rate: { label: 'Compra', color: 'var(--chart-5)' },
} satisfies ChartConfig; } satisfies ChartConfig;
const dailyChartConfig = { const dailyChartConfig = {
total: { total: { label: 'Gasto diario', color: 'var(--chart-1)' },
label: 'Gasto Diario',
color: 'var(--chart-2)',
},
} satisfies ChartConfig; } satisfies ChartConfig;
export default function Analytics() { export default function Analytics() {
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null); const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const cycleParams: Record<string, string> = cycle const [range, setRange] = useState<DateRange | undefined>();
const toISODate = (d: Date) => d.toISOString().slice(0, 10);
const nextDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
const rangeActive = !!(range?.from && range?.to);
// Range takes precedence over the billing-cycle filter (end is exclusive).
const periodParams: Record<string, string> = rangeActive
? {
start_date: toISODate(range!.from!),
end_date: toISODate(nextDay(range!.to!)),
}
: cycle
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) } ? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
: {}; : {};
const byCategoryQ = useQuery({ const byCategoryQ = useQuery({
queryKey: ['analytics', 'by-category', cycle], queryKey: ['analytics', 'by-category', cycle, range],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
api api
.get<CategorySpending[]>('/analytics/by-category', { params: cycleParams, signal }) .get<CategorySpending[]>('/analytics/by-category', { params: periodParams, signal })
.then((r) => r.data), .then((r) => r.data),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
}); });
@@ -97,15 +149,16 @@ export default function Analytics() {
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data), api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
}); });
const dailyQ = useQuery({ const dailyQ = useQuery({
queryKey: ['analytics', 'daily', cycle], queryKey: ['analytics', 'daily', cycle, range],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
api api
.get<DailySpending[]>('/analytics/daily-spending', { params: cycleParams, signal }) .get<DailySpending[]>('/analytics/daily-spending', { params: periodParams, signal })
.then((r) => r.data), .then((r) => r.data),
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
}); });
const byCategory = byCategoryQ.data ?? []; const byCategory = byCategoryQ.data ?? [];
const folded = foldCategories(byCategory);
const trend = trendQ.data ?? []; const trend = trendQ.data ?? [];
const daily = dailyQ.data ?? []; const daily = dailyQ.data ?? [];
const ratesQ = useQuery({ const ratesQ = useQuery({
@@ -125,27 +178,70 @@ export default function Analytics() {
dailyQ.refetch(); dailyQ.refetch();
}; };
// Build dynamic chart config for pie chart const pieChartConfig = folded.reduce<ChartConfig>((acc, cat, i) => {
const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
acc[cat.category_name] = { acc[cat.category_name] = {
label: cat.category_name, label: cat.category_name,
color: COLORS[i % COLORS.length], color: categoricalColor(i, cat.category_name),
}; };
return acc; return acc;
}, {}); }, {});
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <PageHeader
<div> icon={BarChart3}
<div className="flex items-center gap-2"> title="Analytics"
<BarChart3 className="w-5 h-5 text-primary" /> subtitle="Desglose y tendencias de gasto"
<h1 className="text-2xl font-bold font-heading">Analytics</h1> actions={
</div> <>
<p className="text-sm text-muted-foreground mt-1">Desglose y tendencias de gasto</p> <Popover>
</div> <PopoverTrigger
<BillingCycleSelector value={cycle} onChange={setCycle} /> render={
</div> <Button
variant="outline"
size="sm"
className={rangeActive ? 'text-foreground' : 'text-muted-foreground'}
/>
}
>
<CalendarRange className="w-4 h-4 mr-2" aria-hidden />
{rangeActive
? `${range!.from!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })} ${range!.to!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })}`
: 'Rango'}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
selected={range}
onSelect={(r) => {
setRange(r);
if (r?.from && r?.to) setCycle(null);
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
{rangeActive && (
<Button
variant="ghost"
size="icon"
onClick={() => setRange(undefined)}
title="Quitar rango"
aria-label="Quitar rango de fechas"
>
<X className="w-4 h-4" aria-hidden />
</Button>
)}
<BillingCycleSelector
value={cycle}
onChange={(c) => {
setCycle(c);
if (c) setRange(undefined);
}}
/>
</>
}
/>
{anyError && ( {anyError && (
<ErrorState <ErrorState
@@ -155,24 +251,22 @@ export default function Analytics() {
)} )}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Spending by Category - Donut */} {/* Gasto por categoría — donut */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Spending by Category Gasto por categoría
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{byCategory.length === 0 ? ( {folded.length === 0 ? (
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Sin gastos en este período." />
Sin datos para este período
</div>
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full"> <ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
<PieChart> <PieChart>
<Pie <Pie
data={byCategory} data={folded}
dataKey="total" dataKey="total"
nameKey="category_name" nameKey="category_name"
cx="50%" cx="50%"
@@ -180,10 +274,12 @@ export default function Analytics() {
innerRadius={60} innerRadius={60}
outerRadius={100} outerRadius={100}
paddingAngle={2} paddingAngle={2}
strokeWidth={0} strokeWidth={2}
stroke="var(--background)"
isAnimationActive={false}
> >
{byCategory.map((_, i) => ( {folded.map((cat, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} /> <Cell key={cat.category_name} fill={categoricalColor(i, cat.category_name)} />
))} ))}
</Pie> </Pie>
<ChartTooltip <ChartTooltip
@@ -198,11 +294,11 @@ export default function Analytics() {
{/* Legend */} {/* Legend */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md"> <div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
{byCategory.slice(0, 10).map((cat, i) => ( {folded.map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-2 text-xs"> <div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div <div
className="w-2.5 h-2.5 rounded-full flex-shrink-0" className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }} style={{ background: categoricalColor(i, cat.category_name) }}
/> />
<span className="text-muted-foreground truncate">{cat.category_name}</span> <span className="text-muted-foreground truncate">{cat.category_name}</span>
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span> <span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
@@ -214,26 +310,20 @@ export default function Analytics() {
</CardContent> </CardContent>
</Card> </Card>
{/* Monthly Trend - Bar */} {/* Gasto mensual — barras por ciclo */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Monthly Spending (CRC) Gasto mensual por ciclo (CRC)
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{trend.length === 0 ? ( {trend.length === 0 ? (
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Todavía no hay ciclos con datos." />
No data
</div>
) : ( ) : (
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full"> <ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
<BarChart data={trend}> <BarChart data={trend}>
<XAxis <XAxis dataKey="label" axisLine={false} tickLine={false} />
dataKey="label"
axisLine={false}
tickLine={false}
/>
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -246,36 +336,39 @@ export default function Analytics() {
/> />
} }
/> />
<Bar dataKey="total_crc" fill="var(--color-total_crc)" radius={[4, 4, 0, 0]} /> <Bar
dataKey="total_crc"
fill="var(--color-total_crc)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Daily Spending - Line */} {/* Gasto diario — barras */}
<Card className="lg:col-span-2"> <Card className="lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Daily Spending Gasto diario
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{daily.length === 0 ? ( {daily.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Sin gastos en este período." />
Sin datos para este período
</div>
) : ( ) : (
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full"> <ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
<LineChart data={daily}> <BarChart data={daily}>
<XAxis <XAxis
dataKey="date" dataKey="date"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tickFormatter={(v) => { minTickGap={24}
const d = new Date(v); tickFormatter={(v) =>
return `${d.getMonth() + 1}/${d.getDate()}`; new Date(v).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })
}} }
/> />
<YAxis <YAxis
axisLine={false} axisLine={false}
@@ -292,36 +385,34 @@ export default function Analytics() {
/> />
} }
/> />
<Line <Bar
type="monotone"
dataKey="total" dataKey="total"
stroke="var(--color-total)" fill="var(--color-total)"
strokeWidth={2} radius={[2, 2, 0, 0]}
dot={{ fill: 'var(--color-total)', r: 3 }} isAnimationActive={false}
activeDot={{ r: 5 }}
/> />
</LineChart> </BarChart>
</ChartContainer> </ChartContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Top categories summary */} {/* Categorías principales */}
{byCategory.length > 0 && ( {byCategory.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Top Categories Categorías principales
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{byCategory.slice(0, 8).map((cat, i) => ( {folded.map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-3"> <div key={cat.category_name} className="flex items-center gap-3">
<div <div
className="w-3 h-3 rounded-full flex-shrink-0" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }} style={{ background: categoricalColor(i, cat.category_name) }}
/> />
<span className="text-sm flex-1">{cat.category_name}</span> <span className="text-sm flex-1">{cat.category_name}</span>
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span> <span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
@@ -332,8 +423,8 @@ export default function Analytics() {
<div <div
className="h-1.5 rounded-full" className="h-1.5 rounded-full"
style={{ style={{
width: `${cat.percentage}%`, width: `${Math.min(100, cat.percentage)}%`,
background: COLORS[i % COLORS.length], background: categoricalColor(i, cat.category_name),
}} }}
/> />
</div> </div>
@@ -343,28 +434,25 @@ export default function Analytics() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Exchange rate history */}
{/* Tipo de cambio */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Tipo de Cambio USD/CRC (90 días) Tipo de cambio USD/CRC (90 días)
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{(ratesQ.data?.length ?? 0) < 2 ? ( {(ratesQ.data?.length ?? 0) < 2 ? (
<div className="h-40 flex items-center justify-center text-muted-foreground text-sm"> <ChartEmpty description="Sin historial suficiente." />
Sin historial suficiente
</div>
) : ( ) : (
<ChartContainer <ChartContainer config={rateChartConfig} className="h-[200px] w-full">
config={rateChartConfig}
className="h-[200px] w-full"
>
<LineChart data={ratesQ.data}> <LineChart data={ratesQ.data}>
<XAxis <XAxis
dataKey="day" dataKey="day"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
minTickGap={24}
tickFormatter={(v) => tickFormatter={(v) =>
new Date(v).toLocaleDateString('es-CR', { new Date(v).toLocaleDateString('es-CR', {
month: 'short', month: 'short',
@@ -385,19 +473,22 @@ export default function Analytics() {
/> />
} }
/> />
<ChartLegend content={<ChartLegendContent />} />
<Line <Line
type="monotone" type="monotone"
dataKey="sell_rate" dataKey="sell_rate"
stroke="var(--chart-1)" stroke="var(--color-sell_rate)"
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
isAnimationActive={false}
/> />
<Line <Line
type="monotone" type="monotone"
dataKey="buy_rate" dataKey="buy_rate"
stroke="var(--chart-2)" stroke="var(--color-buy_rate)"
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
isAnimationActive={false}
/> />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { import {
CopilotChat, CopilotChat,
@@ -7,9 +7,18 @@ import {
useCopilotKit, useCopilotKit,
} from "@copilotkit/react-core/v2"; } from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core"; import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react"; import { MessageSquarePlus, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { PageHeader } from '@/components/page-header';
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards"; import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
// Single persistent conversation: the backend snapshot store keys on this
// same id (backend/app/api/v1/endpoints/chat.py). An explicit, stable
// threadId also stops CopilotChat from wiping messages on remount.
const CHAT_THREAD_ID = "main";
const STATIC_SUGGESTIONS = { const STATIC_SUGGESTIONS = {
available: "before-first-message" as const, available: "before-first-message" as const,
suggestions: [ suggestions: [
@@ -23,28 +32,74 @@ const STATIC_SUGGESTIONS = {
export default function Asistente() { export default function Asistente() {
useConfigureSuggestions(STATIC_SUGGESTIONS); useConfigureSuggestions(STATIC_SUGGESTIONS);
// A question typed on the Inicio dashboard arrives via router state:
// append it as a user message and run the agent once.
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { agent } = useAgent({ agentId: "wealthysmart" }); const { agent } = useAgent({ agentId: "wealthysmart" });
const { copilotkit } = useCopilotKit(); const { copilotkit } = useCopilotKit();
const sentRef = useRef(false); const [confirmClear, setConfirmClear] = useState(false);
const [clearing, setClearing] = useState(false);
// Ask-box flow: a question typed on Inicio arrives via router state.
// History hydration is no longer done here — CopilotChat's mount connect
// replays the persisted thread through the BFF's PostgresAgentRunner. We
// still await our own connect before sending so the run's input carries
// the full client-known history (idempotent with CopilotChat's connect:
// the MESSAGES_SNAPSHOT merge is id-based).
const bootRef = useRef(false);
useEffect(() => { useEffect(() => {
const ask = (location.state as { ask?: string } | null)?.ask; const ask = (location.state as { ask?: string } | null)?.ask;
if (!ask || sentRef.current || !agent) return; if (!ask || !agent || bootRef.current) return;
sentRef.current = true; bootRef.current = true;
// Clear the state so back/refresh doesn't re-send the question. // Clear the state so back/refresh doesn't re-send the question.
navigate(location.pathname, { replace: true, state: null }); navigate(location.pathname, { replace: true, state: null });
void (async () => {
agent.threadId = CHAT_THREAD_ID;
if (agent.messages.length === 0) {
try {
await copilotkit.connectAgent({ agent });
} catch (err) {
console.error("[asistente] connect before ask failed:", err);
}
}
agent.addMessage({ agent.addMessage({
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: "user", role: "user",
content: ask, content: ask,
}); });
void copilotkit.runAgent({ agent }); void copilotkit.runAgent({ agent });
})();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]); }, [agent]);
const clearThread = async () => {
setClearing(true);
try {
const res = await fetch("/api/v1/chat/thread", {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
throw new Error(`DELETE /chat/thread → ${res.status}`);
}
// NOTE: there is no official flush for the runtime's in-memory thread
// store at CK 1.62 — the single-route endpoint we mount only accepts
// agent/run|connect|stop, info, transcribe; the threads/* router
// (incl. threads/clear) is a separate entrypoint this adapter doesn't
// serve. The BFF's connect intercept (server.ts) is what keeps that
// process memory from ever reaching the UI.
// Full reload is the reliable reset: CopilotKit keeps chat state in
// more places than agent.messages (clearing it in place left the
// conversation rendered), while a fresh boot hydrates from the
// now-empty store and shows the welcome screen + suggestions.
window.location.reload();
} catch (err) {
console.error("[asistente] clear thread failed:", err);
toast.error("No se pudo borrar la conversación. Intenta de nuevo.");
setClearing(false);
setConfirmClear(false);
}
};
useCopilotAction({ useCopilotAction({
name: "render_spending_summary", name: "render_spending_summary",
description: description:
@@ -87,18 +142,47 @@ export default function Asistente() {
// max-w wrapper are all min-h-0 flex columns) — no vh math. // max-w wrapper are all min-h-0 flex columns) — no vh math.
<div className="flex flex-1 min-h-0 flex-col"> <div className="flex flex-1 min-h-0 flex-col">
<div className="mb-4"> <div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}> <PageHeader
<Sparkles className="w-5 h-5 text-primary" /> icon={Sparkles}
Asistente title="Asistente"
</h1> subtitle="Pregúntale a WealthySmart sobre tus finanzas."
<p className="text-sm text-muted-foreground"> actions={
Pregúntale a WealthySmart sobre tus finanzas. <Button
</p> variant="outline"
size="sm"
onClick={() => setConfirmClear(true)}
// Also disabled mid-run: MAF saves the thread at run END, so
// clearing while an answer streams would delete the row only
// to have the finishing run write it right back.
disabled={!agent || agent.messages.length === 0 || agent.isRunning}
>
<MessageSquarePlus />
Nueva conversación
</Button>
}
/>
</div> </div>
{confirmClear && (
<ConfirmDialog
title="¿Nueva conversación?"
message="Se borrará el historial del chat guardado. Esta acción no se puede deshacer."
confirmLabel="Borrar historial"
onConfirm={clearThread}
onCancel={() => setConfirmClear(false)}
loading={clearing}
/>
)}
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card"> <div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat <CopilotChat
className="h-full" className="h-full"
// Without an explicit agentId, CopilotChat resolves DEFAULT_AGENT_ID
// ("default") — a DIFFERENT client agent instance than
// useAgent({agentId:"wealthysmart"}); clearing one leaves the other
// rendering its own copy of the conversation.
agentId="wealthysmart"
threadId={CHAT_THREAD_ID}
labels={{ labels={{
modalHeaderTitle: "WealthySmart", modalHeaderTitle: "WealthySmart",
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?", welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",

View File

@@ -12,6 +12,7 @@ import {
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator'; import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail'; import MonthlyDetail from '@/components/budget/MonthlyDetail';
@@ -128,13 +129,11 @@ export default function Budget() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{cameFromProyecciones && ( {cameFromProyecciones && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mr-4 text-muted-foreground" className="-mb-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')} onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones" aria-label="Volver a Proyecciones"
> >
@@ -142,9 +141,11 @@ export default function Budget() {
Proyecciones Proyecciones
</Button> </Button>
)} )}
<Calculator className="w-6 h-6 text-primary" /> <PageHeader
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1> icon={Calculator}
</div> title="Presupuesto"
subtitle="Proyección y transacciones del mes"
actions={
<PeriodNavigator <PeriodNavigator
label={String(year)} label={String(year)}
onPrev={() => setYear(year - 1)} onPrev={() => setYear(year - 1)}
@@ -152,7 +153,8 @@ export default function Budget() {
prevDisabled={year <= MIN_YEAR} prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR} nextDisabled={year >= MAX_YEAR}
/> />
</div> }
/>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
<TabsList> <TabsList>

View File

@@ -9,6 +9,7 @@ import {
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Table, Table,
@@ -19,6 +20,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog'; import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
function formatDate(iso: string) { function formatDate(iso: string) {
@@ -29,8 +31,20 @@ function formatDate(iso: string) {
}); });
} }
function progressLabel(plan: InstallmentPlan) { function CuotasProgress({ plan }: { plan: InstallmentPlan }) {
return `${String(plan.cuotas_billed).padStart(3, '0')}/${String(plan.num_installments).padStart(3, '0')}`; const pct = (plan.cuotas_billed / plan.num_installments) * 100;
return (
<div className="flex items-center gap-2">
<Progress
value={pct}
className="w-16"
aria-label={`${plan.cuotas_billed} de ${plan.num_installments} cuotas pagadas`}
/>
<span className="font-mono text-xs text-muted-foreground whitespace-nowrap">
{plan.cuotas_billed}/{plan.num_installments}
</span>
</div>
);
} }
export default function Financiamientos() { export default function Financiamientos() {
@@ -86,7 +100,9 @@ export default function Financiamientos() {
<TableCell className="text-muted-foreground whitespace-nowrap"> <TableCell className="text-muted-foreground whitespace-nowrap">
{formatDate(plan.purchase_date)} {formatDate(plan.purchase_date)}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs">{progressLabel(plan)}</TableCell> <TableCell>
<CuotasProgress plan={plan} />
</TableCell>
<TableCell className="text-right font-mono" data-sensitive> <TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.installment_amount, plan.currency)} {formatAmount(plan.installment_amount, plan.currency)}
</TableCell> </TableCell>
@@ -104,14 +120,11 @@ export default function Financiamientos() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <PageHeader
<Layers className="w-6 h-6 text-primary" /> icon={Layers}
<h1 className="text-2xl font-bold tracking-tight">Financiamientos</h1> title="Financiamientos"
</div> subtitle="Compras Tasa Cero en cuotas sin intereses — las cuotas futuras ya cuentan en el presupuesto de sus meses."
<p className="text-sm text-muted-foreground -mt-4"> />
Compras Tasa Cero pagadas en cuotas mensuales sin intereses. Las cuotas
futuras ya cuentan en el presupuesto de sus meses.
</p>
{/* Summary */} {/* Summary */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ArrowRight, Sparkles } from "lucide-react"; import { ArrowRight, Home, Sparkles } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
@@ -16,58 +16,42 @@ import {
formatShortDate, formatShortDate,
} from "@/lib/dates"; } from "@/lib/dates";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/page-header";
import { StatTile } from "@/components/stat-tile";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const CYCLE = currentBudgetCycle(); const CYCLE = currentBudgetCycle();
function StatCardShell({ const SOURCE_LABELS: Record<string, string> = {
to, CREDIT_CARD: "Tarjeta",
ariaLabel, CASH: "Efectivo",
children, TRANSFER: "Transferencias",
}: { };
to: string;
ariaLabel: string;
children: React.ReactNode;
}) {
return (
<Link
to={to}
aria-label={ariaLabel}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card className="h-full transition-colors hover:border-primary/40 cursor-pointer">
{children}
</Card>
</Link>
);
}
function StatValue({ function HoverRows({
value, title,
loading, rows,
className,
}: { }: {
value: string | null; title: string;
loading: boolean; rows: { label: string; value: string }[];
className?: string;
}) { }) {
if (loading) return <Skeleton className="h-8 w-36" />;
return ( return (
<span <div className="space-y-1.5">
data-sensitive <p className="text-sm font-medium">{title}</p>
className={cn("text-2xl font-bold font-mono", className)} <div className="space-y-1">
> {rows.map((r) => (
{value ?? "—"} <div key={r.label} className="flex justify-between gap-3 text-xs">
<span className="text-muted-foreground truncate">{r.label}</span>
<span data-sensitive className="font-mono shrink-0">
{r.value}
</span> </span>
</div>
))}
</div>
</div>
); );
} }
@@ -83,22 +67,33 @@ function GastoDelCicloCard() {
const balance = q.data?.net_balance ?? null; const balance = q.data?.net_balance ?? null;
return ( return (
<StatCardShell to="/budget" ariaLabel="Ver presupuesto"> <StatTile
<CardHeader className="pb-2"> label="Gasto del ciclo"
<CardTitle className="text-sm font-medium text-muted-foreground"> description={`Ciclo al 18 ${MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}`}
Gasto del ciclo value={q.isError || spend === null ? null : formatAmount(spend, "CRC")}
</CardTitle>
<CardDescription>
Ciclo al 18 {MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<StatValue
loading={q.isPending} loading={q.isPending}
value={ to="/budget"
q.isError ? null : spend !== null ? formatAmount(spend, "CRC") : null ariaLabel="Ver presupuesto"
} hoverContent={
q.data ? (
<HoverRows
title="Desglose por fuente"
rows={[
...q.data.actuals_by_source
.filter((s) => s.count > 0)
.map((s) => ({
label: `${SOURCE_LABELS[s.source] ?? s.source} (${s.count})`,
value: formatAmount(s.net, "CRC"),
})),
{
label: "Gran total egresos",
value: formatAmount(q.data.gran_total_egresos, "CRC"),
},
]}
/> />
) : undefined
}
>
{balance !== null && ( {balance !== null && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Balance proyectado:{" "} Balance proyectado:{" "}
@@ -119,8 +114,7 @@ function GastoDelCicloCard() {
No se pudo cargar el ciclo. No se pudo cargar el ciclo.
</p> </p>
)} )}
</CardContent> </StatTile>
</StatCardShell>
); );
} }
@@ -149,22 +143,29 @@ function ProximasCuotasCard() {
const topThree = upcoming.slice(0, 3); const topThree = upcoming.slice(0, 3);
return ( return (
<StatCardShell to="/financiamientos" ariaLabel="Ver financiamientos"> <StatTile
<CardHeader className="pb-2"> label="Próximas cuotas"
<CardTitle className="text-sm font-medium text-muted-foreground"> description={
Próximas cuotas nextDate
</CardTitle>
<CardDescription>
{nextDate
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}` ? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
: "Tasa Cero"} : "Tasa Cero"
</CardDescription> }
</CardHeader>
<CardContent className="space-y-2">
<StatValue
loading={q.isPending}
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")} value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
loading={q.isPending}
to="/financiamientos"
ariaLabel="Ver financiamientos"
hoverContent={
upcoming.length > 0 ? (
<HoverRows
title="Planes activos"
rows={upcoming.map((p) => ({
label: `${p.merchant} (${p.cuotas_billed}/${p.num_installments})`,
value: `${formatShortDate(p.next_cuota_date!)} · ${formatAmount(nextCuotaOf(p), p.currency)}`,
}))}
/> />
) : undefined
}
>
{topThree.length > 0 && ( {topThree.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground"> <ul className="space-y-0.5 text-xs text-muted-foreground">
{topThree.map((p) => ( {topThree.map((p) => (
@@ -186,8 +187,7 @@ function ProximasCuotasCard() {
</span> </span>
</p> </p>
)} )}
</CardContent> </StatTile>
</StatCardShell>
); );
} }
@@ -196,25 +196,28 @@ function PensionCard() {
queryKey: ["pension-fund-summary"], queryKey: ["pension-fund-summary"],
queryFn: () => getPensionFundSummary().then((r) => r.data), queryFn: () => getPensionFundSummary().then((r) => r.data),
}); });
const total = q.data const total = q.data ? q.data.reduce((s, f) => s + f.saldo_final, 0) : null;
? q.data.reduce((s, f) => s + f.saldo_final, 0)
: null;
return ( return (
<StatCardShell to="/pensions" ariaLabel="Ver pensiones"> <StatTile
<CardHeader className="pb-2"> label="Pensión"
<CardTitle className="text-sm font-medium text-muted-foreground"> description="Saldo total de fondos"
Pensión value={q.isError || total === null ? null : formatAmount(total, "CRC")}
</CardTitle>
<CardDescription>Saldo total de fondos</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<StatValue
loading={q.isPending} loading={q.isPending}
value={ to="/pensions"
q.isError ? null : total !== null ? formatAmount(total, "CRC") : null ariaLabel="Ver pensiones"
} hoverContent={
q.data && q.data.length > 0 ? (
<HoverRows
title="Último período por fondo"
rows={q.data.map((f) => ({
label: `${f.fund} · rendimientos`,
value: `+${formatAmount(f.rendimientos, "CRC")}`,
}))}
/> />
) : undefined
}
>
{q.data && q.data.length > 0 && ( {q.data && q.data.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground"> <ul className="space-y-0.5 text-xs text-muted-foreground">
{q.data.map((f) => ( {q.data.map((f) => (
@@ -227,8 +230,7 @@ function PensionCard() {
))} ))}
</ul> </ul>
)} )}
</CardContent> </StatTile>
</StatCardShell>
); );
} }
@@ -327,17 +329,11 @@ function RecentTransactionsCard() {
export default function Inicio() { export default function Inicio() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 icon={Home}
className="text-2xl font-bold tracking-tight" title="Inicio"
style={{ fontFamily: "var(--font-heading)" }} subtitle="Tu panorama financiero de hoy."
> />
Inicio
</h1>
<p className="text-sm text-muted-foreground">
Tu panorama financiero de hoy.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<GastoDelCicloCard /> <GastoDelCicloCard />

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback, useRef } from 'react'; import { useState, useMemo, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog'; import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog';
import { import {
LineChart, LineChart,
@@ -103,7 +104,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'FCL', key: 'FCL',
name: 'FCL', name: 'FCL',
fullName: 'Fondo de Capitalización Laboral', fullName: 'Fondo de Capitalización Laboral',
color: '#3b82f6', color: 'var(--chart-5)',
startBalance: 650_468, startBalance: 650_468,
monthlyContribution: 150_000, monthlyContribution: 150_000,
annualRate: 7.5, annualRate: 7.5,
@@ -115,7 +116,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'ROP', key: 'ROP',
name: 'ROP', name: 'ROP',
fullName: 'Régimen Obligatorio de Pensiones', fullName: 'Régimen Obligatorio de Pensiones',
color: '#10b981', color: 'var(--chart-1)',
startBalance: 18_684_765, startBalance: 18_684_765,
monthlyContribution: 120_000, monthlyContribution: 120_000,
annualRate: 6.0, annualRate: 6.0,
@@ -127,7 +128,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'VOL', key: 'VOL',
name: 'VOL', name: 'VOL',
fullName: 'Fondo Voluntario', fullName: 'Fondo Voluntario',
color: '#f43f5e', color: 'var(--chart-4)',
startBalance: 2_500_381, startBalance: 2_500_381,
monthlyContribution: 400_000, monthlyContribution: 400_000,
annualRate: 8.0, annualRate: 8.0,
@@ -480,17 +481,11 @@ export default function Pensions() {
/> />
)} )}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <PageHeader
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> icon={PiggyBank}
<PiggyBank className="w-5 h-5 text-primary" /> title="Pensiones"
</div> subtitle="Seguimiento de aportes, rendimientos y proyecciones"
<div> />
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
<p className="text-sm text-muted-foreground">
Seguimiento de aportes, rendimientos y proyecciones
</p>
</div>
</div>
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */} {/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
<section className="space-y-3"> <section className="space-y-3">
@@ -606,7 +601,7 @@ export default function Pensions() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ style={{
borderColor: fund.color, borderColor: fund.color,
background: active ? fund.color + '22' : 'transparent', background: active ? `color-mix(in oklab, ${fund.color} 13%, transparent)` : 'transparent',
color: active ? fund.color : 'var(--muted-foreground)', color: active ? fund.color : 'var(--muted-foreground)',
}} }}
> >
@@ -652,6 +647,7 @@ export default function Pensions() {
stroke={FUNDS[key].color} stroke={FUNDS[key].color}
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
isAnimationActive={false}
activeDot={{ r: 4 }} activeDot={{ r: 4 }}
/> />
) : null, ) : null,

View File

@@ -4,6 +4,8 @@ import { Line, LineChart, XAxis, YAxis } from 'recharts';
import { Telescope } from 'lucide-react'; import { Telescope } from 'lucide-react';
import { getYearlyProjection } from '@/lib/api'; import { getYearlyProjection } from '@/lib/api';
import { PageHeader } from '@/components/page-header';
import { StatTile } from '@/components/stat-tile';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -78,15 +80,11 @@ export default function Planificador() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <PageHeader
<Telescope className="w-6 h-6 text-primary" aria-hidden="true" /> icon={Telescope}
<div> title="Planificador"
<h1 className="text-2xl font-bold tracking-tight">Planificador</h1> subtitle="¿Qué pasa si ahorrás un poco más cada mes?"
<p className="text-sm text-muted-foreground"> />
¿Qué pasa si ahorrás un poco más cada mes?
</p>
</div>
</div>
<Card> <Card>
<CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4"> <CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -138,41 +136,18 @@ export default function Planificador() {
</Card> </Card>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card> <StatTile label="Ritmo actual" value={formatCRC(baselineEnd)} />
<CardContent className="p-4"> <StatTile label="Escenario" value={formatCRC(scenarioEnd)} />
<p className="text-xs text-muted-foreground uppercase tracking-wider"> <StatTile
Ritmo actual label="Diferencia"
</p> value={`+${formatCRC(scenarioEnd - baselineEnd)}`}
<p data-sensitive className="text-xl font-bold font-mono"> className="[&_span[data-sensitive]]:text-primary"
{formatCRC(baselineEnd)} />
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Escenario
</p>
<p data-sensitive className="text-xl font-bold font-mono text-primary">
{formatCRC(scenarioEnd)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Diferencia
</p>
<p data-sensitive className="text-xl font-bold font-mono text-emerald-500">
+{formatCRC(scenarioEnd - baselineEnd)}
</p>
</CardContent>
</Card>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Crecimiento comparado Crecimiento comparado
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -191,8 +166,8 @@ export default function Planificador() {
<ChartTooltipContent formatter={(value) => formatCRC(Number(value))} /> <ChartTooltipContent formatter={(value) => formatCRC(Number(value))} />
} }
/> />
<Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} isAnimationActive={false} />
<Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} isAnimationActive={false} />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">

View File

@@ -5,6 +5,7 @@ import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator'; import PeriodNavigator from '@/components/PeriodNavigator';
import YearlyOverview from '@/components/budget/YearlyOverview'; import YearlyOverview from '@/components/budget/YearlyOverview';
@@ -30,12 +31,11 @@ export default function Proyecciones() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> icon={TrendingUp}
<div className="flex items-center gap-3"> title="Proyecciones"
<TrendingUp className="w-6 h-6 text-primary" /> subtitle="Presupuesto anual mes a mes"
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1> actions={
</div>
<PeriodNavigator <PeriodNavigator
label={String(year)} label={String(year)}
onPrev={() => setYear(year - 1)} onPrev={() => setYear(year - 1)}
@@ -43,7 +43,8 @@ export default function Proyecciones() {
prevDisabled={year <= MIN_YEAR} prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR} nextDisabled={year >= MAX_YEAR}
/> />
</div> }
/>
{/* Annual summary cards */} {/* Annual summary cards */}
{projection && ( {projection && (

View File

@@ -1,18 +1,22 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote, Download } from 'lucide-react'; import { Landmark, RefreshCw, Download, Plus } from 'lucide-react';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api'; import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format'; import { formatAmount, formatDate } from '@/lib/format';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import { StatTile } from '@/components/stat-tile';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import SalaryEntryDialog from '@/components/SalaryEntryDialog';
export default function Salarios() { export default function Salarios() {
const [entryOpen, setEntryOpen] = useState(false);
const query = useQuery({ const query = useQuery({
queryKey: ['salarios'], queryKey: ['salarios'],
queryFn: async () => { queryFn: async () => {
@@ -87,17 +91,16 @@ export default function Salarios() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> icon={Landmark}
<div className="flex items-center gap-3"> title="Salarios"
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> subtitle="Historial de depósitos salariales"
<Landmark className="w-5 h-5 text-primary" /> actions={
</div> <>
<div> <Button size="sm" onClick={() => setEntryOpen(true)}>
<h1 className="text-2xl font-bold font-heading">Salarios</h1> <Plus className="w-4 h-4 mr-2" aria-hidden="true" />
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p> Registrar salario
</div> </Button>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -110,44 +113,30 @@ export default function Salarios() {
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh"> <Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} /> <RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button> </Button>
</div> </>
}
/>
{/* Summary cards */} {/* Summary tiles */}
{summary && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card> <StatTile
<CardContent className="p-4"> label="Depósitos"
<div className="flex items-center gap-2 text-muted-foreground mb-1"> value={summary ? String(summary.count) : null}
<Hash className="w-4 h-4" /> loading={query.isPending}
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span> sensitive={false}
/>
<StatTile
label="Total acumulado"
value={summary ? formatAmount(summary.total_amount, 'CRC') : null}
loading={query.isPending}
/>
<StatTile
label="Último depósito"
value={summary?.latest_date ? formatDate(summary.latest_date) : null}
loading={query.isPending}
sensitive={false}
/>
</div> </div>
<span className="text-2xl font-bold font-mono">{summary.count}</span>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Banknote className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
</div>
<span data-sensitive className="text-2xl font-bold font-mono text-primary">
{formatAmount(summary.total_amount, 'CRC')}
</span>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<CalendarDays className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Último depósito</span>
</div>
<span className="text-2xl font-bold font-mono">
{summary.latest_date ? formatDate(summary.latest_date) : '—'}
</span>
</CardContent>
</Card>
</div>
)}
{/* Data table */} {/* Data table */}
{query.isError ? ( {query.isError ? (
@@ -169,6 +158,12 @@ export default function Salarios() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{entryOpen && (
<SalaryEntryDialog
onClose={() => setEntryOpen(false)}
onSaved={() => { void query.refetch(); }}
/>
)}
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates'; import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates';
import MeterLabelsDialog from '@/components/MeterLabelsDialog'; import MeterLabelsDialog from '@/components/MeterLabelsDialog';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { import {
BarChart, BarChart,
@@ -51,13 +52,13 @@ import {
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const METER_COLORS: Record<string, string> = { const METER_COLORS: Record<string, string> = {
'7335': '#3b82f6', '7335': 'var(--chart-5)',
'7345': '#10b981', '7345': 'var(--chart-1)',
'9345': '#f59e0b', '9345': 'var(--chart-2)',
}; };
const DEFAULT_METER_COLOR = '#8b5cf6'; const DEFAULT_METER_COLOR = 'var(--chart-3)';
// ─── Utilities ─────────────────────────────────────────────────────────────── // ─── Utilities ───────────────────────────────────────────────────────────────
@@ -105,8 +106,11 @@ function getMeterIds(readings: WaterMeterReading[]): string[] {
// ─── Charge Trend Data ─────────────────────────────────────────────────────── // ─── Charge Trend Data ───────────────────────────────────────────────────────
// Fixed categorical slots (validated palette); series beyond the 5th read
// muted — never cycle hues.
const CHARGE_COLORS = [ const CHARGE_COLORS = [
'#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)',
'var(--chart-5)', 'var(--muted-foreground)',
]; ];
interface ChargeTrendPoint { interface ChargeTrendPoint {
@@ -306,17 +310,11 @@ export default function ServiciosMunicipales() {
/> />
)} )}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <PageHeader
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> icon={Droplets}
<Droplets className="w-5 h-5 text-primary" /> title="Municipalidad"
</div> subtitle="Municipalidad de Belén — recibos y consumo de agua"
<div> />
<h1 className="text-2xl font-bold font-heading">Servicios Municipales</h1>
<p className="text-sm text-muted-foreground">
Municipalidad de Belén recibos y consumo de agua
</p>
</div>
</div>
{/* ── Summary Cards ───────────────────────────────────────────────── */} {/* ── Summary Cards ───────────────────────────────────────────────── */}
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <section className="grid grid-cols-2 sm:grid-cols-4 gap-3">
@@ -546,7 +544,7 @@ export default function ServiciosMunicipales() {
type="monotone" type="monotone"
dataKey={name} dataKey={name}
name={name} name={name}
stroke={CHARGE_COLORS[i % CHARGE_COLORS.length]} stroke={CHARGE_COLORS[Math.min(i, CHARGE_COLORS.length - 1)]}
strokeWidth={2} strokeWidth={2}
dot={{ r: 3 }} dot={{ r: 3 }}
activeDot={{ r: 5 }} activeDot={{ r: 5 }}

View File

@@ -1,3 +1,4 @@
import { Fragment } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
AlertTriangle, AlertTriangle,
@@ -9,8 +10,18 @@ import {
import { getSyncStatus, type SyncSource } from '@/lib/api'; import { getSyncStatus, type SyncSource } from '@/lib/api';
import { formatRelativeAge } from '@/lib/dates'; import { formatRelativeAge } from '@/lib/dates';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from '@/components/ui/item';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -38,16 +49,11 @@ export default function SyncStatus() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<div className="flex items-center gap-3"> icon={RefreshCw}
<RefreshCw className="w-6 h-6 text-primary" aria-hidden="true" /> title="Sincronización"
<div> subtitle="Estado de los flujos automáticos de datos (n8n y tipo de cambio)"
<h1 className="text-2xl font-bold tracking-tight">Sincronización</h1> actions={
<p className="text-sm text-muted-foreground">
Estado de los flujos automáticos de datos (n8n y tipo de cambio)
</p>
</div>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -60,7 +66,8 @@ export default function SyncStatus() {
/> />
Actualizar Actualizar
</Button> </Button>
</div> }
/>
{query.isError ? ( {query.isError ? (
<ErrorState <ErrorState
@@ -75,26 +82,23 @@ export default function SyncStatus() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{query.data.sources.map((s) => ( <Card className="py-2">
<Card <ItemGroup>
key={s.key} {query.data.sources.map((s, i) => (
className={ <Fragment key={s.key}>
s.status !== 'ok' ? 'border-amber-500/40' : undefined <Item>
} <ItemMedia>
>
<CardContent className="p-4 flex items-center gap-4">
<StatusIcon status={s.status} /> <StatusIcon status={s.status} />
<div className="flex-1 min-w-0"> </ItemMedia>
<div className="flex items-center gap-2"> <ItemContent>
<span className="font-medium">{s.label}</span> <ItemTitle>
{s.label}
{s.status !== 'ok' && ( {s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge> <Badge variant="destructive">Atención</Badge>
)} )}
</div> </ItemTitle>
<p className="text-sm text-muted-foreground truncate"> <ItemDescription>{s.description}</ItemDescription>
{s.description} </ItemContent>
</p>
</div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p> <p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -103,9 +107,12 @@ export default function SyncStatus() {
: '—'} : '—'}
</p> </p>
</div> </div>
</CardContent> </Item>
</Card> {i < query.data.sources.length - 1 && <ItemSeparator />}
</Fragment>
))} ))}
</ItemGroup>
</Card>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Si una fuente aparece atrasada, revisá el flujo correspondiente en Si una fuente aparece atrasada, revisá el flujo correspondiente en
n8n un cambio de formato en los correos del banco es la causa más n8n un cambio de formato en los correos del banco es la causa más

View File

@@ -1,8 +1,13 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-oxc";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "path"; import path from "path";
// Browser requests arrive at Vite, which then proxies them to the backend.
// Locally the backend is published on the host; Docker Compose supplies the
// service hostname instead (see docker-compose.yml).
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8001";
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
@@ -19,7 +24,7 @@ export default defineConfig({
}, },
// All other API calls → Python backend // All other API calls → Python backend
"/api": { "/api": {
target: "http://localhost:8001", target: backendUrl,
changeOrigin: true, changeOrigin: true,
}, },
}, },