Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions components/wallet/activity-view.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client';

import { useMemo, useState } from 'react';
import { Receipt, ArrowDownLeft, ArrowUpRight } from 'lucide-react';
import { Receipt, ArrowDownLeft, ArrowUpRight, Download } from 'lucide-react';
import { EmptyState } from '@/components/ui/states';
import { formatCents } from '@/lib/wallet/ledger';
import { txnTypeLabel, signedAmountCents, filterTxns, groupByDay, netCents, type ActivityTxn } from '@/lib/wallet/activity';
import { txnTypeLabel, signedAmountCents, filterTxns, groupByDay, netCents, toStatementCsv, statementFilename, type ActivityTxn } from '@/lib/wallet/activity';
import { WalletSubnav } from '@/components/wallet/wallet-subnav';

type Row = ActivityTxn & { childName: string | null };
Expand All @@ -30,6 +30,21 @@ export function WalletActivityView({ rows, childOptions }: { rows: Row[]; childO
const groups = useMemo(() => groupByDay(filtered), [filtered]);
const net = useMemo(() => netCents(filtered), [filtered]);

function downloadStatement() {
// Export exactly what's in view (respects the active filters). Pure CSV +
// a client-side Blob download — no server round-trip, no external deps.
const csv = toStatementCsv(filtered);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = statementFilename();
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

const selCls = 'h-9 rounded-lg border border-border bg-bg px-2 text-sm';

return (
Expand All @@ -50,6 +65,15 @@ export function WalletActivityView({ rows, childOptions }: { rows: Row[]; childO
<option value="credit">Money in</option>
<option value="debit">Money out</option>
</select>
<button
type="button"
onClick={downloadStatement}
disabled={filtered.length === 0}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-surface/60 px-3 text-sm font-medium hover:bg-elevated disabled:opacity-50"
aria-label="Download statement as CSV"
>
<Download className="h-4 w-4" /> Statement
</button>
</div>
</div>

Expand Down
26 changes: 23 additions & 3 deletions components/wallet/child-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useRouter } from 'next/navigation';
import {
ArrowLeft, Plus, Target, PiggyBank, ShoppingBag, HandHeart, TrendingUp, Receipt,
Sparkles, CreditCard, Send, HandCoins, X, ChevronRight, ChevronDown,
ArrowDownLeft, ArrowUpRight, Check, Clock, Banknote,
ArrowDownLeft, ArrowUpRight, Check, Clock, Banknote, Download,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal';
Expand All @@ -24,7 +24,7 @@ import { Avatar } from '@/components/ui/avatar';
import { cn } from '@/lib/utils/cn';
import { fmtRelative } from '@/lib/utils/format';
import { formatCents, goalProgress, type BucketKind, type Split } from '@/lib/wallet/ledger';
import { txnTypeLabel, signedAmountCents, groupByDay, type ActivityTxn } from '@/lib/wallet/activity';
import { txnTypeLabel, signedAmountCents, groupByDay, toStatementCsv, statementFilename, type ActivityTxn } from '@/lib/wallet/activity';
import { addFundsAction, requestSpendAction, sendMoneyAction, requestAllowanceAction } from '@/app/(app)/wallet/actions';
import { describeDbError } from '@/lib/supabase/errors';

Expand Down Expand Up @@ -594,6 +594,19 @@ export function ChildDetailView({
const groups = groupByDay(history);
const visibleGroups = activityExpanded ? groups : groups.slice(0, 3);
const hasMore = groups.length > 3;

function downloadStatement() {
const csv = toStatementCsv(history.map((h) => ({ ...h, childName: child.name })));
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = statementFilename();
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const activeGoals = goals.filter((g) => g.status !== 'reached' && g.status !== 'cancelled');
const reachedGoals = goals.filter((g) => g.status === 'reached');

Expand Down Expand Up @@ -742,7 +755,14 @@ export function ChildDetailView({
<h2 className="flex items-center gap-2 text-sm font-bold">
<Receipt className="h-4 w-4 text-brand" /> Activity
</h2>
<Link href="/wallet/activity" className="text-xs font-semibold text-brand hover:underline">All activity →</Link>
<div className="flex items-center gap-3">
{history.length > 0 && (
<button type="button" onClick={downloadStatement} className="inline-flex items-center gap-1 text-xs font-semibold text-muted hover:text-fg" aria-label="Download this child's statement as CSV">
<Download className="h-3.5 w-3.5" /> Statement
</button>
)}
<Link href="/wallet/activity" className="text-xs font-semibold text-brand hover:underline">All activity →</Link>
</div>
</div>
{history.length === 0 ? (
<div className="py-4">
Expand Down
24 changes: 24 additions & 0 deletions docs/AGENT_HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@ Living context doc so another agent can continue without re-deriving everything.
Last updated: 2026-07-04 — **Marketplace** (migration `0120`, `/dashboard/marketplace` + 500-record seed) AND **Voice Control** (migration `0121_voice_commands`, `lib/voice/command-router.ts`, `/dashboard/voice`) both shipped to `main` — completing all 12 roadmap features. Repo-root **`todo.md`** is the build guide. See the "🛒🎙️ 2026-07-04" block below. Earlier: 2026-07-03 — Trust Engine wired into ALL wallet money movement (Trust TODO #1 closed; see top session block). Earlier same day: Mobile polish merged (#210: scrollable wide tables, safe-area overlays, a11y labels), public-route overflow e2e guard (`tests/e2e/overflow.spec.ts`), and the FINAL describeDbError sweep (zero raw `err.message` toast paths remain). Earlier same day: Mobile-readiness **foundation** pass (see the "2026-07-03 SESSION — Mobile foundation" section immediately below). Also recently shipped to `main`: customizable sidebar (Settings → **Navigation Choices**, top-level + per-group sub-pages, `user_preferences.notification_prefs.sidebarNav`/`.sidebarNavChildren`); Capture grid shows only chosen shortcuts with Add gated behind **Customize** (cap 30 = 10 rows, multi-add picker); **in-app camera** on Create Memory (`components/ui/camera-capture.tsx`); removed Planning/Food Hub nav links; new custom **All Services** icon (`components/app/icons/all-services-icon.tsx`).
Last updated: 2026-06-30 — Session shipped: tabbed Settings, mobile house-logo → /home, sidebar polish (All Services de-emphasized + distinct dashboard icons), Calendar redesign (Day/Week/Month + Calendars/Show/Share rail + Sync footer), Tasks page redesign + 500-row seed, Meals page redesign (photos/tabs/votes) + `meals.image_url` + 500-row seed — AND the big systemic find: **production RLS drift** (RLS enabled but family-scoped SELECT policies missing in prod) was silently returning 0 rows for whole tables; repaired via migrations 0105 (calendar_events), 0106 (todo_lists/todo_items), 0107 (meals domain). See the "2026-06-30 SESSION" section directly below. Previously: 2026-06-29 — Curated sidebar now lists Parent Dashboard (/dashboard) + Family Dashboard (/dashboard?view=family) as a grouped pair below the primary nav (DASHBOARD_NAV); NEW `/home` dashboard (mockup-matched, Supabase-wired) is now the default post-login landing + the Home button target for everyone except super-admins; Discoverability pass (#193): Shopping + Family Inbox added to the curated Free-tier PRIMARY_NAV, and an above-the-fold "Why families switch" highlights strip on /pricing for the 8 differentiators; Feature tiers aligned to the competitive-analysis recommendations + pricing matrix rebuilt (#192); plus the prior 2026-06-28 work: Plan/tier resolution made bulletproof (service-role read + highest-plan-across-rows + noStore, fixing "everyone shows Free Tier"); Free-tier core nav un-gated (Files/Location/Family/Family Members → free, Dashboard link fixed); Services hub; mobile-first nav drawer; super-admin excluded from curated sidebar; sidebar account+theme footer (AI-coach box removed); onboarding fix; App Lock; Create Memory + Welcome/More/logout screens. Keep this updated as you ship.

> ## 🗓️ 2026-07-04 SESSION — Wallet build-out: CSV statement export + 500-row ledger seed (branch `claude/wallet-statement-export`)
> Working `todo.md` #2 "Wallet — full family financial OS". Audit first: the wallet is **already deeply
> wired** — allowance cron (`app/api/cron/wallet-allowance`) credits the immutable ledger via
> `creditChildWallet` and advances `next_run_on`; the AI coach (`/api/ai/wallet`) computes balances from the
> ledger; reconcile is surfaced at `/admin/wallet/reconciliation`. The only "coming soon" left is the
> **Stripe-Issuing cards** (money-cards-view / child-detail card face) — legit future infra, kept honest.
> The real missing feature for a "financial OS": **exportable statements**. Added it:
> - **`lib/wallet/activity.ts`** — new pure `toStatementCsv(txns)` (RFC-4180 escaping; running balance
> computed oldest→newest over **completed** rows so the newest row shows the current total; columns
> Date/Time/Type/Description/Child/Direction/Amount/Status/Balance) + `statementFilename(now)`
> (`bubaly-wallet-statement-YYYY-MM-DD.csv`). Tests: `tests/wallet-activity.test.ts` +6 (now 10).
> - **`components/wallet/activity-view.tsx`** — "Statement" download button (respects the active
> child/type/direction filters; disabled when empty). Client-side Blob download, no server round-trip.
> - **`components/wallet/child-detail-view.tsx`** — per-child "Statement" button in the Activity header.
> - **`supabase/seed_wallet_ledger_one_family.sql`** + `db:seed:wallet-ledger` — 500
> `wallet_transactions` (the 0088 child ledger this feature reads; distinct from the 0113 money-hub
> `transactions` seeded by `db:seed:wallet`) for the target family: ensures family wallet + child_wallet
> with Spend/Save/Give/Invest buckets per non-manager member, then 500 txns across 8 types / both
> directions / 4 statuses / ~180 days. RLS preamble, `metadata->>'seed'='wallet_ledger'` idempotent tag.
> Validated on PG16 (500 rows, idempotent re-run, every row bucket-resolved, parent excluded).
> Verified: tsc clean, eslint clean, **1536 tests pass** (+5), `next build` exit 0.
> Remaining wallet gaps for a follow-up: Stripe Issuing (blocked on keys), a parent "run allowance now"/
> catch-up trigger (cron-only today), and a dedicated statement date-range picker.
>
> ## 🛒🎙️ 2026-07-04 — MARKETPLACE + VOICE CONTROL shipped + roadmap build guide (READ FIRST)
>
> Ran the **roadmap build** (the product roadmap: Wallet, Allowance, Concierge, Family Memory,
Expand Down
64 changes: 64 additions & 0 deletions lib/wallet/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,67 @@ export function groupByDay<T extends ActivityTxn>(txns: T[]): Array<{ date: stri
export function netCents(txns: ActivityTxn[]): number {
return txns.reduce((s, t) => (t.status === 'completed' ? s + signedAmountCents(t) : s), 0);
}

// ── Statement export (CSV) ────────────────────────────────────────────────────
// A family financial OS needs an exportable record. This is pure + deterministic
// so it's unit-tested and callable from a client "Download statement" button.

type CsvTxn = ActivityTxn & { childName?: string | null };

/** Escape one CSV field per RFC 4180: wrap in quotes when it contains a comma,
* quote, or newline, doubling any embedded quotes. */
function csvField(value: string | number): string {
const s = String(value);
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

/** Dollars string with sign, from signed cents: 12345 → "123.45", -50 → "-0.50". */
function dollars(cents: number): string {
const sign = cents < 0 ? '-' : '';
const abs = Math.abs(cents);
return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}

/**
* Render transactions (assumed newest-first) to a CSV statement string with a
* header row and a trailing running-balance column computed OLDEST→newest over
* completed rows only, so the last data row's balance is the current total.
* Columns: Date, Time, Type, Description, Child, Direction, Amount, Status, Balance.
*/
export function toStatementCsv(txns: CsvTxn[]): string {
const header = ['Date', 'Time', 'Type', 'Description', 'Child', 'Direction', 'Amount', 'Status', 'Balance'];

// Running balance accrues oldest→newest (completed only); map back to the
// incoming newest-first order for output.
const oldestFirst = [...txns].reverse();
const balanceById = new Map<string, number>();
let running = 0;
for (const t of oldestFirst) {
if (t.status === 'completed') running += signedAmountCents(t);
balanceById.set(t.id, running);
}

const rows = txns.map((t) => {
const iso = t.created_at;
const date = iso.slice(0, 10);
const time = iso.slice(11, 19) || '';
return [
date,
time,
txnTypeLabel(t.type),
t.description ?? '',
t.childName ?? '',
t.direction === 'credit' ? 'in' : 'out',
dollars(signedAmountCents(t)),
t.status.replace(/_/g, ' '),
dollars(balanceById.get(t.id) ?? 0),
].map(csvField).join(',');
});

return [header.join(','), ...rows].join('\r\n');
}

/** Stable, date-stamped statement filename, e.g. "bubaly-wallet-statement-2026-07-03.csv". */
export function statementFilename(now: Date = new Date()): string {
return `bubaly-wallet-statement-${now.toISOString().slice(0, 10)}.csv`;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"db:seed:messages": "psql \"${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}\" -f supabase/seed_messages_one_family.sql",
"db:seed:files": "psql \"${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}\" -f supabase/seed_files_one_family.sql",
"db:seed:wallet": "psql \"${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}\" -f supabase/seed_wallet_one_family.sql",
"db:seed:wallet-ledger": "psql \"${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}\" -f supabase/seed_wallet_ledger_one_family.sql",
"db:seed:family": "psql \"${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}\" -f supabase/seed_family_safety_one_family.sql",
"db:seed:meals": "psql \"${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}\" -f supabase/seed_meals_extras_one_family.sql",
"db:seed:finance": "psql \"${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}\" -f supabase/seed_finance_hub_one_family.sql",
Expand Down
Loading
Loading