first ruggedy draft

This commit is contained in:
2026-06-28 13:48:15 +02:00
parent c916f64fcb
commit 1053f6a2a3
78 changed files with 20905 additions and 2 deletions

View File

@@ -0,0 +1 @@
VITE_API_URL=http://127.0.0.1:8001

5
apps/web/.env.example Normal file
View File

@@ -0,0 +1,5 @@
VITE_API_URL=http://127.0.0.1:8001
# Optional. Leave unset to use the embedded API route at ${VITE_API_URL}/agent.
# Set this only if you run `mousehold-agent serve` as a separate process.
VITE_LOCAL_AGENT_URL=http://127.0.0.1:8765

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mousehold</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
apps/web/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@mousehold/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"lint": "tsc --noEmit",
"test:e2e": "playwright test"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}

View File

@@ -0,0 +1,16 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 30_000,
use: {
baseURL: process.env.PLAYWRIGHT_WEB_URL ?? "http://127.0.0.1:5173",
trace: "retain-on-failure"
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] }
}
]
});

4071
apps/web/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

38
apps/web/src/api.ts Normal file
View File

@@ -0,0 +1,38 @@
const API_BASE = import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000";
const LOCAL_AGENT_BASE = import.meta.env.VITE_LOCAL_AGENT_URL ?? `${API_BASE}/agent`;
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || response.statusText);
}
return (await response.json()) as T;
}
export async function localAgentApi<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${LOCAL_AGENT_BASE}${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || response.statusText);
}
return (await response.json()) as T;
}
export function apiUrl(path: string): string {
return `${API_BASE}${path}`;
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

890
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,890 @@
:root {
color: #1f2528;
background: #f5f7f8;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
select,
input,
textarea {
min-height: 38px;
border: 1px solid #cfd7dc;
border-radius: 6px;
background: #ffffff;
color: #1f2528;
}
input,
select,
textarea {
width: 100%;
padding: 0 10px;
}
textarea {
min-height: 72px;
padding-top: 9px;
resize: vertical;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 12px;
cursor: pointer;
background: #1f6f68;
border-color: #1f6f68;
color: #ffffff;
white-space: nowrap;
}
button.secondary {
width: 100%;
margin-top: 10px;
background: #ffffff;
color: #1f2528;
border-color: #aab6bc;
}
button.inline-button {
width: auto;
margin-top: 0;
}
button.icon-button {
width: 38px;
min-width: 38px;
padding: 0;
}
button.danger {
background: #9a321f;
border-color: #9a321f;
}
button:disabled,
input:disabled,
select:disabled,
textarea:disabled {
opacity: 0.55;
cursor: not-allowed;
}
h1,
h2,
h3 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 26px;
line-height: 1.15;
}
h2 {
font-size: 17px;
margin-bottom: 12px;
}
h3 {
font-size: 15px;
line-height: 1.25;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid #e0e5e8;
text-align: left;
vertical-align: middle;
}
th {
color: #607079;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
td span {
display: block;
color: #68767d;
font-size: 12px;
margin-top: 2px;
}
.shell {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid #dce3e6;
background: #ffffff;
}
.topbar span {
color: #607079;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.topbar-actions select {
min-width: 220px;
}
.page-rail {
position: sticky;
top: 75px;
z-index: 1;
display: flex;
gap: 6px;
overflow-x: auto;
padding: 10px 24px;
border-bottom: 1px solid #dce3e6;
background: #ffffff;
}
.page-rail button {
min-height: 36px;
background: #ffffff;
border-color: transparent;
color: #3d484d;
}
.page-rail button.active {
background: #1f6f68;
border-color: #1f6f68;
color: #ffffff;
}
.page {
padding: 18px 24px 32px;
}
.layout {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
gap: 18px;
padding: 18px 24px 32px;
}
.workspace {
display: grid;
gap: 18px;
min-width: 0;
}
.setup-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.wide-panel {
grid-column: 1 / -1;
}
.panel,
aside.panel > section {
background: #ffffff;
border: 1px solid #dce3e6;
border-radius: 8px;
padding: 16px;
}
aside.panel {
display: grid;
align-content: start;
gap: 18px;
background: transparent;
border: 0;
padding: 0;
}
.grid {
display: grid;
gap: 18px;
}
.grid.two {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stack {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 10px;
align-items: end;
}
.form-grid .wide {
grid-column: span 2;
}
.field {
display: grid;
gap: 4px;
min-width: 0;
color: #3d484d;
font-size: 12px;
font-weight: 650;
line-height: 1.2;
}
.field > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field input,
.field select,
.field textarea {
font-size: 14px;
font-weight: 400;
}
.topbar-field {
grid-template-columns: auto minmax(220px, 1fr);
align-items: center;
gap: 8px;
}
.section-select-field {
min-width: 220px;
}
.management-list {
display: grid;
gap: 8px;
margin-top: 14px;
}
.connector-item {
display: grid;
gap: 6px;
}
.connector-meta {
margin-top: 0;
}
.management-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(120px, 180px) repeat(3, 38px);
gap: 8px;
align-items: end;
padding: 8px;
border: 1px solid #e0e5e8;
border-radius: 8px;
}
.management-row.account-row {
grid-template-columns:
minmax(140px, 1fr) minmax(120px, 1fr) minmax(120px, 160px)
minmax(120px, 150px) minmax(120px, 150px) auto 38px 38px;
}
.management-row.connector-row {
grid-template-columns:
minmax(140px, 1fr) minmax(120px, 150px) minmax(120px, 150px)
minmax(110px, 140px) minmax(140px, 1fr) minmax(120px, 1fr)
minmax(180px, 1.2fr) auto 38px 38px;
}
.management-row.recurring-row {
grid-template-columns:
minmax(120px, 1fr) minmax(120px, 1fr) minmax(100px, 120px)
minmax(110px, 130px) minmax(130px, 150px) minmax(120px, 160px)
minmax(120px, 160px) minmax(120px, 160px) minmax(120px, 140px)
minmax(130px, 150px) minmax(110px, 130px) 38px 38px;
}
.management-row.taxonomy-row {
grid-template-columns: minmax(130px, 1fr) minmax(140px, 1fr) minmax(150px, 1fr) auto 38px 38px;
}
.management-row.tag-row {
grid-template-columns: minmax(140px, 1fr) 38px 38px;
}
.compact-check {
min-height: 38px;
white-space: nowrap;
}
.form-grid > button,
.management-row > button,
.form-grid > .checkbox,
.management-row > .checkbox {
align-self: end;
}
.fints-grid {
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.mail-grid {
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.statement-grid {
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.compact-form {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
color: #3d484d;
}
.checkbox input {
width: auto;
min-height: auto;
}
.summary-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.summary-list span {
border: 1px solid #dce3e6;
border-radius: 999px;
padding: 4px 9px;
color: #54646c;
font-size: 12px;
}
.scope-list span {
max-width: 100%;
overflow-wrap: anywhere;
border-radius: 6px;
}
.metric-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.wide-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 14px;
margin-bottom: 0;
}
.metric {
min-height: 74px;
border: 1px solid #dce3e6;
border-radius: 8px;
padding: 12px;
}
.metric span {
display: block;
color: #607079;
font-size: 12px;
margin-bottom: 6px;
}
.metric strong {
font-size: 22px;
}
.list {
display: grid;
gap: 8px;
}
.row-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 58px;
padding: 10px;
border: 1px solid #dce3e6;
border-radius: 8px;
}
.row-card span {
display: block;
max-width: 42ch;
overflow: hidden;
color: #607079;
text-overflow: ellipsis;
white-space: nowrap;
}
.reconcile-list {
margin-top: 10px;
}
.review-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.review-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(300px, 0.75fr);
gap: 14px;
align-items: start;
padding: 12px;
border: 1px solid #dce3e6;
border-radius: 8px;
background: #ffffff;
}
.review-main {
display: grid;
gap: 8px;
min-width: 0;
}
.review-select {
align-self: start;
}
.review-main span,
.linked-event span,
.linked-event small,
.muted-note {
display: block;
color: #607079;
font-size: 12px;
}
.linked-event {
display: grid;
gap: 3px;
padding-top: 8px;
border-top: 1px solid #e0e5e8;
}
.review-actions {
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 8px;
align-items: end;
}
.review-edit-grid {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 8px;
align-items: end;
padding-top: 8px;
border-top: 1px solid #e0e5e8;
}
.review-edit-grid .wide {
grid-column: span 2;
}
.review-edit-grid > button {
align-self: end;
}
.review-link-field {
grid-column: 1 / -1;
}
.review-filters {
grid-template-columns: auto minmax(180px, 240px) auto;
}
.review-batch-actions {
align-items: center;
margin-bottom: 8px;
}
.import-run-detail {
display: grid;
gap: 14px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #dce3e6;
}
.reconcile-card {
align-items: stretch;
}
.reconcile-main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
min-width: 0;
}
.reconcile-event {
min-width: 0;
}
.reconcile-event span,
.reconcile-actions span,
.reconcile-event small {
display: block;
max-width: none;
overflow: visible;
color: #68767d;
font-size: 12px;
margin-top: 2px;
text-overflow: clip;
white-space: normal;
}
.reconcile-event strong {
display: block;
overflow-wrap: anywhere;
}
.reconcile-actions {
display: grid;
grid-template-columns: minmax(90px, 1fr) auto auto;
align-items: center;
gap: 12px;
min-width: 250px;
}
.status {
display: inline-flex;
align-items: center;
min-height: 26px;
border-radius: 999px;
padding: 0 9px;
background: #edf1f2;
color: #425158;
font-size: 12px;
text-transform: capitalize;
white-space: nowrap;
}
.status.confirmed,
.status.booked,
.status.settled,
.status.candidate_created {
background: #e3f4ec;
color: #176342;
}
.status.outstanding,
.status.announced {
background: #fff2d8;
color: #7a4d00;
}
.status.duplicate_source,
.status.error {
background: #ffe7e3;
color: #9a321f;
}
.table-wrap {
overflow-x: auto;
}
.selected-row td {
background: #eef6f5;
}
.compact-table {
margin-top: 14px;
}
.actions {
display: flex;
gap: 6px;
}
.actions button {
width: 34px;
height: 34px;
min-height: 34px;
padding: 0;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title h2 {
margin-bottom: 0;
}
.section-title select {
max-width: 260px;
}
.table-filters {
display: grid;
grid-template-columns: auto minmax(160px, 260px) minmax(150px, 190px);
gap: 8px;
align-items: center;
}
.event-detail {
display: grid;
gap: 14px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #dce3e6;
}
.event-edit-form {
display: grid;
gap: 10px;
}
.event-detail .section-title span {
display: block;
margin-top: 3px;
color: #607079;
font-size: 12px;
}
.tag-picker {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
min-height: 38px;
align-items: center;
}
.inspector-row {
display: grid;
gap: 8px;
}
.inspector-section {
display: grid;
gap: 8px;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.detail-grid section {
display: grid;
align-content: start;
gap: 10px;
min-width: 0;
}
.detail-card span {
white-space: normal;
}
.fints-prompt {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 260px) auto;
gap: 10px;
align-items: end;
margin-top: 14px;
padding: 12px;
border: 1px solid #cfd7dc;
border-radius: 8px;
background: #f8fafb;
}
.challenge strong,
.challenge span {
display: block;
}
.challenge span {
color: #3d484d;
margin-top: 4px;
}
.challenge img {
display: block;
max-width: 220px;
max-height: 220px;
margin-top: 10px;
border: 1px solid #cfd7dc;
border-radius: 6px;
}
.error {
margin: 16px 24px 0;
padding: 10px 12px;
border: 1px solid #f0b5aa;
border-radius: 8px;
background: #fff0ed;
color: #8d2f1e;
}
@media (max-width: 1120px) {
.layout,
.grid.two,
.grid.three,
.setup-grid {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wide-panel {
grid-column: auto;
}
.management-row,
.management-row.account-row,
.management-row.connector-row,
.management-row.recurring-row,
.management-row.taxonomy-row,
.management-row.tag-row,
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.review-card {
grid-template-columns: 1fr;
}
.review-edit-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
.topbar {
position: static;
align-items: stretch;
flex-direction: column;
padding: 14px;
}
.topbar-actions {
align-items: stretch;
flex-direction: column;
}
.topbar-actions select {
min-width: 0;
}
.page-rail {
position: static;
padding: 8px 14px;
}
.page,
.layout {
padding: 14px;
}
.form-grid,
.fints-grid,
.mail-grid,
.statement-grid,
.metric-row,
.wide-metrics,
.management-row,
.management-row.account-row,
.management-row.connector-row,
.management-row.recurring-row,
.management-row.taxonomy-row,
.management-row.tag-row,
.table-filters,
.reconcile-main,
.reconcile-actions,
.review-card,
.review-actions,
.review-edit-grid,
.review-filters,
.detail-grid,
.compact-form {
grid-template-columns: 1fr;
}
.fints-prompt {
grid-template-columns: 1fr;
}
.form-grid .wide {
grid-column: span 1;
}
.review-edit-grid .wide {
grid-column: span 1;
}
}

405
apps/web/src/types.ts Normal file
View File

@@ -0,0 +1,405 @@
export type Household = {
id: string;
name: string;
default_currency: string;
};
export type User = {
id: string;
household_id: string;
display_name: string;
email: string | null;
};
export type Account = {
id: string;
household_id: string;
owner_user_id: string | null;
name: string;
account_type: string;
institution_name: string | null;
currency: string;
visibility_mode: string;
is_active: boolean;
};
export type Observation = {
id: string;
household_id: string;
source_type: string;
raw_hash: string;
status: string;
source_reference: string | null;
owner_user_id?: string | null;
source_account_id?: string | null;
observed_at?: string | null;
received_at?: string;
merchant?: string | null;
parsed_json: Record<string, string | null> | null;
created_at: string;
};
export type SplitLine = {
id: string;
event_id: string;
user_id: string;
share_amount: string;
reason: string;
};
export type Event = {
id: string;
household_id: string;
source_account_id: string | null;
payment_account_id: string | null;
payer_user_id: string | null;
event_type: string;
status: string;
event_date: string;
booking_date: string | null;
due_date: string | null;
amount: string;
currency: string;
merchant: string | null;
counterparty: string | null;
description: string | null;
category_id: string | null;
project_id: string | null;
split_lines: SplitLine[];
tag_ids: string[];
};
export type Category = {
id: string;
household_id: string;
parent_id: string | null;
name: string;
path: string;
is_active: boolean;
};
export type Tag = {
id: string;
household_id: string;
name: string;
};
export type EventRelation = {
id: string;
from_event_id: string;
to_event_id: string;
relation_type: string;
confidence: string;
created_by: string;
created_at: string;
};
export type EventObservationLink = {
id: string;
event_id: string;
observation_id: string;
relation: string;
confidence: string;
created_at: string;
};
export type EventDetail = {
event: Event;
category: Category | null;
tags: Tag[];
observation_links: EventObservationLink[];
observations: Observation[];
relations: EventRelation[];
related_events: Event[];
};
export type AuthSession = {
session: {
id: string;
user_id: string;
household_id: string;
created_at: string;
last_seen_at: string;
expires_at: string;
};
user: User;
household: Household;
};
export type ObservationReviewItem = {
observation: Observation;
events: Event[];
action_required: boolean;
suggested_action: string;
};
export type SettlementSummary = {
household_id: string;
project_id: string | null;
currency: string;
included_event_ids: string[];
excluded_event_ids: string[];
balances: Array<{ user_id: string; paid: string; share: string; net: string }>;
suggested_transfers: Array<{ from_user_id: string; to_user_id: string; amount: string; currency: string }>;
explanation: string[];
};
export type Project = {
id: string;
household_id: string;
name: string;
status: string;
currency: string;
};
export type ProjectSummary = {
project_id: string;
currency: string;
budgeted_total: string;
committed_total: string;
paid_total: string;
reconciled_total: string;
settled_total: string;
due_later_total: string;
remaining_uncommitted_budget: string;
open_actions: string[];
};
export type Connection = {
id: string;
household_id: string;
owner_user_id: string;
connection_type: string;
display_name: string;
status: string;
last_sync_at: string | null;
last_error: string | null;
secret_storage_ref: string | null;
runs_locally: boolean;
connector_config_json: Record<string, unknown> | null;
created_at: string;
updated_at: string;
};
export type ImportRun = {
id: string;
household_id: string;
connection_id: string | null;
actor_user_id: string | null;
source_type: string | null;
status: string;
imported_count: number;
duplicate_count: number;
created_event_count: number;
skipped_count: number;
error: string | null;
summary_json: Record<string, unknown> | null;
started_at: string;
finished_at: string | null;
};
export type ImportRunDetail = {
run: ImportRun;
observations: Observation[];
events: Event[];
};
export type ObservationBatchResponse = {
updated_count: number;
items: ObservationReviewItem[];
};
export type FinTSAccount = {
index: number;
iban: string | null;
bic: string | null;
accountnumber: string | null;
blz: string | null;
balance?: {
amount: string;
currency: string | null;
date: string;
status: string | null;
};
};
export type FinTSJob = {
id: string;
kind: string;
status: "running" | "needs_tan" | "needs_choice" | "done" | "error";
error: string | null;
result: {
accounts?: FinTSAccount[];
observations?: Array<Record<string, unknown>>;
count?: number;
} | null;
challenge: {
challenge: string;
challenge_html: string;
challenge_hhduc: string | null;
challenge_matrix: { mime_type: string; data_base64: string } | null;
decoupled: boolean;
} | null;
choice_kind: string | null;
choice_options: Array<{ value: string; label: string; description?: string }>;
};
export type IMAPFolder = {
name: string;
delimiter: string | null;
attributes: string[];
};
export type IMAPPreviewMessage = {
message_id: string | null;
source_reference: string;
from: string;
to: string;
subject: string;
date: string;
folder: string;
source_type: string;
event_type: string;
status: string;
amount: string | null;
currency: string | null;
merchant: string | null;
due_date: string | null;
references: Record<string, string>;
importable: boolean;
skip_reason: string | null;
};
export type IMAPScanResult = {
folder: string;
scanned_count: number;
filtered_count: number;
matched_count: number;
importable_count: number;
messages: IMAPPreviewMessage[];
observations: Array<Record<string, unknown>>;
};
export type StatementPreviewRow = {
row: number;
source_type: string;
source_reference: string | null;
event_date: string | null;
amount: string | null;
currency: string;
merchant: string | null;
description: string | null;
raw_type: string;
event_type: string;
status: string;
importable: boolean;
skip_reason: string | null;
};
export type StatementParseResult = {
source: "paypal" | "klarna";
filename: string | null;
row_count: number;
importable_count: number;
skipped_count: number;
rows: StatementPreviewRow[];
observations: Array<Record<string, unknown>>;
};
export type ReconciliationEventSummary = {
id: string;
source_type: string | null;
event_type: string;
status: string;
event_date: string;
amount: string;
currency: string;
merchant: string | null;
counterparty: string | null;
description: string | null;
source_reference: string | null;
provider_transaction_id: string | null;
observation_id: string | null;
};
export type ReconciliationSuggestion = {
id: string;
kind: string;
relation_type: string;
primary_event: ReconciliationEventSummary;
confirming_event: ReconciliationEventSummary;
confidence: string;
reasons: string[];
action: string;
};
export type ReconciliationAcceptResponse = {
accepted: ReconciliationSuggestion;
};
export type ReconciliationDecision = {
id: string;
household_id: string;
suggestion_id: string;
decision: string;
kind: string;
relation_type: string;
primary_event_id: string;
confirming_event_id: string;
confidence: string;
actor_user_id: string | null;
reason: string | null;
created_at: string;
updated_at: string;
};
export type ReconciliationIgnoreResponse = {
ignored: ReconciliationSuggestion;
decision: ReconciliationDecision;
};
export type SyncObservationsResponse = {
imported_observation_ids: string[];
created_event_ids: string[];
duplicate_observation_ids: string[];
connection_id: string | null;
imported_count: number;
duplicate_count: number;
created_event_count: number;
run: ImportRun | null;
};
export type BackupResult = {
file_path: string;
size_bytes: number;
created_at: string;
};
export type HouseholdExport = {
household: Record<string, unknown>;
exported_at: string;
counts: Record<string, number>;
data: Record<string, Array<Record<string, unknown>>>;
};
export type RecurringCommitment = {
id: string;
household_id: string;
provider: string;
description: string | null;
amount_expected: string;
amount_min: string | null;
amount_max: string | null;
currency: string;
interval: string;
next_due_date: string;
payer_account_id: string | null;
category_id: string | null;
project_id: string | null;
notice_period: string | null;
renewal_date: string | null;
status: string;
matching_rule_json: Record<string, unknown> | null;
created_at: string;
updated_at: string;
};

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

102
apps/web/tests/mvp.spec.ts Normal file
View File

@@ -0,0 +1,102 @@
import { expect, test, type APIRequestContext, type APIResponse } from "@playwright/test";
import { readFileSync } from "node:fs";
function envFileValue(key: string): string | undefined {
try {
const lines = readFileSync(".env.development", "utf8").split(/\r?\n/);
const prefix = `${key}=`;
const line = lines.find((item) => item.startsWith(prefix));
return line?.slice(prefix.length).trim().replace(/^["']|["']$/g, "");
} catch {
return undefined;
}
}
const apiBase =
process.env.PLAYWRIGHT_API_URL ??
process.env.VITE_API_URL ??
envFileValue("VITE_API_URL") ??
"http://127.0.0.1:8000";
async function expectOk(response: APIResponse): Promise<void> {
expect(response.ok(), await response.text()).toBeTruthy();
}
async function postJson<T>(
request: APIRequestContext,
path: string,
data: Record<string, unknown>
): Promise<T> {
const response = await request.post(`${apiBase}${path}`, { data });
await expectOk(response);
return (await response.json()) as T;
}
test("local MVP ledger flow", async ({ page, request }) => {
test.setTimeout(60_000);
const suffix = Date.now().toString(36);
const household = await postJson<{ id: string; name: string }>(request, "/households", {
name: `Smoke household ${suffix}`,
default_currency: "EUR"
});
const userA = await postJson<{ id: string; display_name: string }>(request, "/users", {
household_id: household.id,
display_name: `Alice ${suffix}`,
email: `alice-${suffix}@example.test`
});
await postJson<{ id: string }>(request, "/users", {
household_id: household.id,
display_name: `Bob ${suffix}`,
email: `bob-${suffix}@example.test`
});
const account = await postJson<{ id: string; name: string }>(request, "/accounts", {
household_id: household.id,
owner_user_id: userA.id,
name: `Alice checking ${suffix}`,
account_type: "checking",
institution_name: "Smoke Bank",
currency: "EUR",
visibility_mode: "shared_full",
is_active: true
});
try {
await page.goto("/");
const householdSelect = page.getByLabel("Household");
await expect(householdSelect).toContainText(household.name);
await householdSelect.selectOption({ value: household.id });
const sessionSelect = page.getByLabel("Signed in");
await expect(sessionSelect).toBeEnabled();
await expect(sessionSelect).toContainText(userA.display_name);
await sessionSelect.selectOption({ value: userA.id });
await expect(page.getByRole("button", { name: "Logout" })).toBeVisible();
await page.getByRole("button", { name: "Entry" }).click();
const manualEntry = page.locator("section.panel").filter({ has: page.getByRole("heading", { name: "Entry" }) });
await manualEntry.getByLabel("Amount", { exact: true }).fill("42.50");
await manualEntry.getByLabel("Merchant", { exact: true }).fill(`Smoke Shop ${suffix}`);
await manualEntry.getByLabel("Description", { exact: true }).fill("Smoke groceries");
await manualEntry.locator('select[name="payer_user_id"]').selectOption({ label: userA.display_name });
await manualEntry.locator('select[name="source_account_id"]').selectOption({ label: account.name });
await manualEntry.getByRole("button", { name: "Observation" }).click();
await page.getByRole("button", { name: "Inbox" }).click();
const reviewCard = page.locator(".review-card").filter({ hasText: `Smoke Shop ${suffix}` }).first();
await expect(reviewCard).toBeVisible();
await reviewCard.getByRole("button", { name: "Confirm" }).click();
await page.getByRole("button", { name: "Ledger" }).click();
const ledgerRow = page.locator("tbody tr").filter({ hasText: `Smoke Shop ${suffix}` }).first();
await expect(ledgerRow).toBeVisible();
await expect(ledgerRow.getByText("confirmed")).toBeVisible();
await page.getByRole("button", { name: "Setup" }).click();
await expect(page.getByRole("button", { name: "Export JSON" })).toBeEnabled();
await expect(page.getByRole("button", { name: "SQLite backup" })).toBeEnabled();
} finally {
const cleanup = await request.delete(`${apiBase}/households/${household.id}`);
if (!cleanup.ok() && cleanup.status() !== 404) {
console.warn(await cleanup.text());
}
}
});

21
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: false
}
});