From 43e747697b19e622b01d88430f4ffdb51acd306a Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:22:30 +0530 Subject: [PATCH 01/18] docs: Phase 5 design addendum (Home v2, Spending, Bills, Budget, Receipts) and approved mockups --- .../specs/2026-10-22-phase5/bills.html | 230 +++++++++++++++ .../specs/2026-10-22-phase5/budget.html | 253 ++++++++++++++++ .../specs/2026-10-22-phase5/home.html | 196 +++++++++++++ .../specs/2026-10-22-phase5/receipts.html | 249 ++++++++++++++++ .../specs/2026-10-22-phase5/shell.css | 72 +++++ .../specs/2026-10-22-phase5/spending.html | 272 ++++++++++++++++++ .../2026-10-22-ui-revamp-phase5-design.md | 181 ++++++++++++ 7 files changed, 1453 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-22-phase5/bills.html create mode 100644 docs/superpowers/specs/2026-10-22-phase5/budget.html create mode 100644 docs/superpowers/specs/2026-10-22-phase5/home.html create mode 100644 docs/superpowers/specs/2026-10-22-phase5/receipts.html create mode 100644 docs/superpowers/specs/2026-10-22-phase5/shell.css create mode 100644 docs/superpowers/specs/2026-10-22-phase5/spending.html create mode 100644 docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md diff --git a/docs/superpowers/specs/2026-10-22-phase5/bills.html b/docs/superpowers/specs/2026-10-22-phase5/bills.html new file mode 100644 index 0000000..90c53c5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/bills.html @@ -0,0 +1,230 @@ + +Bills mockup + + + + +
+ + +
+
+
+
+

Bills & subscriptions

+
+ ‹October 2026› + Upload + + NB +
+
+

Rent of ₹25,000 is due Friday, and your BESCOM bill is three days late. Two subscriptions look unused, about ₹1,618 a month.

+ +
+
Due in the next 30 days +
₹51,664
8 bills · biggest is rent on 24 Oct
+
Recurring every month +
₹32,383
9 regular charges · yearly ones spread per month
+
Subscriptions you may not use +
₹1,618 a month
2 subscriptions · Spotify, Cult.fit
+
+ +
+ +

BESCOM electricity, ₹1,860, was due Sun 19 Oct

No payment seen yet. It usually lands within 2 days of the bill.

+ +

Airtel postpaid was paid 3 days late on 14 Oct.

+
+ Mark as paid + Remind me tomorrow + Not a bill +
+
+ +
+
+
October, bill by bill +
PaidDuePaid lateOverdue
+
+
Paid so far ₹21,106Still due this month ₹25,999Overdue ₹1,860
+
+ +
+
UpcomingNext 30 days
+
This week₹25,000
+
Fri24RentMonthly · Rent & housing₹25,000
+
Next week₹1,767
+
Mon27Jio FiberMonthly · Broadband₹999
+
Sat1NetflixMonthly · Streaming₹649
+
Sun2Spotify unusedMonthly · Streaming₹119
+
Later₹24,897
+
Wed5Cult.fit unusedMonthly · Fitness₹1,499
+
Tue11Airtel postpaidMonthly · Phone₹599
+
Fri14Amazon PrimeYearly · Subscriptions₹1,499
+
Sun16HDFC credit cardStatement due · variesabout₹21,300
+
+
+ +
+
Subscriptions + Subscriptions 6Bills 5All 11 + Sorted by: needs a look first
+
NameLast chargedNextAmount
+ +
+ + Spotify unused 7 weeksMonthly · since Mar 2024 + 2 Oct2 Nov + ₹119a month + Mark as cancelledSet reminderHow to cancel +
+
+ + Cult.fit unused 5 weeksMonthly · since Jan 2026 + 5 Oct5 Nov + ₹1,499a month + Mark as cancelledSet reminderHow to cancel +
+
+ + Netflix ₹499 → ₹649Monthly · price went up in Aug + 1 Oct1 Nov + ₹649a month + ··· +
+
+ + Amazon PrimeYearly · ₹125 a month spread + 14 Nov 202514 Nov + ₹1,499a year + ··· +
+
+ + Jio FiberMonthly · broadband + 27 Sep27 Oct + ₹999a month + ··· +
+
+ + YouTube Premium looks stoppedMonthly · no charge since 9 Aug + 9 Aug— + ₹149a month + HideConfirm cancelled +
+
+
+
+
+ +
Bills: decisions + open questions +
    +
  1. Calendar shows bills, not spend (Home already has spend). Paid/due/late/overdue as soft fills, one bill per cell + "+1" if more.
  2. +
  3. Row actions only on flagged subscriptions; calm ones get a ··· menu. OK?
  4. +
  5. "How to cancel" opens Ask Lumen with a ready question; Mark cancelled / Set reminder act in place.
  6. +
  7. Needs backend: REST route for find_recurring (agent-only today); paid/late/overdue matching per cycle; "unused" (no usage data, only "looks stopped"); saved cancelled/reminder/not-a-bill state; card-bill due dates (amount varies, not detected).
  8. +
  9. Quarterly cadence (LIC) isn't detected (weekly/monthly/yearly only). Add?
  10. +
+ + + diff --git a/docs/superpowers/specs/2026-10-22-phase5/budget.html b/docs/superpowers/specs/2026-10-22-phase5/budget.html new file mode 100644 index 0000000..d484157 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/budget.html @@ -0,0 +1,253 @@ + +Budget mockup + + + + +
+ + +
+
+
+
+

Budget

+
‹October 2026›
+
+ Edit budget + Let Lumen draft +
+
+

You're on track: ₹8,786 under where an even pace would put you. Shopping is the one line over, mostly the Croma order.

+ +
+
+
Spent in OctoberOn track
+
₹62,182 of ₹1,00,000
+ +
62% spentEven pace today: 71%
+
+
Left to spend₹37,818
+
Days left9
+
Safe per day₹4,091after ₹999 Jio Fiber
+
+
+
+
Pace +
ActualEven pace to ₹1,00,000
+ +
+
+ +
+
+
+
By category1 over1 closeSort: most used ▾
+ + + + + + + + + + + + + +
CategoryBudgetSpentLeftUsed
Shopping₹8,000₹16,840₹8,840 over
210% · Croma ₹9,999 is most of it
Food delivery₹6,000₹5,930₹70
99%
Rent & housing₹25,000₹25,000₹0
Paid in full · 3 Oct
Groceries₹11,000Enter to save · Esc₹8,120₹2,880
74% at ₹11,000
Travel₹5,000₹2,140₹2,860
43%
Bills & utilities₹6,000₹1,998₹4,002
33% · ₹999 due 27 Oct
Entertainment₹3,000₹768₹2,232
26%
Health₹3,000₹640₹2,360
21%
Other₹5,000₹746₹4,254
15%
+
Categories add up to ₹73,000 · ₹27,000 of your ₹1,00,000 is unassigned+ Add category
+
+
+ +
+

Lumen's draft for November

2 of 6 decided
+

Built from Jul–Sep. Each line is a monthly limit; nothing changes until you approve it.

+ +
Rent & housing₹25,000no change + Same ₹25,000 every month.✓ Approved
+ +
Shoppingone-off excluded₹9,000was ₹8,000 + Based on the Jul–Sep median (₹8,410), excluding a one-off ₹9,999 Croma order on 18 Aug. + SkipApprove
+ +
Groceries₹11,000was ₹12,000 + Averaged ₹10,620 a month over Jul–Sep, rounded up. You've stayed under ₹12,000 all three months. + SkipApprove
+ +
Food delivery₹6,500was ₹6,000 + Averaged ₹6,230 over Jul–Sep and rising each month. Approving accepts the trend; skipping keeps ₹6,000. + SkipApprove
+ +
Travel₹4,000was ₹5,000 + Only Aug and Sep had travel (₹3,640 avg). July had none, so it's left out of the average. + SkipApprove
+ +
Health₹2,000was ₹3,000 + Averaged ₹1,720 over Jul–Sep, rounded up.Skipped · keeps ₹3,000
+ +
Approve remaining 4Nov total would be ₹73,500 of ₹1,00,000
+
+
+ +
+
Last 6 months› +
Within budgetOverOctober forecastBudget
+
+ +
+ Track record +
3 of 5 months under
+

Over in June (travel) and August (the Croma order). October is heading for ₹86,400.

+
+
+
+
+
+
+ +
+ Budget page: decisions + open questions
+ 1. Draft lives on the page (not a dialog); Approve/Skip carry the category in their accessible names.
+ 2. Outlier basis ("median, excluding a one-off Croma order") and "2 of 3 months had spend" need backend: draft_budget is mean-of-3 today and basis.months always lists 3.
+ 3. Month switcher, pace chart and 6-month history need backend: GET /api/budget is current month only, no history or daily series.
+ 4. Safe per day subtracts known upcoming bills. OK, or plain left ÷ days?
+ 5. Unassigned ₹27,000 shown honestly. Should it nudge to "Other", or stay? +
+ + + diff --git a/docs/superpowers/specs/2026-10-22-phase5/home.html b/docs/superpowers/specs/2026-10-22-phase5/home.html new file mode 100644 index 0000000..54f8359 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/home.html @@ -0,0 +1,196 @@ + +Home v2 mockup + + + + +
+ + +
+
+
+
+

Good evening, Nishant

+
+ Upload + + + NB +
+
+

October is running 34% heavier than September, mostly shopping. Rent of ₹25,000 is due Friday.

+ +
+
Spent in October› +
OctoberSeptemberForecast
+
+
₹62,182
+
+₹15,822 (34%) vs September by the 22nd · heading for ₹86,400
+ +
1W1M3MYTD
+
+ +
+
+
+
+
+
October, day by day›
+
₹62,182 across 21 days
+
+
+
+
Recent transactionsSee all
+
FreshMart21 Oct · Groceries₹1,284
+
Swiggy21 Oct · Food delivery₹436
+
Uber20 Oct · Travel₹212
+
Croma12 Oct · Shopping₹9,999
+
Jio Fiber9 Oct · Bills₹999
+
+
+
+ +
+
Lumen found3 to look atSee all
+
+ +

FreshMart charged you twice for one order

₹1,284 on 18 Oct, same items, 4 minutes apart.

+
+
+ +

Shopping is ₹11,400 over your usual

Mostly one Croma order on 12 Oct (₹9,999).

+
+
+ +

Spotify hasn't been used in 7 weeks

₹119 a month, renews 2 Nov.

+
+
+
+ +
+
+
Budget in October›
+
₹62,182 of ₹1,00,000
+
62%
+
+ Rent & housing₹25,000 / 25,000
+ Shopping₹16,840 / 8,000
+ Groceries₹8,120 / 12,000
+ Food delivery₹5,930 / 6,000
+
+
+ +
+
Coming up›All bills
+
Fri24RentMonthly₹25,000
+
Mon27Jio FiberMonthly₹999
+
Sat1NetflixMonthly₹649
+
Sun2Spotify unusedMonthly₹119
+
+
+
+
+
+
+ + diff --git a/docs/superpowers/specs/2026-10-22-phase5/receipts.html b/docs/superpowers/specs/2026-10-22-phase5/receipts.html new file mode 100644 index 0000000..1fbb7e0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/receipts.html @@ -0,0 +1,249 @@ + +Receipts mockup + + + + +
+ + +
+
+
+
+

Receipts & statements

+
+ Upload + Import statement + Connect email +
+
+

Four receipts need your OK before they count. The other 42 files are filed.

+ + +
+
Needs your review4Highlighted fields are the ones Lumen wasn't sure aboutApprove all high-confidence
+
+
+
CromaInvoice · uploaded 12 Oct₹9,999
+
Vendor
Croma, Andheri W
Date
12 Oct 2026
Items + GST
₹8,474 + ₹1,525
Total
₹9,999
+

Totals don't add up. Items and GST add to ₹9,999; the printed total looks like ₹9,990.

+
ApproveEditReject
+
+
+
FreshMartReceipt · from email 18 Oct₹1,284
+
Vendor
FreshMart, Powai
Date
18 Oct 2026
Bill no.
FM-55120
Total
₹1,284
+

Looks like a duplicate. FreshMart FM-55120 is already recorded from 4 minutes earlier.

+
Keep bothCompareReject
+
+
+
Amazon.inInvoice · uploaded 20 Oct₹2,349
+
Vendor
Amazon Seller Services
Date
20/10/26 ?
Order
404-8812…
Total
₹2,349
+

Date unclear. Printed as 20/10/26; Lumen read it as 20 Oct 2026.

+
ApproveEditReject
+
+
+
Blue TokaiReceipt · photo 21 Oct₹640
+
Vendor
Blue Tokai Coffee
Date
21 Oct 2026
Currency
none printed
Total
₹640
+

New to you. No earlier payments to this vendor, and no currency on the bill (assumed ₹).

+
ApproveEditReject
+
+
+
+ +
+ +
+
Everything you've given Lumen46 files
+
+
AllReceiptsInvoicesStatementsContracts
+ +
+
+
FileCame fromDateAmountStatus
+
JPGUber trip receiptReceipt · uber-22oct.jpgUpload22 Oct₹212Reading
+
JPGBlue Tokai CoffeeReceipt · IMG_4412.jpgUpload21 Oct₹640Needs review
+
PDFAmazon.in order 404-8812…Invoice · amazon-invoice.pdfUpload20 Oct₹2,349Needs review
+
PNGzomato-order.pngReceipt · couldn't read the totalUpload19 Oct—FailedRetry
+
PDFFreshMart bill FM-55120Receipt · billing@freshmart.inGmail18 Oct₹1,284Needs review
+
PDFCroma tax invoiceInvoice · croma-CR2210.pdfUpload12 Oct₹9,999Needs review
+
PDFJio Fiber bill, OctoberInvoice · ebill@jio.comGmail9 Oct₹999Done
+
CSVHDFC Savings, SeptemberStatement · 142 linesImport3 Oct—Done
+
PDFRental agreement, Powai flatContract · 14 sections indexedUpload1 Oct₹25,000/moDone
+ +
+ +
+ +
+
Statement imports›Import statement
+
+
HDFC Savings ··44171–30 Sep · imported 3 Oct142 lines
+
+
97 new21 matched receipts3 duplicates21 credits skipped
+
+
+
ICICI Amazon Pay card1–30 Sep · imported 4 Oct58 lines
+
+
44 new11 matched3 credits skipped
+
+

October statements are usually out by 5 Nov. Lumen will remind you.

+
+ + +
+
From your inboxConnectedSettings
+
nishant.b@gmail.comChecked 12 min ago · 11 receipts this month
+
Check nowPause
+
+
+
+
+
+
+ + +
State: upload dialog open
+
+
+
+ +

Upload to Lumen

+

Lumen reads each file, fills in the details and asks you only when it isn't sure.

+
+
Receipts & invoicesRead the vendor, date and total, then add to spending
+
DocumentsContracts, policies, POs. Indexed so Ask Lumen can quote them
+
+
+
+ Drop files here +

or browse your computer · PDF, PNG or JPG, several at once

+
+
+
PDF
croma-CR2210.pdfCroma · ₹9,999 · 1 thing to check
Needs review
+
JPG
freshmart-21oct.jpgReading the receipt…
64%
+
PDF
amazon-invoice.pdfWaiting
Queued
+
HEIC
IMG_4420.heicHEIC isn't supported yet. Export it as JPG and try again.
Remove
+
+
Bank statement? Use Import statement (CSV).CloseUpload 2 more
+
+
Open questions for the owner
+1. Review cards replace the old Review tab; Approve on a duplicate becomes "Keep both". OK?
+2. Field highlights come from the existing validation flags (total, date, vendor, currency). True per-field confidence is needs-backend.
+3. Library merges RAG documents + invoices into one list with a status pill. Persisted "Reading" state and Retry are needs-backend (extraction is synchronous today).
+4. Statement imports history (bank, period, counts) is needs-backend; import returns counts but nothing is stored. PDF statements too: CSV only today.
+5. List view over grid: calmer and scans by amount. Grid toggle kept for photo receipts.
+
+ + + diff --git a/docs/superpowers/specs/2026-10-22-phase5/shell.css b/docs/superpowers/specs/2026-10-22-phase5/shell.css new file mode 100644 index 0000000..ebc5adb --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/shell.css @@ -0,0 +1,72 @@ +/* Shared mockup shell for the Phase 4.5 polish + Phase 5 pages. Sky palette from globals.css. */ +*{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg:#f5f4f0;--side:#f0efea;--card:#fff;--line:#e7e5df;--line2:#efede8; + --text:#1c1c1a;--ink2:#46453f;--mute:#7a7871;--faint:#a8a69f; + --acc:#2f6fe0;--accSoft:#e4edfc;--acc2:#7fa8ee; + --good:#2e8b57;--goodSoft:#e3f1e8;--warn:#b7791f;--warnSoft:#faf0dc;--bad:#c4572f;--badSoft:#fae6dd; + --chip:#ecebe6; + --band:linear-gradient(180deg,#d3e4fb 0%,#e8f0fb 50%,rgba(245,244,240,0) 100%); + --shadow:0 1px 2px rgba(28,28,26,.04),0 4px 16px -8px rgba(28,28,26,.08); +} +body{background:#d9d8d3;font-family:'Funnel Sans',system-ui,sans-serif;color:var(--text);font-size:14px;-webkit-font-smoothing:antialiased} +.num{font-variant-numeric:tabular-nums} +.serif{font-family:Newsreader,serif;font-weight:400} + +.app{display:flex;width:1440px;min-height:1000px;background:var(--bg);margin:0 auto} + +/* sidebar */ +.side{width:232px;flex:none;background:var(--side);border-right:1px solid var(--line);padding:14px 10px 12px;display:flex;flex-direction:column;align-self:stretch} +.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 18px;font-weight:600;font-size:16.5px;letter-spacing:-.01em} +.brand img{width:22px;height:22px} +.brand .col{margin-left:auto;width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--mute);opacity:.0;transition:opacity .15s} +.side:hover .brand .col,.brand .col.show{opacity:1} +.brand .col:hover{background:var(--chip)} +.grp{padding:14px 10px 5px;font-size:11.5px;font-weight:500;color:var(--faint);letter-spacing:.02em} +.it{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--ink2);font-size:13.5px} +.it svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.6;opacity:.75} +.it.on{background:var(--card);color:var(--text);font-weight:500;box-shadow:var(--shadow)} +.it.on svg{opacity:1;stroke:var(--acc)} +.it .n{margin-left:auto;font-size:11px;font-weight:600;background:var(--accSoft);color:var(--acc);border-radius:10px;padding:1px 7px} +.it .new{margin-left:auto;font-size:10.5px;color:var(--acc)} +.foot{margin-top:auto;display:flex;flex-direction:column;gap:8px} +.askbox{display:flex;align-items:center;gap:9px;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 11px;color:var(--mute);font-size:13px} +.askbox svg{width:15px;height:15px;stroke:var(--acc);fill:none;stroke-width:1.6} +.askbox kbd{margin-left:auto;font:11px 'Funnel Sans';color:var(--faint);border:1px solid var(--line);border-radius:5px;padding:1px 5px} +.me{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:10px} +.me:hover{background:var(--chip)} +.av{width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#6c93d8,#2f6fe0);color:#fff;display:grid;place-items:center;font-size:11.5px;font-weight:600} +.me b{font-weight:500;font-size:13px;display:block} +.me small{color:var(--mute);font-size:11.5px} +.me .chev{margin-left:auto;color:var(--faint)} + +/* main */ +.main{flex:1;min-width:0;position:relative} +.band{position:absolute;inset:0 0 auto 0;height:420px;background:var(--band);pointer-events:none} +.wrap{position:relative;padding:26px 36px 48px;max-width:1208px} +.label{font-size:12.5px;font-weight:500;color:var(--mute);letter-spacing:.01em} +.card{background:var(--card);border:1px solid var(--line2);border-radius:16px;padding:18px 20px;box-shadow:var(--shadow)} +.ch{display:flex;align-items:center;gap:8px;margin-bottom:12px} +.ch a{margin-left:auto;font-size:12.5px;color:var(--mute);text-decoration:none} +.ch a:hover{color:var(--text)} +.btn{display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 13px;border-radius:9px;font:500 13px 'Funnel Sans';border:1px solid var(--line);background:rgba(255,255,255,.7);color:var(--ink2);backdrop-filter:blur(8px)} +.btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.6} +.btn.pri{background:var(--text);color:#fff;border-color:var(--text)} +.chips{display:inline-flex;gap:2px;background:rgba(255,255,255,.55);border:1px solid var(--line2);border-radius:9px;padding:2px} +.chips span{font-size:12px;font-weight:500;padding:4px 10px;border-radius:7px;color:var(--mute)} +.chips .on{background:var(--card);color:var(--text);box-shadow:var(--shadow)} +.pill{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:500;border-radius:20px;padding:2px 9px} +.pill.good{background:var(--goodSoft);color:var(--good)} +.pill.warn{background:var(--warnSoft);color:var(--warn)} +.pill.bad{background:var(--badSoft);color:var(--bad)} +.pill.acc{background:var(--accSoft);color:var(--acc)} +.mute{color:var(--mute)} +.row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line2)} +.row:last-child{border:0} +.logo{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:12px;font-weight:600;flex:none} +.amt{margin-left:auto;font-variant-numeric:tabular-nums;font-weight:500} +.bar{height:6px;background:var(--chip);border-radius:6px;overflow:hidden} +.bar i{display:block;height:100%;border-radius:6px} +svg text{font:10.5px 'Funnel Sans';fill:var(--faint)} +.note{position:fixed;right:16px;bottom:16px;max-width:340px;background:#1c1c1a;color:#eee;font-size:12.5px;line-height:1.5;padding:12px 14px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.25)} +.note b{color:#fff} diff --git a/docs/superpowers/specs/2026-10-22-phase5/spending.html b/docs/superpowers/specs/2026-10-22-phase5/spending.html new file mode 100644 index 0000000..48a2993 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/spending.html @@ -0,0 +1,272 @@ + +Spending mockup + + + + +
+ + +
+
+
+
+

Spending

+
+ Upload + + + NB +
+
+

Shopping is most of the jump, and one Croma order is ₹9,999 of it.

+ +
+
This monthLast month3MYTDCustom
+ Compare with September + 1 – 22 Oct 2026 · vs 1 – 22 Sep +
+ +
+
+
Spent in October +
OctoberSeptember
+
₹62,182
+
+₹15,822 (34%) vs September by the 22nd · 214 transactions
+ +
+
+ Month by month +
₹71,720 avg, May–Sep
+

Shown as bars when you pick 3M or YTD. October so far is under your average.

+
+
+
+ +
+
+
By categoryFiltering transactions: ShoppingClear
+
CategoryShare of OctoberSpentSharevs Sep
+
+
3 transactions still need a categorySort them now
+
+ +
+
Lumen noticed4 to look at
+
+ +

FreshMart charged you twice

₹1,284 on 18 Oct, same items, 4 minutes apart.

+
+
+ +

Shopping is ₹11,400 over usual

Mostly one Croma order on 12 Oct (₹9,999).

+
+
+ +

Netflix went up to ₹649

Was ₹499 until September. Next charge 1 Nov.

+
+
+ +

First time at Decathlon

₹899 on 17 Oct. Filed under Shopping.

+
+
+
+ +
+
+
Transactions
+
+ Search merchant or amount + Shopping + All sources + 8 in Shopping · ₹16,840 +
+ + + +
DateMerchantCategorySourceAmount
+
Showing 8 of 214 in OctoberShow all categories
+
+ +
+
Top merchantsSee all
+
R. Kulkarni (rent)1 payment · Rent & housing₹25,000
+
Croma1 order · Shopping₹9,999
+
FreshMart6 visits · Groceries₹6,420
+
Amazon4 orders · Shopping₹3,744
+
Swiggy9 orders · Food delivery₹3,480
+
Zomato6 orders · Food delivery₹2,450
+
Uber11 rides · Travel₹2,160
+
+
Spending: decisions + questions
+1. Categories as horizontal bars, not a donut: rent (40%) would swallow a donut. Click a row to filter the table; Clear undoes.
+2. Calendar + reminders moved out to Bills. Spending has no due dates.
+3. Month bars live in the hero inset; for 3M/YTD they become the main chart and the cumulative line hides.
+4. "First time at merchant" is not a finding kind yet (backend has duplicate / spike / price_rise / review). Add it, or drop the card?
+5. Export: CSV of the filtered list only, or the whole period?
+
+
+
+
+
+ + + + + diff --git a/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md new file mode 100644 index 0000000..b956db2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md @@ -0,0 +1,181 @@ +# UI revamp, Phase 5: Home v2 and the inner pages + +Addendum to `2026-10-20-ui-revamp-design.md` (IA, Sky palette, motion, zero layout-shift rule and §12 backlog still +apply). Approved by the owner on 2026-10-22 from the mockups in `2026-10-22-phase5/` (open the `.html` files in a +browser; `shell.css` is the shared style). The dark "decisions + questions" box on each mockup is a review aid, not UI. + +## 1. Why + +The owner ran Phases 0–4 locally and found the app still felt like the old one with a new coat: Home didn't resemble +the inspiration (Origin's app home), labels used a generic all-caps mono font, sidebar groups weren't marked, and some +chips sent text to the AI when they should have done something. The inner pages (Spending, Bills, Receipts) are still +the old pages stacked inside new URLs, and Budget has no page. Phase 5 makes every main page look and behave like one +calm product: "not too pushy with info, not too implicit". + +The shell fixes (labelled groups, collapse in the sidebar header, no desktop top bar, `.label-ui` sentence-case labels, +upload chip in Ask) shipped first in PR #96 and are the baseline for this phase. + +## 2. Goals, non-goals, anti-goals + +**Goals** +- Home v2 and four pages (Spending, Bills & subscriptions, Budget, Receipts & statements) built to the mockups. +- Budget becomes a page with a **Plan** sidebar group (Plan · Budget), between Track and Records. +- One shared page skeleton so the pages can't drift: header (title, one-line serif brief, quiet actions), cards, + labels, number style, the smooth area chart, the calendar grid. +- The cheap backend pieces each page needs (§5), and the §12 items tagged Phase 5 (§7). +- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box (bounding-box or CLS tests, + as in Phases 0–4). + +**Non-goals** +- Forecast page (Plan · Forecast) and Phase 6 performance work. +- Smarter "Lumen found" and budget drafts (median/trimmed mean, one-off detection): stays §12 *Later*, gated on an eval + set first (owner request). The draft UI is built so a "basis" sentence can carry it later. +- Paid-vs-overdue matching of bills to payments, per-field extraction confidence, "unused" (usage) detection, PDF bank + statements, async extraction states (Reading / Failed + Retry): deferred, see §6. + +**Anti-goals** (would be a failure even if it "works") +- Showing anything the data can't back: a deferred element is **hidden**, never faked or filled with placeholders. +- Loud saturated blue buttons, all-caps mono labels, a dark dialog inside the light app. +- A chip or button whose label is an action ("Upload…", "Mark as cancelled") that sends text to the chat instead. +- Layout jumps when data loads, filters change or a row enters edit mode. + +## 3. Shared skeleton (all pages) + +As in `home.html`: +- **Header row:** sans 26px `h1`, quiet actions on the right (`Upload` as a light button, icon buttons for export/help), + leaving room for the floating account menu (`md:pr-12`). Below it a one-line Newsreader brief built from real numbers + (same style as Home's brief; one emphasised phrase in italic accent). +- **Cards:** white, hairline border, 16px radius, very soft shadow; title row = `.label-ui` + optional `›` link + optional + small `+` action on the right. One strong number per card, tabular numerals. +- **Charts:** smooth (monotone/Catmull-Rom) area line in accent with a soft gradient, previous period dashed grey, + forecast dotted light accent, today dot, bill pins as small round markers on the x-axis; y labels right-aligned; + sparse x ticks (no overlap at any width; fixes the "01 OC09 OCT" overlap on phones). +- **Range/period chips** centred under the chart (Home) or in a segmented control under the header (pages). +- **Buttons:** `.btn` quiet (light, hairline) or dark primary for the one main action of a page. The shadcn `Button` + default blue is not used on these pages; Export/Upload get restyled (§12). +- **Dialogs:** light (Sky tokens), including the upload dialog. + +Implementation is the builder's call (a `PageHeader` + a few card primitives in `components/page/` is the expected +shape); the rule is that the five pages use the same pieces. + +## 4. Pages + +### 4.1 Home v2 (`home.html`) +Greeting row; brief; **hero card "Spent in ›"** (big number, delta vs previous period with sign colour, "heading +for ₹X" from the forecast, legend, `+` = upload, chart, range chips 1W/1M/3M/YTD); then a two-column grid: +- left: **", day by day"** card = month total + calendar with the amount in each day cell (heat shade, today + outlined, future days dashed with known bills named) beside **Recent transactions** (5, logo chip, date · category, + amount, "See all" → Spending); **Lumen found** as a quiet list (icon, headline, detail, two action links that *do* + things: e.g. Review duplicate → the review item, See in Spending → filtered Spending, Ask why → Ask with the question + filled). +- right: **Budget in ** (spent of total, green bar + %, top 4 category bars; no budget → the draft promo linking to + the Budget page); **Coming up** (next 4 bills with date chips); Activity stays only if it fits, else moves to + Receipts. +Everything on Home already has data except per-category budget bars (exists via `/api/budget`) — no new endpoint. + +### 4.2 Spending (`spending.html`) +Answers "where did my money go, and is anything off?". Header + period control (This month / Last month / 3M / YTD / +Custom) + "Compare with " toggle + date range. Hero: total, delta, cumulative line vs previous; inset "Month +by month" bars (last 6 months, average). For 3M/YTD the bars become the main chart (owner may revisit). +Row 2: **By category** (horizontal bars: amount, share, change vs previous; click a row = filter the table, "Clear"; +"N transactions still need a category → Sort them now") and **Lumen noticed** (findings from `/api/home/summary` +kinds: duplicate, spike, price_rise, review; each with real actions + "Ask Lumen why"). The mockup's "First time at a +merchant" card is **dropped** (no such finding kind). +Row 3: **Transactions** table (search, category chip filter, source filter, date/merchant/category pill/source/amount, +hover pencil → category menu; "Always file here" only if a rule store exists — otherwise omit) and **Top +merchants**. Export = CSV of the current filtered view. +Removes the duplicated payment calendar + reminders and the two `

`s (§12). + +### 4.3 Bills & subscriptions (`bills.html`) +Answers "what's due, what recurs, what can I cut?". Header + month switcher. Three tiles: due in next 30 days, recurring +per month, "subscriptions that look stopped" (renamed from "you may not use": we only know charges stopped, not usage). +**Month calendar** with bill names/amounts on due days (states shown: upcoming, today; paid/late/overdue states are +deferred) beside an **Upcoming** agenda grouped This week / Next week / Later with group totals. **Subscriptions table** +(tabs Subscriptions / Bills / All): logo chip, cadence, last charged, next charge, amount, pills for price rise and +"looks stopped". Actions: **Mark as cancelled**, **Not a bill** (both saved, §5), **How to cancel** → opens Ask with +"How do I cancel ?" typed but not sent (owner choice). "Set reminder" and the overdue strip are deferred. +Bills owns the calendar and reminders; Spending no longer shows them. + +### 4.4 Budget (`budget.html`) — new page, sidebar Plan · Budget +Answers "am I on track, and what should my plan be?". Header + month switcher, "Edit budget" (quiet) and "Let Lumen +draft" (dark). Hero: spent of total, green bar with an even-pace tick, left / days left / safe per day (= left ÷ days +left; subtracting known upcoming bills is the builder's call if the bills data is already loaded), pace chart (actual +vs straight line to the budget). **By category** table: budget, spent, left, bar (green / amber ≥90% / red over), hover +→ inline edit of the amount (Enter saves, Esc cancels, no row height change), footer "categories add up to X, Y +unassigned" + Add category. **Draft panel on the page** (owner choice; replaces the dialog): each line shows proposed +amount, old limit, basis sentence, a small month-bars glyph, Approve / Skip with the category in the accessible name, +decided state in place, "Approve remaining N". Home's promo links here. **Last 6 months**: budget outline vs actual +bars (green under / red over), current month as "so far" + forecast. + +### 4.5 Receipts & statements (`receipts.html`) +Answers "what have I given Lumen, and what needs my OK?". Header actions Upload / Import statement / Connect email. +**Needs your review** row of cards (only when the queue is non-empty; count = sidebar badge): extracted fields with the +doubtful ones highlighted from existing validation flags (`total_mismatch`, `duplicate`, `bad_date`, `unknown_vendor`, +`no_currency`), a plain-words reason, Approve / Edit / Reject (duplicate: "Keep both" / Compare / Reject). This replaces +the old Review tab. **Library** list (filter chips All / Receipts / Invoices / Statements / Contracts, search, list view +default; grid toggle optional): file icon, title, came from (Upload / Gmail / Import), date, amount, status (Needs +review, Done; Reading/Failed deferred). Right column: **Statement imports** history (bank/file, period, lines, new / +matched / duplicates / credits skipped bar) and **From your inbox** (Gmail status, Check now, Pause). **Light upload +dialog**: choose Receipts & invoices vs Documents, drop zone, accepted types, per-file progress rows, pointer to Import +statement for CSVs. + +## 5. Backend in scope ("build what's cheap", owner choice) +- `GET /api/bills/recurring`: wraps `ai/recurring.find_recurring` (vendor, cadence, typical amount, last/next date, + bill vs subscription, price change, looks-stopped) minus vendors the user dismissed; plus + `POST /api/bills/recurring//state` with `cancelled | not_a_bill | active` saved per user (new small table). + Home's bills card and the Bills page both use it. +- `GET /api/budget?month=YYYY-MM` and `GET /api/budget/history?months=6` (budget vs actual per month). Daily cumulative + series for the pace chart comes from the transactions the page already fetches (no new endpoint unless that is + heavy). +- Statement import history: persist one row per import (file name, account/bank label if given, period from the + rows, counts already returned by the import); `GET /api/statements/imports`. +- §12 backend items: `POST /chat` creates the thread and its messages in one commit; the lost approval race returns a + generic "This proposal was already decided" (re-read status after a failed claim); `basis.months` lists only months + that had spend. +- Everything new: per-user scoping, rate limits like the neighbouring routes, tests first. + +**Decisions made after the builder check (agent's calls, owner can overrule):** +- **Budgets are versioned by month.** Today `Budget` is one `monthly_limit` per category. Add an `effective_from` + month (migration; existing rows get the month they were created). A month's budget = for each category, the latest + row with `effective_from <= month`. Editing (inline or Edit budget) writes a row effective from the **current** + month, so past months keep what they were; approving a draft "for November" writes rows effective from November. + Past months are read-only in the UI. History therefore compares each month with the limits that applied then; months + before any budget show actual spend only (no outline). `User.monthly_budget` stays the overall total fallback. +- **Saved recurring states are keyed by the normalised vendor** (`ai/recurring._norm`, the same key `find_recurring` + groups by); the route takes the display name and normalises it. `not_a_bill` hides the vendor from every recurring + list (a "Hidden" tab or footer link lets you undo). `cancelled` keeps the row in a dimmed "Cancelled" group at the + bottom; if a charge from that vendor appears **after** the cancel date, the state is ignored and the row shows a + "charged again after you cancelled" pill. +- **Library "Statements" filter** lists the new import-history rows as library items (type Statement, came from + Import, no file to open since CSVs aren't kept). The chip shows only when there is at least one. +- **Import history starts with the new table** (older imports don't appear). Counts are stored as the import returned + them; a re-import of the same file is a new history row and its "matched" count may repeat, labelled "at import". + +## 6. Deferred (hidden until built; recorded in §12) +Paid / paid-late / overdue matching per bill cycle and the overdue strip; "Set reminder"; usage-based "unused"; +quarterly cadence detection; credit-card bill due dates from statements; per-field extraction confidence; persisted +Reading/Failed + Retry; PDF statement import; "Approve all high-confidence"; category rules ("Always file X here"); +median/outlier budget drafts (eval set first); Spending's "first time at a merchant" finding. + +## 7. §12 items folded in +Duplicate calendar/reminders and the double `

` on Spending; Export/Upload restyle + light upload dialog; draft +buttons' accessible names + `basis.months`; `POST /chat` single commit; generic 409 text; thread rail (disable +Keep/Delete while busy, focus "Keep" when confirm opens, trash visible on touch); AskPalette rendered outside +`` so Ctrl+K works on tablets with the sheet closed. + +## 8. Delivery +Stacked PRs into `refactor`, each green and checkable on its own, in this order (later ones may depend on earlier): +1. **5a** shared skeleton + Home v2 + Export/Upload restyle + light upload dialog + Ask/§12 frontend fixes. +2. **5b** Spending. +3. **5c** Bills (+ recurring API and saved states). +4. **5d** Budget page + Plan group (+ month/history API, draft panel, §12 draft items). +5. **5e** Receipts & statements (+ import history API, review cards replacing the Review tab). +Each PR: TDD per task, layout-shift tests for its controls, a manual-check list for the owner, a STORY entry, and the +codebase guide updated. One whole-branch review at the end of the phase; Critical/Important fixed test-first, Minors to +§12. + +## 9. Left to the builder +Component boundaries and file layout; chart implementation (existing chart code vs a small SVG helper); exact +breakpoints (pages must work at phone width with no sideways scroll); empty and loading states in the established +skeleton style; whether Activity stays on Home; how the brief sentence is phrased per page as long as it uses real +numbers only. From d13376c822ecff9fca0b0888e9103f48ec81f4df Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:28:26 +0530 Subject: [PATCH 02/18] docs: Phase 5 implementation plan (5a detailed, 5b-5e outlined) --- .../plans/2026-10-22-ui-revamp-phase5.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md new file mode 100644 index 0000000..6143227 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -0,0 +1,203 @@ +# UI revamp Phase 5 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build Home v2 and the Spending, Bills & subscriptions, Budget and Receipts & statements pages to the approved mockups, on one shared page skeleton, with the cheap backend each page needs. + +**Architecture:** A small set of shared page pieces (`components/page/*`, `lib/chart.ts`) is built first and used by every page so they can't drift. Each sub-phase (5a–5e) is one stacked PR into `refactor`. Pages keep fetching through `lib/api/client.ts`; new backend routes are FastAPI routers in `backend/api/` like `budget.py`. + +**Tech Stack:** Next.js (app router), Tailwind v4 + shadcn tokens (Sky theme), motion, Playwright e2e; FastAPI + SQLAlchemy (Flask-SQLAlchemy models), pytest. + +**Spec:** `docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md` (mockups in `docs/superpowers/specs/2026-10-22-phase5/`), parent `docs/superpowers/specs/2026-10-20-ui-revamp-design.md`. + +## Global Constraints + +- Labels: `.label-ui` (Funnel Sans, sentence case). No all-caps mono, no shadcn default blue `Button` on these pages, no dark dialog in the app. +- Nothing the data can't back is shown: deferred elements are hidden, never faked (spec §2, §6). +- A control whose label is an action does that action; it never sends its text to the chat. +- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box; each gets a bounding-box test. +- Pages work at 375px wide with no sideways scroll; leave `md:pr-12` room for the floating account menu in header rows. +- Inside `.bui` (Ask) use bui tokens (`bg-hover`, `bg-surface`, `border-line`, `text-ink-2`), never shadcn `accent`. +- Git: branch names without "claude", no attribution lines in commits or PRs; PRs into `refactor` on coderconnoisseur/Lumen. +- Backend test env: `OPENROUTER_API_KEY=test-key SECRET_KEY=test-secret-key-for-ci-only SUPABASE_URL=https://example.supabase.co python -m pytest -q` from `backend/`. +- Frontend lint/build env: `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=e2e-dummy NEXT_PUBLIC_BACKEND_URL=http://127.0.0.1:9`. + +## Review Focus + +1. **Empty account** (no transactions, no budget, no bills): every Home card shows its empty state at its loading height; no NaN, "₹0 of ₹0", or a chart line at zero. Pinned by Task 3 and Task 6 empty-state tests. +2. **Phone width (375px)**: chart ticks never overlap (the reported "01 OC09 OCT"), calendar cells keep amounts readable, header actions wrap under the title without covering the account menu. Pinned by Task 2 (`pickTicks`) and Task 3 / Task 1 bounding-box tests. +3. **Forecast missing or outside the month** (backend forecasts from the last transaction date): no "heading for" text and no dotted line. Pinned in Task 3. +4. **Ask prefill** (`/ask?draft=`) must never send by itself, also after a reload or when Ask is already mounted. Pinned in Task 5. +5. **Ask busy state**: Keep/Delete in the thread rail are disabled while an answer is in flight; deleting the open thread mid-answer must not orphan the answer. Pinned in Task 8. + +--- + +## Phase 5a — shared skeleton, Home v2, dialogs, Ask/§12 fixes (detailed) + +Branch: `ui-revamp/phase5-pages` (contains the spec commit; stacked on `ui-revamp/polish`). + +### Task 1: Page header, page card, quiet buttons + +**Files:** +- Create: `frontend/src/components/page/page-header.tsx`, `frontend/src/components/page/page-card.tsx` +- Modify: `frontend/src/components/ui/button.tsx` (variants), `frontend/src/app/(app)/home/_components/card.tsx` (delete; callers import `PageCard`), all Home cards that import `HomeCard`, `frontend/src/app/(app)/home/homeContent.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Produces: `PageHeader({ title: ReactNode; brief?: ReactNode; actions?: ReactNode; children?: ReactNode })` — renders `

`, actions right-aligned in the same row with `md:pr-12`, brief below in `font-serif text-[19px] text-ink-2`-equivalent (`text-foreground/80`), `children` = a control row under the brief. +- Produces: `PageCard({ title: ReactNode; href?: string; action?: ReactNode; extra?: ReactNode; loading?: boolean; skeletonHeight: number; className?; "data-testid"?; children })` — same contract as today's `HomeCard` (skeleton at final height, `minHeight` after), restyled: `rounded-2xl border border-border/70 bg-card px-5 py-[18px] shadow-[0_1px_2px_rgba(28,28,26,.04),0_4px_16px_-8px_rgba(28,28,26,.08)]`; title row = `.label-ui` title, `›` after it when `href`, `extra` (legend, pill) then `action` at the right. +- Produces: `Button` variants `quiet` (`border border-border bg-card/70 text-foreground/80 hover:bg-card hover:text-foreground backdrop-blur`) and `ink` (`bg-foreground text-background hover:bg-foreground/90`); size `pill-icon` (`size-[34px] rounded-full`). + +- [ ] **Step 1: Write the failing test** `home header keeps clear of the account menu` in `e2e/home.spec.ts`: at 1440×900 and 1024×800, `signIn`, goto `/home`; the bounding box of `getByRole("button", { name: "Account menu" })` does not intersect the boxes of `getByRole("button", { name: /upload/i })` and `getByRole("button", { name: "Export" })`; `getByRole("heading", { level: 1 })` has `font-size` `26px`. +- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "account menu"` — Expected: FAIL (font-size 24px / names differ). +- [ ] **Step 3: Implement** `PageHeader`, `PageCard`, the button variants; switch Home's greeting row to `PageHeader` (title = greeting, brief = ``), replace every `HomeCard` import with `PageCard` and delete `card.tsx`. +- [ ] **Step 4: Run** the new test and the whole `e2e/home.spec.ts` — Expected: PASS. +- [ ] **Step 5: Commit** `feat: shared page header and card for the Phase 5 pages`. + +### Task 2: Chart helpers + +**Files:** +- Create: `frontend/src/lib/chart.ts` +- Test: `frontend/e2e/home-math.spec.ts` (pure-function tests live here) + +**Interfaces:** +- Produces: `smoothPath(points: [number, number][]): string` — SVG path through every point; monotone cubic (Fritsch–Carlson) so a running total never dips or overshoots between points; 0 points → `""`, 1 point → `"M x y"`. +- Produces: `pickTicks(candidates: { i: number; x: number; label: string; keep?: boolean }[], minGap: number): typeof candidates` — keeps `keep` ticks (today, first, last) and drops any other tick closer than `minGap` px to a kept one, left to right. + +- [ ] **Step 1: Write failing tests** in `e2e/home-math.spec.ts`: + - `smoothPath([[0,10],[10,20],[20,20],[30,40]])` starts with `"M0 10"`, contains `"20 20"` and ends with `"30 40"`. + - Sampling the cubic segments of `smoothPath([[0,0],[10,100],[20,100],[30,101]])` at t=0.25/0.5/0.75 never gives y > 101 or a y lower than the previous sample (monotone, no overshoot). Parse the `C` commands to compute the samples in the test. + - `pickTicks` with x = 0, 20, 40, 300, 310 (`keep` on 0, 300, 310) and `minGap: 48` returns labels for 0, 300, 310 only. +- [ ] **Step 2: Run** `npx playwright test e2e/home-math.spec.ts` — Expected: FAIL (module not found). +- [ ] **Step 3: Implement** both in `lib/chart.ts`. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: smooth monotone chart path and tick picking`. + +### Task 3: Hero spend card v2 + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/spend-card.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Consumes: `PageCard`, `smoothPath`, `pickTicks`; existing props of `SpendCard` plus `onUpload: () => void`. +- Title (`TITLE` map): `"Spent in "` for 1M (month from `period.start`), `"Spent this week"`, `"Spent this quarter"`, `"Spent in "`; `href="/spending"`. + +- [ ] **Step 1: Write failing tests** in `e2e/home.spec.ts` (use the existing home fixture with transactions and forecast): + - `hero shows delta, percent and the forecast total`: text matches `/\+₹[\d,]+ \(\d+%\) vs \w+ by the \d+(st|nd|rd|th)/` and `/heading for ₹[\d,]+/`. + - `no forecast, no heading-for`: fixture forecast dates all in an earlier month → no `heading for` text and no `[data-series="forecast"]`. + - `ticks never overlap at phone width`: viewport 375×812; the bounding boxes of all `[data-testid="tick"]` are pairwise non-overlapping. + - `the + button opens upload`: click `getByRole("button", { name: "Upload a receipt" })` inside `[data-testid="spend-card"]` → a dialog is visible. + - keep the existing range-change bounding-box test green. +- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "hero|ticks|\\+ button"` — Expected: FAIL. +- [ ] **Step 3: Implement**: number 36px medium; delta line `+₹X (Y%) vs by the ` (bad colour when up, good when down; for 3M/YTD "vs last period"/"vs "); `· heading for ` only when the forecast is drawn; legend (Month / Previous / Forecast) in `extra`; `+` icon button (`aria-label="Upload a receipt"`) in `action`; paths via `smoothPath`; chart height 200px; bill pins become 22px round markers on the x-axis with the vendor's first letter and `title={vendor}`; ticks via `pickTicks` with `data-testid="tick"`, `minGap` 48; range chips centred below. +- [ ] **Step 4: Run** `e2e/home.spec.ts` — Expected: PASS. +- [ ] **Step 5: Commit** `feat: Home hero card in the Origin style`. + +### Task 4: "Month, day by day" calendar + recent transactions + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/calendar-card.tsx`, `frontend/src/lib/home-math.ts` (export `ordinal(n)` if Task 3 didn't) +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Consumes: `PageCard`; new prop `reminders: Reminder[]`, `today: Date`. +- Produces: `vendorChip(name: string): { initials: string; hue: number }` in `lib/home-math.ts` (2 initials, stable hue from the name) — reused by Spending/Bills. + +- [ ] **Step 1: Write failing tests**: `calendar marks today and names future bills` — the cell for today's date has `data-today="true"`; a fixture reminder dated after today puts its vendor text in that day's cell; future cells have `data-future="true"`; the card title links to `/spending`; `See all` in Recent transactions links to `/spending`. `vendorChip("Swiggy Instamart")` → `initials: "SI"` and the same hue on two calls (home-math.spec). +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement** per `home.html`: title `", day by day"`, month total + "across N days" (days with spend), 50px cells with day number top and short amount bottom, heat shade as today, today outlined in primary, future cells dashed with up to one bill name; recent rows with a 28px initials chip from `vendorChip`. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: day-by-day calendar with amounts and bill names`. + +### Task 5: Lumen found as a list, Ask prefill + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/lumen-found.tsx`, `frontend/src/app/(app)/ask/chatbotContent.tsx`, `frontend/src/components/ask/prompt-bar.tsx` (accept an initial value if it doesn't) +- Test: `frontend/e2e/home.spec.ts`, `frontend/e2e/ask.spec.ts` + +**Interfaces:** +- Produces: `/ask?draft=` — opens a fresh chat with the composer filled with ``, focused, **not sent**; the URL is cleaned (`router.replace("/ask")`) so a reload doesn't refill. Used by Lumen found "Ask why", Spending, and Bills "How to cancel". +- Produces: `askWhy(item: FoundItem, money): string` in `lumen-found.tsx` — `"Why " + headline (filled, lower-cased first letter, no trailing period) + "?"`. + +- [ ] **Step 1: Write failing tests**: + - home: `Lumen found is a list with doing links` — each item is a row with an icon, headline, detail, a primary link to `item.href` and an "Ask why" link whose href starts with `/ask?draft=`. + - ask: `draft prefills without sending` — goto `/ask?draft=Why%20is%20Food%20up%3F`; composer value is `Why is Food up?`; no `/chat` POST happened (count requests in the fixture route); URL becomes `/ask`; reload → composer empty. +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement** list layout per `home.html` (32px tinted icon by `kind`: duplicate=bad, spike=warn, price_rise/review=primary; title row pill "N to look at"); `?draft=` handling next to the existing `?q=` effect, guarded by the same `lastQ`-style ref. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: Lumen found list with real actions; /ask?draft= prefills`. + +### Task 6: Budget card v2, Coming up, Home layout + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/budget-card.tsx`, `bills-card.tsx`, `homeContent.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Consumes: `BudgetState` from `lib/api/client.ts` (unchanged), `PageCard`, `vendorChip`. + +- [ ] **Step 1: Write failing tests**: + - `budget card shows spent of total, percent and top categories`: with a fixture budget of 4+ categories → text `/₹[\d,]+ of ₹[\d,]+/`, a `%` label, exactly 4 `[data-testid="budget-cat"]` rows ordered by spent desc; an over-budget category row has `data-over="true"`. + - `empty account Home`: fixture with no transactions/budget/reminders → no text matching `/NaN|of ₹0\b/`; every card's box height equals its loading skeleton height ±1px (record during loading via a delayed route, compare after). + - `coming up lists the next four bills`: 6 upcoming reminders → 4 rows with date chips (weekday + day). + - existing draft-budget tests stay green (promo still opens the draft dialog in 5a; 5d moves it). +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement** per `home.html`: layout = hero full width, then `grid lg:grid-cols-[minmax(0,1fr)_380px]`: left = calendar card, Lumen found; right = Budget in , Coming up, Activity (kept at the bottom for now). Budget: big spent of total, green bar + %, then top 4 category bars (green / amber ≥90% / red over). +- [ ] **Step 4: Run** full `e2e/home.spec.ts` — Expected: PASS. +- [ ] **Step 5: Commit** `feat: Home v2 layout, budget and coming-up cards`. + +### Task 7: Upload and Export restyle, light upload dialog + +**Files:** +- Modify: `frontend/src/components/uploadDialog.tsx`, `frontend/src/components/exportDialog.tsx`, `homeContent.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Produces: `DialogDemo` gains `trigger?: ReactNode` (default: ``) and `open`/`onOpenChange` passthrough so the hero `+` (Task 3) and `?upload=1` open the same dialog. `ExportDialog` default trigger = `Button variant="quiet" size="pill-icon" aria-label="Export"` with a download icon. + +- [ ] **Step 1: Write failing tests**: `upload dialog is light` — open it; computed `background-color` of `[role="dialog"]` has luminance > 0.9 (parse rgb); no element inside has class matching `neutral-9|#0b0612`. `Home buttons are quiet` — the Upload button's computed background is not `rgb(37, 99, 235)` (blue-600). +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement**: dialog on Sky tokens (`bg-card`, `border-border`, `text-foreground/muted-foreground`), drop zone `border-dashed border-border bg-muted/40`, file rows `bg-muted/50`; keep all upload logic untouched. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: quiet Upload/Export and a light upload dialog`. + +### Task 8: Ask and chat §12 fixes + +**Files:** +- Modify: `frontend/src/app/(app)/ask/thread-rail.tsx`, `frontend/src/components/app-sidebar.tsx` (render `AskPalette` outside ``; lift `paletteOpen` into `app-shell.tsx` or a tiny context), `backend/routes/chat.py` (`_thread_for` + `_save_exchange` in one commit), `backend/api/agent.py` (409 text) +- Test: `frontend/e2e/ask.spec.ts`, `frontend/e2e/shell.spec.ts`, `backend/tests/test_chat_threads.py` (or the existing chat test module), `backend/tests/test_agent_api.py` (existing proposals tests) + +**Interfaces:** +- Backend: `_thread_for(user_id, thread_id, question)` no longer commits; the `/chat` handler commits once after `_save_exchange`. On a lost claim, both 409s say `"This proposal was already decided."`. + +- [ ] **Step 1: Write failing tests**: + - e2e ask: `rail actions wait for the answer` — with a delayed `/chat` route, while pending the thread's Delete button is disabled; opening the delete confirm moves focus to "Keep"; at 375px with the history sheet open the trash button is visible without hover. + - e2e shell: `ctrl+k works on a tablet with the sheet closed` — viewport 768×1024 (below the md sheet breakpoint if 768 is still sheet; else 767), press Control+k → palette dialog visible. + - backend: `test_chat_save_failure_leaves_no_empty_thread` — monkeypatch the message save to raise; after the failed POST, the user has 0 threads. + - backend: `test_lost_claim_says_already_decided` — approve twice concurrently (or claim then approve) → 409 with detail `"This proposal was already decided."`. +- [ ] **Step 2: Run** them — Expected: FAIL. +- [ ] **Step 3: Implement** the four fixes. +- [ ] **Step 4: Run** `e2e/ask.spec.ts e2e/shell.spec.ts` and backend `pytest -q` — Expected: PASS. +- [ ] **Step 5: Commit** `fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit chat save, clear 409`. + +### Task 9: 5a docs, full checks, PR + +- [ ] **Step 1:** STORY.md entry `## 32.` (owner review round + Home v2: what was flagged, what changed, test counts), CODEBASE-GUIDE: `components/page/*`, `lib/chart.ts`, `/ask?draft=`; spec §12: tick the Phase 5 items done in 5a. +- [ ] **Step 2:** Run backend `pytest -q`, `pytest -q -m eval`; frontend `npm run lint`, `npm run build` (dummy env), `npm run test:e2e`. Expected: all green. +- [ ] **Step 3:** Screenshot `/home` at 1440 and 375 with seeded fixture data (throwaway `e2e/tmp/shot.spec.ts`) and compare with `home.html` side by side; fix visible drift. +- [ ] **Step 4:** Push `ui-revamp/phase5-pages`, open PR into `refactor` (or stacked on `ui-revamp/polish` until #96 merges) with manual checks: Home matches the mockup on your seeded account; hero `+` and Upload open the same light dialog; "Ask why" fills Ask without sending; phone width has no overlapping ticks. + +--- + +## Phase 5b — Spending (outline; detail when reached) +Backend: none expected (transactions, categories, `/api/home/summary` findings exist); confirm category-update endpoint for the hover edit. Tasks: period control + compare toggle (layout-shift test); hero (cumulative + month bars inset; 3M/YTD bars as main chart); By category bars with click-to-filter (`?category=`); Lumen noticed (summary findings, no "first time" card); Transactions table (search, filters, hover category edit, `vendorChip`), Top merchants; Export CSV of the filtered view; remove the stacked old pages (single `

`, no calendar/reminders). STORY 33. + +## Phase 5c — Bills & subscriptions (outline) +Backend (TDD): `RecurringState` table (`user_id`, `vendor_key`, `state`, `changed_at`); `GET /api/bills/recurring` (wraps `find_recurring`, applies states: `not_a_bill` hidden, `cancelled` dimmed unless charged after `changed_at`); `POST /api/bills/recurring/state {vendor, state}`; Home's Coming up switches to it. Frontend: tiles, month calendar + Upcoming agenda (This week / Next week / Later), subscriptions table with tabs and actions (Mark as cancelled, Not a bill + Hidden undo, How to cancel → `/ask?draft=How do I cancel ?`). STORY 34. + +## Phase 5d — Budget page (outline) +Backend (TDD): `Budget.effective_from` (migration + create_all column list; existing rows → created month); `get_budget(month)` = latest row per category with `effective_from <= month`; `GET /api/budget?month=YYYY-MM`, `GET /api/budget/history?months=6`; edits write rows effective from the current month; draft approval writes rows effective from the draft's month; `basis.months` only months with spend; draft buttons' accessible names include the category. Frontend: `/budget` page + sidebar Plan group; hero with even-pace tick and pace chart; category table with inline edit (no row height change); draft panel on the page (Home promo links here); last 6 months bars. STORY 35. + +## Phase 5e — Receipts & statements (outline) +Backend (TDD): `StatementImport` table + write on import; `GET /api/statements/imports`. Frontend: header actions; Needs-your-review cards from the review queue with highlights from validation flags (replaces the Review tab); library list with filter chips (Statements = import rows) and search; Statement imports card; From your inbox card; Activity moves here from Home if it no longer fits. Old `/documents`, `/import`, `/review` routes redirect into `/receipts`. STORY 36, then the whole-branch review for Phase 5. From ff30d4ffa3c43bec1336bc7b827fba33dba9a461 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:46:34 +0530 Subject: [PATCH 03/18] feat: shared page header and card for the Phase 5 pages --- frontend/e2e/home.spec.ts | 16 ++++++++++++ .../(app)/home/_components/activity-card.tsx | 6 ++--- .../app/(app)/home/_components/bills-card.tsx | 6 ++--- .../src/app/(app)/home/_components/brief.tsx | 6 ++--- .../(app)/home/_components/budget-card.tsx | 6 ++--- .../(app)/home/_components/calendar-card.tsx | 6 ++--- .../(app)/home/_components/lumen-found.tsx | 6 ++--- .../app/(app)/home/_components/spend-card.tsx | 6 ++--- frontend/src/app/(app)/home/homeContent.tsx | 24 ++++++++--------- .../page/page-card.tsx} | 25 +++++++++++++----- frontend/src/components/page/page-header.tsx | 26 +++++++++++++++++++ frontend/src/components/ui/button.tsx | 4 +++ 12 files changed, 96 insertions(+), 41 deletions(-) rename frontend/src/{app/(app)/home/_components/card.tsx => components/page/page-card.tsx} (52%) create mode 100644 frontend/src/components/page/page-header.tsx diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 9caaceb..308af4f 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -333,3 +333,19 @@ test("a replaced draft says so", async ({ page }) => { await page.waitForTimeout(300); expect(approvals).toBe(1); }); + +const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) => + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; + +test("home header keeps clear of the account menu", async ({ page }) => { + for (const [width, height] of [[1440, 900], [1024, 800]]) { + await page.setViewportSize({ width, height }); + await openHome(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveCSS("font-size", "26px"); + const menu = (await page.getByRole("button", { name: "Account menu" }).boundingBox())!; + for (const name of [/upload/i, /^export$/i]) { + const box = (await page.getByRole("button", { name }).first().boundingBox())!; + expect(overlaps(menu, box), `${String(name)} at ${width}px`).toBe(false); + } + } +}); diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index 5287f27..7475e7a 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -2,13 +2,13 @@ import Link from "next/link"; import type { ActivityItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { fill, relativeTime } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // What happened lately: statement imports, receipts read, your decisions (GET /api/home/summary). export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; now: Date; loading: boolean }) { const money = useMoney(); return ( - + {items.length === 0 ? (

Nothing imported yet.{" "} @@ -25,6 +25,6 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n )) )} - + ); } diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx index 214bbd1..1989478 100644 --- a/frontend/src/app/(app)/home/_components/bills-card.tsx +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -1,7 +1,7 @@ import { useMoney } from "@/lib/format"; import { parseDay } from "@/lib/home-math"; import type { Reminder } from "../use-home-data"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // The next three recurring charges Lumen expects. export function BillsCard({ reminders, today, loading }: { reminders: Reminder[]; today: Date; loading: boolean }) { @@ -11,7 +11,7 @@ export function BillsCard({ reminders, today, loading }: { reminders: Reminder[] .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 3); return ( - + {next.length === 0 ? (

No bills coming up in the next month.

) : ( @@ -29,6 +29,6 @@ export function BillsCard({ reminders, today, loading }: { reminders: Reminder[] ); }) )} -
+ ); } diff --git a/frontend/src/app/(app)/home/_components/brief.tsx b/frontend/src/app/(app)/home/_components/brief.tsx index 190c266..a7cf827 100644 --- a/frontend/src/app/(app)/home/_components/brief.tsx +++ b/frontend/src/app/(app)/home/_components/brief.tsx @@ -3,11 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton"; // The one- or two-sentence brief, in the serif. Reserved height = two lines, so it never pushes the cards down. export function Brief({ text }: { text: string | null }) { return ( -
+
{text === null ? ( - + ) : ( -

+

{text}

)} diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index 9e0737f..4d3b9d0 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -6,7 +6,7 @@ import { useMoney } from "@/lib/format"; import { toast } from "@/lib/toast"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // Overall budget = the profile's monthly budget (onboarding); per-category limits come from approved set_budget // proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so. @@ -18,7 +18,7 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg const daysLeft = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate() + 1; return ( - + {total ? ( <>
@@ -51,7 +51,7 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg ))}
)} -
+ ); } diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 8d66590..2324b7e 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -1,6 +1,6 @@ import { useMoney } from "@/lib/format"; import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // "₹2.2k" in the user's own currency: take the symbol from how useMoney prints zero const kShort = (n: number, symbol: string) => (n >= 1000 ? `${symbol}${(n / 1000).toFixed(1)}k` : `${symbol}${Math.round(n)}`); @@ -16,7 +16,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P const recent = [...txns].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 5); return ( - +
{Array.from({ length: lead }, (_, i) => ( @@ -56,6 +56,6 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P )}
-
+ ); } diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx index a986f88..e528ed2 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import type { HomeSummary } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { fill } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // "Lumen found": duplicates, receipts to review, spending spikes and price rises (GET /api/home/summary, rule-based). // A money "worth" is only claimed for things you may get back (duplicates). @@ -11,7 +11,7 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null; const found = summary?.found ?? []; const total = found.length + (summary?.more ?? 0); return ( - + {found.length === 0 ? (

Nothing needs you right now.

) : ( @@ -39,6 +39,6 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null;
)} - + ); } diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index a10fbfa..b8bbe03 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -6,7 +6,7 @@ import { useMoney } from "@/lib/format"; import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; import { cn } from "@/lib/utils"; import type { Forecast, Reminder } from "../use-home-data"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; const RANGES: Range[] = ["1W", "1M", "3M", "YTD"]; const TITLE: Record = { "1W": "Spent this week", "1M": "Spent this month", "3M": "Spent this quarter", YTD: "Spent this year" }; @@ -95,7 +95,7 @@ export function SpendCard({ range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point"; return ( - +
@@ -182,6 +182,6 @@ export function SpendCard({ ))}
- + ); } diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index ca06636..52ddc4e 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -8,6 +8,7 @@ import { ExportDialog } from "@/components/exportDialog"; import type { FoundItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { briefSentence, cumulative, dayIndex, fill, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; +import { PageHeader } from "@/components/page/page-header"; import { Brief } from "./_components/brief"; import { ActivityCard } from "./_components/activity-card"; import { BillsCard } from "./_components/bills-card"; @@ -68,19 +69,16 @@ export default function HomeContent() {
- {/* md:pr-12 leaves room for the floating account menu (app-shell.tsx) */} -
-

- {firstName ? `Good ${partOfDay(new Date().getHours())}, ${firstName}` : "Welcome back"} -

-
- - -
-
-
- -
+ } + actions={ + <> + + + + } + />
-
+
+
{href ? ( - {title} › + {title} › ) : (

{title}

)} + {extra} {action &&
{action}
}
{loading ? :
{children}
} diff --git a/frontend/src/components/page/page-header.tsx b/frontend/src/components/page/page-header.tsx new file mode 100644 index 0000000..38a4480 --- /dev/null +++ b/frontend/src/components/page/page-header.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +// Every Phase 5 page opens the same way (spec 2026-10-22 §3): title and quiet actions on one row, the serif brief +// under it, then an optional control row. md:pr-12 leaves room for the floating account menu (app-shell.tsx). +export function PageHeader({ + title, + brief, + actions, + children, +}: { + title: ReactNode; + brief?: ReactNode; + actions?: ReactNode; + children?: ReactNode; +}) { + return ( +
+
+

{title}

+ {actions &&
{actions}
} +
+ {brief &&
{brief}
} + {children &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 24e29f1..0968e6d 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -18,6 +18,9 @@ const buttonVariants = cva( "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", + // Phase 5 pages: light hairline actions, and one dark primary per page (no loud blue) + quiet: "border border-border bg-card/70 text-foreground/80 backdrop-blur hover:bg-card hover:text-foreground", + ink: "bg-foreground text-background hover:bg-foreground/90", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", @@ -26,6 +29,7 @@ const buttonVariants = cva( icon: "size-9", "icon-sm": "size-8", "icon-lg": "size-10", + "pill-icon": "size-[34px] rounded-full", }, }, defaultVariants: { From ce1416e53516d62de33cf025bcf257c58233360a Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:50:37 +0530 Subject: [PATCH 04/18] feat: smooth monotone chart path and tick picking --- frontend/e2e/home-math.spec.ts | 40 ++++++++++++++++++++++++++++ frontend/src/lib/chart.ts | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 frontend/src/lib/chart.ts diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts index 00fec18..c0a3979 100644 --- a/frontend/e2e/home-math.spec.ts +++ b/frontend/e2e/home-math.spec.ts @@ -1,5 +1,6 @@ // Node-only unit tests (no browser) for the Home calculations. import { test, expect } from "@playwright/test"; +import { pickTicks, smoothPath } from "../src/lib/chart"; import { briefSentence, fill, @@ -153,3 +154,42 @@ test("suggestedQuestions maps found items, dedupes, caps at 4", () => { ]); expect(suggestedQuestions([], ["A", "B"])).toEqual(["A", "B"]); }); + +// ---- lib/chart.ts (Phase 5) ---- + +// y of each cubic segment of a path "M x y C x1 y1 x2 y2 x y C …" sampled at t +function samples(d: string, ts = [0.25, 0.5, 0.75]): number[] { + const n = d.match(/-?\d+(\.\d+)?/g)!.map(Number); + let [x0, y0] = [n[0], n[1]]; + const out: number[] = []; + for (let i = 2; i + 5 < n.length + 1; i += 6) { + const [, c1, , c2, x, y] = n.slice(i, i + 6); + for (const t of ts) out.push((1 - t) ** 3 * y0 + 3 * (1 - t) ** 2 * t * c1 + 3 * (1 - t) * t ** 2 * c2 + t ** 3 * y); + [x0, y0] = [x, y]; + } + void x0; + return out; +} + +test("smoothPath passes through every point", () => { + const d = smoothPath([[0, 10], [10, 20], [20, 20], [30, 40]]); + expect(d.startsWith("M0 10")).toBe(true); + expect(d).toContain("20 20"); + expect(d.endsWith("30 40")).toBe(true); + expect(smoothPath([])).toBe(""); + expect(smoothPath([[5, 6]])).toBe("M5 6"); +}); + +test("smoothPath never overshoots a running total", () => { + const ys = samples(smoothPath([[0, 0], [10, 100], [20, 100], [30, 101]])); + for (let i = 0; i < ys.length; i++) { + expect(ys[i]).toBeLessThanOrEqual(101 + 1e-9); + if (i) expect(ys[i]).toBeGreaterThanOrEqual(ys[i - 1] - 1e-9); + } +}); + +test("pickTicks drops ticks that crowd a kept one", () => { + const t = (x: number, keep = false) => ({ i: x, x, label: String(x), keep }); + expect(pickTicks([t(0, true), t(20), t(40), t(300, true), t(310, true)], 48).map((k) => k.label)).toEqual(["0", "300", "310"]); + expect(pickTicks([t(0, true), t(60), t(120), t(200, true)], 48).map((k) => k.label)).toEqual(["0", "60", "120", "200"]); +}); diff --git a/frontend/src/lib/chart.ts b/frontend/src/lib/chart.ts new file mode 100644 index 0000000..77e59e9 --- /dev/null +++ b/frontend/src/lib/chart.ts @@ -0,0 +1,48 @@ +// Small chart helpers shared by the Phase 5 pages (spec 2026-10-22 §3). + +const n = (v: number) => +v.toFixed(2); + +// A smooth SVG path through every point: monotone cubic (Fritsch–Carlson), so a running total never dips or +// overshoots between two days the way a plain Catmull-Rom curve does. +export function smoothPath(points: [number, number][]): string { + if (!points.length) return ""; + const [x0, y0] = points[0]; + if (points.length === 1) return `M${n(x0)} ${n(y0)}`; + const last = points.length - 1; + const m = points.slice(0, last).map(([x, y], k) => (points[k + 1][1] - y) / (points[k + 1][0] - x || 1)); + const t = points.map((_, k) => (k === 0 ? m[0] : k === last ? m[last - 1] : m[k - 1] * m[k] <= 0 ? 0 : (m[k - 1] + m[k]) / 2)); + for (let k = 0; k < last; k++) { + if (m[k] === 0) { + t[k] = t[k + 1] = 0; + continue; + } + const a = t[k] / m[k]; + const b = t[k + 1] / m[k]; + const s = a * a + b * b; + if (s > 9) { + const tau = 3 / Math.sqrt(s); + t[k] = tau * a * m[k]; + t[k + 1] = tau * b * m[k]; + } + } + let d = `M${n(x0)} ${n(y0)}`; + for (let k = 0; k < last; k++) { + const [xa, ya] = points[k]; + const [xb, yb] = points[k + 1]; + const h = (xb - xa) / 3; + d += ` C${n(xa + h)} ${n(ya + t[k] * h)} ${n(xb - h)} ${n(yb - t[k + 1] * h)} ${n(xb)} ${n(yb)}`; + } + return d; +} + +// Axis labels that never overlap: `keep` ticks (first, today, last) always stay; any other tick is dropped when it +// sits closer than `minGap` px to a tick already shown. +export function pickTicks(candidates: T[], minGap: number): T[] { + const kept = candidates.filter((c) => c.keep); + const shown = [...kept]; + for (const c of candidates) { + if (c.keep) continue; + if (shown.every((s) => Math.abs(s.x - c.x) >= minGap)) shown.push(c); + } + return candidates.filter((c) => shown.includes(c)); +} From 877f4447394ad446d53cfbbfffe4794114d3e3f8 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:02:20 +0530 Subject: [PATCH 05/18] feat: Home hero card in the Origin style --- frontend/e2e/home.spec.ts | 39 ++- .../app/(app)/home/_components/spend-card.tsx | 249 ++++++++++++------ frontend/src/app/(app)/home/homeContent.tsx | 9 +- frontend/src/components/uploadDialog.tsx | 20 +- frontend/src/lib/home-math.ts | 3 + 5 files changed, 233 insertions(+), 87 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 308af4f..e8d32e7 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -68,20 +68,23 @@ test("old dashboard is gone", async ({ page }) => { }); const spend = (page: Page) => page.getByTestId("spend-card"); -const prevShort = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "short" }); +const prevLong = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "long" }); +const monthLong = new Date(Y, M, 1).toLocaleString("en-US", { month: "long" }); +const ord = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`; const lastDay = new Date(Y, M + 1, 0).getDate(); test("spend card shows total and delta", async ({ page }) => { await openHome(page); await expect(spend(page).getByText("₹18,412", { exact: true })).toBeVisible(); - await expect(spend(page).getByTestId("spend-delta")).toHaveText(`↓ ₹1,868 vs ${prevShort} at this point`); + await expect(spend(page).getByTestId("spend-delta")).toHaveText(`−₹1,868 (9%) vs ${prevLong} by the ${ord(now.getDate())}`); }); test("range chips switch period", async ({ page }) => { await openHome(page); - await expect(spend(page)).toContainText("SPENT THIS MONTH", { ignoreCase: true }); + await expect(spend(page)).toContainText(`Spent in ${monthLong}`); + await expect(spend(page).getByRole("link", { name: new RegExp(`Spent in ${monthLong}`) })).toHaveAttribute("href", "/spending"); await spend(page).getByRole("button", { name: "1W" }).click(); - await expect(spend(page)).toContainText("SPENT THIS WEEK", { ignoreCase: true }); + await expect(spend(page)).toContainText("Spent this week"); await expect(spend(page).getByRole("button", { name: "1W" })).toHaveAttribute("aria-pressed", "true"); }); @@ -96,6 +99,9 @@ test("forecast draws to month end", async ({ page }) => { const dates = Array.from({ length: 31 }, (_, i) => ymd(Y, M, now.getDate() + 1 + i)); await openHome(page, { ...typical, forecast: { dates, values: dates.map(() => 100) } }); await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(1); + // 18,412 so far + 100 for each day left in the month + const left = lastDay - now.getDate(); + await expect(spend(page)).toContainText(`heading for ₹${(18412 + 100 * left).toLocaleString("en-IN")}`); }); test("bill pins show upcoming bills", async ({ page }) => { @@ -104,7 +110,8 @@ test("bill pins show upcoming bills", async ({ page }) => { ...typical, reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(now.getDate() + 1), type: "upcoming" }], }); - await expect(spend(page).getByTestId("bill-pin")).toHaveText("Netflix"); + await expect(spend(page).getByTestId("bill-pin")).toHaveAttribute("title", "Netflix"); + await expect(spend(page).getByTestId("bill-pin")).toHaveText("N"); }); test("new user sees empty states", async ({ page }) => { @@ -228,6 +235,7 @@ test("a forecast with no matching days draws no line", async ({ page }) => { await openHome(page, { ...typical, forecast: { dates: [lastMonth(2), lastMonth(3)], values: [500, 500] } }); await expect(spend(page).locator('[data-series="current"]')).toHaveCount(1); await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(0); + await expect(spend(page)).not.toContainText("heading for"); }); const dupe = { kind: "duplicate", tag: "DUPLICATES", headline: "Possible double charge: Croma {0}", detail: "Charged on 14 Oct and 15 Oct.", @@ -349,3 +357,24 @@ test("home header keeps clear of the account menu", async ({ page }) => { } } }); + +test("hero shows an increase with its percent", async ({ page }) => { + await openHome(page, { txns: [tx("a", "Croma", thisMonth(1), 30000), tx("b", "Rent", lastMonth(1), 20000)] }); + await expect(spend(page).getByTestId("spend-delta")).toHaveText(`+₹10,000 (50%) vs ${prevLong} by the ${ord(now.getDate())}`); +}); + +test("chart ticks never overlap at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openHome(page); + await expect(spend(page).getByTestId("tick").first()).toBeVisible(); + const boxes = await spend(page).getByTestId("tick").evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON())); + expect(boxes.length).toBeGreaterThanOrEqual(2); + for (let i = 0; i < boxes.length; i++) + for (let j = i + 1; j < boxes.length; j++) expect(overlaps(boxes[i], boxes[j]), `ticks ${i} and ${j}`).toBe(false); +}); + +test("the hero + opens upload", async ({ page }) => { + await openHome(page); + await spend(page).getByRole("button", { name: "Upload a receipt" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); +}); diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index b8bbe03..2419abd 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -2,18 +2,31 @@ import { useEffect, useState } from "react"; import { animate } from "motion/react"; +import { Plus } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import { pickTicks, smoothPath } from "@/lib/chart"; import { useMoney } from "@/lib/format"; -import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; +import { dayIndex, ordinal, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; import { cn } from "@/lib/utils"; import type { Forecast, Reminder } from "../use-home-data"; -import { PageCard } from "@/components/page/page-card"; const RANGES: Range[] = ["1W", "1M", "3M", "YTD"]; -const TITLE: Record = { "1W": "Spent this week", "1M": "Spent this month", "3M": "Spent this quarter", YTD: "Spent this year" }; -const W = 860; -const H = 160; -const short = (d: Date) => d.toLocaleString("en-US", { month: "short" }); // "Sep", not en-GB's "Sept" -const tick = (d: Date) => d.toLocaleString("en-GB", { day: "2-digit", month: "short" }); +const W = 1000; +const H = 200; +const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); +const dayMonth = (d: Date) => d.toLocaleString("en-GB", { day: "numeric", month: "short" }); + +function title(range: Range, period: Period) { + if (range === "1M") return `Spent in ${monthLong(period.start)}`; + if (range === "YTD") return `Spent in ${period.start.getFullYear()}`; + return range === "1W" ? "Spent this week" : "Spent this quarter"; +} + +function compared(range: Range, previous: Period, today: Date) { + if (range === "1M") return `vs ${monthLong(previous.start)} by the ${ordinal(today.getDate())}`; + if (range === "YTD") return `vs ${previous.start.getFullYear()} at this point`; + return range === "1W" ? "vs last week at this point" : "vs last quarter at this point"; +} // Counts up once per browser session, then shows the number straight away (motion rule: no replay on revisits). function CountUp({ value, format }: { value: number; format: (n: number) => string }) { @@ -32,9 +45,26 @@ function CountUp({ value, format }: { value: number; format: (n: number) => stri return <>{format(shown)}; } +// The width the tick labels are laid out in, so they can be spaced in real pixels at any screen size. A callback +// ref: the axis only exists once the card has data, after the first render. +function useWidth() { + const [el, setEl] = useState(null); + const [width, setWidth] = useState(800); + useEffect(() => { + if (!el) return; + const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, [el]); + return [setEl, width] as const; +} + +// Home's hero (spec 2026-10-22 §4.1, Origin style): the period's total, how it compares, where it's heading, and the +// running total as a smooth line against the previous period, with upcoming bills pinned on the axis. export function SpendCard({ range, onRange, + onUpload, period, previous, cur, @@ -46,6 +76,7 @@ export function SpendCard({ }: { range: Range; onRange: (r: Range) => void; + onUpload: () => void; period: Period; previous: Period; cur: number[]; @@ -56,6 +87,7 @@ export function SpendCard({ loading: boolean; }) { const money = useMoney(); + const [axis, width] = useWidth(); const t = Math.min(Math.max(dayIndex(period, today), 0), period.days - 1); const total = cur[t] ?? 0; const delta = sameDayDelta(cur, prev, t); @@ -78,9 +110,9 @@ export function SpendCard({ const empty = total === 0 && !prev.some((v) => v > 0); const max = Math.max(total, prev[prev.length - 1] ?? 0, fc[fc.length - 1] ?? 0, 1); const x = (i: number) => (period.days > 1 ? (i / (period.days - 1)) * W : 0); - const y = (v: number) => H - (v / max) * (H - 20); - const line = (vals: number[], from = 0) => vals.map((v, i) => `${i ? "L" : "M"}${x(i + from).toFixed(1)} ${y(v).toFixed(1)}`).join(" "); - const actual = cur.slice(0, t + 1); + const y = (v: number) => H - (v / max) * (H - 16); + const pts = (vals: number[], from = 0) => vals.map((v, i) => [x(i + from), y(v)] as [number, number]); + const actual = pts(cur.slice(0, t + 1)); const pins = reminders.filter((r) => { if (r.type !== "upcoming") return false; @@ -88,83 +120,146 @@ export function SpendCard({ return i > t && i < period.days; }); - const ticks = [0, Math.round((period.days - 1) * 0.25), Math.round((period.days - 1) * 0.5), t, period.days - 1] - .filter((v, i, a) => a.indexOf(v) === i) - .sort((a, b) => a - b); - const vsLabel = - range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point"; + // first, today and last always show unless they'd crowd today; the weekly ones in between only where they fit + const px = (i: number) => (x(i) / W) * width; + const minGap = 48; + const ticks = pickTicks( + Array.from({ length: period.days }, (_, i) => i) + .filter((i) => i === 0 || i === t || i === period.days - 1 || i % 7 === 0) + .map((i) => ({ + i, + x: px(i), + keep: i === t || ((i === 0 || i === period.days - 1) && Math.abs(px(i) - px(t)) >= minGap), + label: i === t ? "Today" : i === 0 || i === period.days - 1 ? dayMonth(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i)) : String(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i).getDate()), + })), + minGap, + ); + + const legend = ( + + + + Now + + + + Before + + {fc.length > 0 && ( + + + Forecast + + )} + + ); return ( - -
-
-
- money(n)} /> -
-
- {delta && Math.round(delta.abs) !== 0 && ( - - {delta.abs < 0 ? "↓" : "↑"} {money(Math.abs(Math.round(delta.abs)))} {vsLabel} - - )} -
-
-
- — now - ┄ before - {fc.length > 0 && · forecast} -
+ + + + } + loading={loading} + skeletonHeight={318} + className="relative" + data-testid="spend-card" + > +
+ money(n)} /> +
+
+ {delta && Math.round(delta.abs) !== 0 && ( + + + {delta.abs < 0 ? "−" : "+"} + {money(Math.abs(Math.round(delta.abs)))} ({Math.abs(Math.round(delta.pct))}%) + {" "} + {compared(range, previous, today)} + + )} + {fc.length > 0 && ( + + {delta && Math.round(delta.abs) !== 0 && " · "}heading for{" "} + {money(Math.round(fc[fc.length - 1]))} + + )}
{empty ? ( -
+

Nothing spent yet this {range === "1W" ? "week" : range === "1M" ? "month" : range === "3M" ? "quarter" : "year"}. Upload a receipt or import a statement and your spending will show up here.

) : ( - <> -
- - - - - - - - {prev.length > 1 && ( - - )} - - - {fc.length > 0 && ( - - )} - - - {/* HTML overlays keep text and dots crisp while the svg stretches */} - - {pins.map((p) => { - const i = dayIndex(period, parseDay(p.date)); - return ( - - - {p.vendor} + <> +
+ + + + + + + + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + {prev.length > 1 && ( + + )} + + + {fc.length > 0 && ( + + )} + + + {/* HTML overlays keep text and dots crisp while the svg stretches */} + + {pins.map((p) => { + const i = dayIndex(period, parseDay(p.date)); + return ( + + {p.vendor.charAt(0).toUpperCase()} + + ); + })} +
+
+ {ticks.map(({ i, label }) => ( + + {label} - - - ); - })} -
-
- {ticks.map((i) => ( - - {i === t ? "today" : tick(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i))} - - ))} -
- + ))} +
+ )}
{RANGES.map((r) => ( @@ -174,8 +269,8 @@ export function SpendCard({ aria-pressed={r === range} onClick={() => onRange(r)} className={cn( - "label-ui rounded-md px-2.5 py-1 transition-colors", - r === range ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground", + "label-ui rounded-lg px-3.5 py-1 transition-colors", + r === range ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground", )} > {r} diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 52ddc4e..4c152cb 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useProfile } from "@/components/profile/profile-provider"; import { DialogDemo } from "@/components/uploadDialog"; @@ -38,6 +38,10 @@ export default function HomeContent() { const { profile } = useProfile(); const money = useMoney(); const [range, setRange] = useState("1M"); + const [uploadOpen, setUploadOpen] = useState(openUpload); + useEffect(() => { + if (openUpload) setUploadOpen(true); + }, [openUpload]); const today = useMemo(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()); @@ -75,7 +79,7 @@ export default function HomeContent() { actions={ <> - + } /> @@ -84,6 +88,7 @@ export default function HomeContent() { setUploadOpen(true)} period={chart.current} previous={chart.previous} cur={cumulative(data.txns, chart.current)} diff --git a/frontend/src/components/uploadDialog.tsx b/frontend/src/components/uploadDialog.tsx index 1bed992..8c2df4e 100644 --- a/frontend/src/components/uploadDialog.tsx +++ b/frontend/src/components/uploadDialog.tsx @@ -17,14 +17,28 @@ interface FileWithStatus { extractedData?: any; } -export function DialogDemo({ defaultOpen = false }: { defaultOpen?: boolean }) { +// Pass `open`/`onOpenChange` to open it from elsewhere too (Home's hero +); otherwise it manages itself. +export function DialogDemo({ + defaultOpen = false, + open: openProp, + onOpenChange, +}: { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); - const [open, setOpen] = useState(defaultOpen); + const [ownOpen, setOwnOpen] = useState(defaultOpen); + const open = openProp ?? ownOpen; + const setOpen = (next: boolean) => { + setOwnOpen(next); + onOpenChange?.(next); + }; useEffect(() => { if (defaultOpen) { - setOpen(true); + setOwnOpen(true); } }, [defaultOpen]); diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts index 5a22fe8..5b28fa7 100644 --- a/frontend/src/lib/home-math.ts +++ b/frontend/src/lib/home-math.ts @@ -137,3 +137,6 @@ export function suggestedQuestions(found: { kind: string; subject: string | null const fromFound = found.slice(0, 3).map((f) => ASK_ABOUT[f.kind]?.(f.subject)).filter((q): q is string => !!q); return [...new Set([...fromFound, ...server])].slice(0, 4); } + +// "1st", "22nd", "13th" +export const ordinal = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`; From 1b0c2b4ea74043234d2b61984d1a0b8112f9029f Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:55:46 +0530 Subject: [PATCH 06/18] fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit chat save, clear 409 --- backend/api/agent.py | 7 ++++--- backend/routes/chat.py | 7 +++++-- backend/tests/test_api_agent.py | 3 ++- backend/tests/test_chat_attach.py | 2 +- backend/tests/test_chat_threads.py | 15 ++++++++++++++ frontend/e2e/ask.spec.ts | 22 +++++++++++++++++++++ frontend/e2e/fixtures.ts | 3 +++ frontend/e2e/shell.spec.ts | 9 +++++++++ frontend/src/app/(app)/ask/thread-rail.tsx | 18 +++++++++++++---- frontend/src/components/app-shell.tsx | 21 ++++++++++++++++++-- frontend/src/components/app-sidebar.tsx | 23 +++++----------------- 11 files changed, 99 insertions(+), 31 deletions(-) diff --git a/backend/api/agent.py b/backend/api/agent.py index 45b8e3d..8dd3eb2 100644 --- a/backend/api/agent.py +++ b/backend/api/agent.py @@ -120,7 +120,7 @@ def _save_invoice(proposal_id: str, user_id: str, deps: AgentDeps) -> dict | Non if not session.execute(update(Proposal).where( Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending") .values(status="saving")).rowcount: - raise HTTPException(409, f"Proposal is already {p.status}") + raise HTTPException(409, "This proposal was already decided.") payload = json.loads(p.payload or "{}") try: item = submit_invoice(deps.engine, user_id, payload, warnings_seen=True) @@ -148,12 +148,13 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d if p is None: raise HTTPException(404, "Proposal not found") # claim it first (UPDATE ... WHERE status = 'pending'): of two concurrent decisions only one can apply; - # anything raised below rolls the claim back with the rest of the transaction + # anything raised below rolls the claim back with the rest of the transaction. The 409 doesn't name p.status: + # it was read before the claim, so the loser of a race would still see "pending" now = datetime.utcnow() if not session.execute(update(Proposal).where( Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending") .values(status=decision, decided_at=now).execution_options(synchronize_session=False)).rowcount: - raise HTTPException(409, f"Proposal is already {p.status}") + raise HTTPException(409, "This proposal was already decided.") p.status, p.decided_at = decision, now applied = None if decision == "approved" and p.type == "set_budget": diff --git a/backend/routes/chat.py b/backend/routes/chat.py index 7ef7e04..c8ceb85 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -98,11 +98,12 @@ def _own_thread(user_id: str, thread_id) -> ChatThread: def _thread_for(user_id: str, thread_id: str | None, question: str) -> ChatThread: + """The thread to save into. A new one is only added to the session: _save_exchange commits it together with its + messages, so a failed save leaves no empty titled thread behind.""" if thread_id: return _own_thread(user_id, thread_id) thread = ChatThread(id=str(uuid.uuid4()), user_id=user_id, title=_title(question) or "New chat") db.session.add(thread) - db.session.commit() return thread @@ -193,8 +194,10 @@ def chat(): _save_exchange(user_id, thread.id, f"{query} [attached: {file.filename}]" if file else query, result["response"]) except Exception as e: - # Losing history shouldn't cost the user their answer. + # Losing history shouldn't cost the user their answer. A new thread was rolled back with it: don't hand out its id. db.session.rollback() + if not thread_id: + result.pop("thread_id", None) logger.exception("Failed to save chat history for user=%s: %s", user_id, e) return jsonify({"success": True, "data": result}), 200 diff --git a/backend/tests/test_api_agent.py b/backend/tests/test_api_agent.py index a1dff19..7f2932a 100644 --- a/backend/tests/test_api_agent.py +++ b/backend/tests/test_api_agent.py @@ -86,7 +86,8 @@ def category(): approved = client.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(u1)).json() assert approved["proposal"]["status"] == "approved" and approved["applied"]["transactions_updated"] == 1 assert category() == "Shopping" - assert client.post(f"/api/agent/proposals/{pid}/reject", headers=bearer(u1)).status_code == 409 + late = client.post(f"/api/agent/proposals/{pid}/reject", headers=bearer(u1)) + assert late.status_code == 409 and late.json()["error"] == "This proposal was already decided." with engine.connect() as conn: actions = [r[0] for r in conn.execute(text( "SELECT action FROM audit_events WHERE proposal_id = :p ORDER BY id"), {"p": pid})] diff --git a/backend/tests/test_chat_attach.py b/backend/tests/test_chat_attach.py index dd8f117..80b99f4 100644 --- a/backend/tests/test_chat_attach.py +++ b/backend/tests/test_chat_attach.py @@ -84,7 +84,7 @@ def test_approve_save_invoice_records_once(env): assert out["proposal"]["status"] == "approved" with pytest.raises(HTTPException) as e: _decide(pid, "user-1", "approved", deps) - assert e.value.status_code == 409 + assert (e.value.status_code, e.value.detail) == (409, "This proposal was already decided.") assert _count(engine, ReviewItem) == 1 # one submission (auto-approved: its transaction, else queued for review) assert _count(engine, Transaction) == 1 diff --git a/backend/tests/test_chat_threads.py b/backend/tests/test_chat_threads.py index 842d9fe..30e336d 100644 --- a/backend/tests/test_chat_threads.py +++ b/backend/tests/test_chat_threads.py @@ -51,6 +51,21 @@ def test_post_without_thread_creates_one_titled_from_question(chat): assert not thread["title"].rstrip("…").endswith(" ") +def test_chat_save_failure_leaves_no_empty_thread(chat, monkeypatch): + import routes.chat as chat_routes + + client, post, _, _ = chat + + def boom(*a): + raise RuntimeError("db down") + + monkeypatch.setattr(chat_routes, "_save_exchange", boom) + res = post(A, "Lost to a failed save") + assert res.status_code == 200 # the answer still comes back + assert "thread_id" not in res.get_json()["data"] # no id for a thread that was never saved + assert client.get("/chat/threads", headers=bearer(A)).get_json()["threads"] == [] + + def test_post_with_thread_appends(chat): client, post, _, _ = chat tid = post(A, "First").get_json()["data"]["thread_id"] diff --git a/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts index a81ecd9..a1eeaa0 100644 --- a/frontend/e2e/ask.spec.ts +++ b/frontend/e2e/ask.spec.ts @@ -73,6 +73,28 @@ test("deleting a thread removes it", async ({ page }) => { await expect(rail(page).getByRole("button", { name: /^Bills due/ })).toHaveCount(0); }); +test("rail actions wait for the answer", async ({ page }) => { + await openAsk(page, { ...old, delayMs: 1500 }); + await rail(page).getByRole("button", { name: "Delete chat: Bills due" }).click(); + await expect(rail(page).getByRole("button", { name: "Keep" })).toBeFocused(); + await composer(page).fill("Slow one"); + await composer(page).press("Enter"); + await expect(rail(page).getByRole("button", { name: "Keep" })).toBeDisabled(); + await expect(rail(page).getByRole("button", { name: "Confirm delete" })).toBeDisabled(); + await expect(rail(page).getByRole("button", { name: "Delete chat: Food last month" })).toBeDisabled(); + await expect(page.getByText("Answer to: Slow one")).toBeVisible(); + await expect(rail(page).getByRole("button", { name: "Keep" })).toBeEnabled(); +}); + +test("the trash button shows without hover at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openAsk(page, old); + await page.getByRole("button", { name: "History" }).click(); + const trash = rail(page).getByRole("button", { name: "Delete chat: Bills due" }); + await expect(trash).toBeVisible(); + await expect(trash).toHaveCSS("opacity", "1"); +}); + test("handoff sends once", async ({ page }) => { const posts: string[] = []; page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push(r.postDataJSON().query)); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 64868a8..fd5aa86 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -30,6 +30,8 @@ export type ChatFixture = { /** the "Next:" suggestion POST /chat returns (default none) */ followUp?: string; suggestions?: string[]; + /** hold each POST /chat this long, to test the in-flight state */ + delayMs?: number; }; export async function signIn( @@ -133,6 +135,7 @@ export async function signIn( } if (path === "/chat" && route.request().method() === "POST") { const body = route.request().postDataJSON() as { query: string; thread_id?: string }; + if (opts.chat?.delayMs) await new Promise((r) => setTimeout(r, opts.chat!.delayMs)); let t = threads.find((x) => x.id === body.thread_id); if (!t) { t = { id: `t${nextThread++}`, title: body.query.slice(0, 60), updated_at: new Date().toISOString(), message_count: 0, messages: [] }; diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 8bc6b4d..1e47af9 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -140,6 +140,15 @@ test("ctrl+k opens the ask palette and enter hands off", async ({ page }) => { expect(posts).toEqual(["Where did my money go"]); }); +test("ctrl+k works below md with the sheet closed", async ({ page }) => { + await page.setViewportSize({ width: 767, height: 1024 }); + await signIn(page); + await page.goto("/home"); + await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible(); + await page.keyboard.press("Control+k"); + await expect(palette(page)).toBeVisible(); +}); + test("palette works with caps lock", async ({ page }) => { await signIn(page); await page.goto("/home"); diff --git a/frontend/src/app/(app)/ask/thread-rail.tsx b/frontend/src/app/(app)/ask/thread-rail.tsx index b2f14bb..112395a 100644 --- a/frontend/src/app/(app)/ask/thread-rail.tsx +++ b/frontend/src/app/(app)/ask/thread-rail.tsx @@ -7,7 +7,8 @@ import { relativeTime } from "@/lib/home-math"; import { cn } from "@/lib/utils"; // Past conversations (UI revamp Phase 4): one click resumes, delete asks once inline. Rendered as a column on -// large screens and inside a Sheet below that (chatbotContent.tsx). +// large screens and inside a Sheet below that (chatbotContent.tsx). `disabled` while an answer is in flight, so nothing +// here can delete or swap the thread that answer is landing in. export function ThreadRail({ threads, activeId, @@ -43,13 +44,20 @@ export function ThreadRail({ {confirming === t.id ? (
Delete this chat? - diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index 9810ec2..6c6a085 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -1,16 +1,32 @@ "use client"; -import { Suspense, type ReactNode } from "react"; +import { Suspense, useEffect, useState, type ReactNode } from "react"; import { MotionConfig } from "motion/react"; import { AppSidebar } from "@/components/app-sidebar"; +import { AskPalette } from "@/components/ask-palette"; import { AuthGuard } from "@/components/auth/auth-guard"; import { ProfileProvider } from "@/components/profile/profile-provider"; import { AvatarMenu } from "@/components/avatar-menu"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { isTypingTarget } from "@/lib/utils"; // The signed-in app's frame: auth check, sidebar and top bar are built once and stay mounted while you move between // pages, so only the content area swaps, like tabs on X. Rendered by app/(app)/layout.tsx. export function AppShell({ children }: { children: ReactNode }) { + // the Ask palette lives here, not in the sidebar: below md the sidebar is a Sheet that isn't mounted while closed + const [paletteOpen, setPaletteOpen] = useState(false); + useEffect(() => { + // e.code, not e.key: Caps Lock or Shift turns the key into "K" + const onKey = (e: KeyboardEvent) => { + if (e.code === "KeyK" && (e.metaKey || e.ctrlKey) && !isTypingTarget(e.target)) { + e.preventDefault(); + setPaletteOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + return ( // Sky (globals.css) wraps everything, auth loader included, so the first server paint is already light.
@@ -21,7 +37,8 @@ export function AppShell({ children }: { children: ReactNode }) { {/* Only ever rendered client-side (the auth guard shows a loader on the server), so reading the remembered state straight from the cookie gives the right width on the sidebar's first paint: no jump, no SSR read. */} - + setPaletteOpen(true)} /> + {/* no top bar on desktop: just the account menu, floating top-right over the page (owner, 2026-10-22). Phones keep a slim row, since the sidebar there is a sheet that needs a button to open. */} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 2329ff9..4e54e0d 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -20,10 +20,8 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { AskPalette } from "@/components/ask-palette"; import { reviewApi } from "@/lib/api/client"; import { eventBus, EVENTS } from "@/lib/events"; -import { isTypingTarget } from "@/lib/utils"; type Item = { label: string; url: string; icon: LucideIcon }; @@ -55,11 +53,12 @@ function NavItem({ item, children }: { item: Item; children?: React.ReactNode }) ); } -export function AppSidebar(props: React.ComponentProps) { +// `onAsk` opens the Ask palette, which app-shell.tsx owns: below md this sidebar is a Sheet that isn't mounted while +// closed, so a palette (and its Ctrl+K) living in here would do nothing there. +export function AppSidebar({ onAsk, ...props }: React.ComponentProps & { onAsk: () => void }) { const pathname = usePathname(); const [toReview, setToReview] = React.useState(0); const [mac, setMac] = React.useState(false); - const [paletteOpen, setPaletteOpen] = React.useState(false); // refreshed on navigation and after each review decision (which happens without leaving /receipts) React.useEffect(() => { @@ -68,18 +67,7 @@ export function AppSidebar(props: React.ComponentProps) { return eventBus.on(EVENTS.REVIEW_DECIDED, refresh); }, [pathname]); - React.useEffect(() => { - setMac(/Mac|iPhone|iPad/.test(navigator.platform)); - // e.code, not e.key: Caps Lock or Shift turns the key into "K" - const onKey = (e: KeyboardEvent) => { - if (e.code === "KeyK" && (e.metaKey || e.ctrlKey) && !isTypingTarget(e.target)) { - e.preventDefault(); - setPaletteOpen(true); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); + React.useEffect(() => setMac(/Mac|iPhone|iPad/.test(navigator.platform)), []); return ( @@ -137,7 +125,7 @@ export function AppSidebar(props: React.ComponentProps) { setPaletteOpen(true)} + onClick={onAsk} className="border border-sidebar-border bg-card text-muted-foreground hover:text-foreground" > @@ -151,7 +139,6 @@ export function AppSidebar(props: React.ComponentProps) { - ); From 85685505892d22abcd41e95b49ad1d11528eb3b3 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:07:26 +0530 Subject: [PATCH 07/18] feat: day-by-day calendar with amounts and bill names --- frontend/e2e/home-math.spec.ts | 8 ++ frontend/e2e/home.spec.ts | 15 +++ .../(app)/home/_components/calendar-card.tsx | 108 +++++++++++++----- frontend/src/app/(app)/home/homeContent.tsx | 2 +- frontend/src/components/page/vendor-chip.tsx | 16 +++ frontend/src/lib/home-math.ts | 10 ++ 6 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/page/vendor-chip.tsx diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts index c0a3979..126d816 100644 --- a/frontend/e2e/home-math.spec.ts +++ b/frontend/e2e/home-math.spec.ts @@ -11,6 +11,7 @@ import { relativeTime, sameDayDelta, type Txn, + vendorChip, } from "../src/lib/home-math"; const d = (s: string) => { @@ -193,3 +194,10 @@ test("pickTicks drops ticks that crowd a kept one", () => { expect(pickTicks([t(0, true), t(20), t(40), t(300, true), t(310, true)], 48).map((k) => k.label)).toEqual(["0", "300", "310"]); expect(pickTicks([t(0, true), t(60), t(120), t(200, true)], 48).map((k) => k.label)).toEqual(["0", "60", "120", "200"]); }); + +test("vendorChip gives two initials and a stable hue", () => { + expect(vendorChip("Swiggy Instamart").initials).toBe("SI"); + expect(vendorChip("swiggy").initials).toBe("SW"); + expect(vendorChip("Swiggy Instamart").hue).toBe(vendorChip("Swiggy Instamart").hue); + expect(vendorChip("").initials).toBe("?"); +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index e8d32e7..5795df5 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -378,3 +378,18 @@ test("the hero + opens upload", async ({ page }) => { await spend(page).getByRole("button", { name: "Upload a receipt" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); }); + +test("calendar marks today and names future bills", async ({ page }) => { + test.skip(now.getDate() === lastDay, "no future days left this month"); + const tomorrow = now.getDate() + 1; + await openHome(page, { + ...typical, + reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(tomorrow), type: "upcoming" }], + }); + await expect(page.getByTestId(`cal-day-${now.getDate()}`)).toHaveAttribute("data-today", "true"); + await expect(page.getByTestId(`cal-day-${tomorrow}`)).toHaveAttribute("data-future", "true"); + await expect(page.getByTestId(`cal-day-${tomorrow}`)).toContainText("Netflix"); + const card = page.getByTestId("calendar-card"); + await expect(card.getByRole("link", { name: new RegExp(`${monthLong}, day by day`) })).toHaveAttribute("href", "/spending"); + await expect(card.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/spending"); +}); diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 2324b7e..3d8ec61 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -1,56 +1,110 @@ -import { useMoney } from "@/lib/format"; -import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; +import Link from "next/link"; import { PageCard } from "@/components/page/page-card"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { useMoney } from "@/lib/format"; +import { calendarBuckets, dayIndex, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; +import type { Reminder } from "../use-home-data"; // "₹2.2k" in the user's own currency: take the symbol from how useMoney prints zero const kShort = (n: number, symbol: string) => (n >= 1000 ? `${symbol}${(n / 1000).toFixed(1)}k` : `${symbol}${Math.round(n)}`); const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); +const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); -// This month as a heat grid (darker = more spent) beside the latest transactions. -export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: Period; loading: boolean }) { +// This month day by day (spec 2026-10-22 §4.1): what each day cost, darker = more, today outlined, and the bills +// still to come named on their days; the latest transactions beside it. +export function CalendarCard({ + txns, + reminders, + period, + today, + loading, +}: { + txns: Txn[]; + reminders: Reminder[]; + period: Period; + today: Date; + loading: boolean; +}) { const money = useMoney(); const symbol = money(0).replace(/[\d.,\s]/g, ""); const days = calendarBuckets(txns, period); const max = Math.max(...days.map((d) => d.amount), 0); + const total = days.reduce((s, d) => s + d.amount, 0); + const spentDays = days.filter((d) => d.amount > 0).length; + const t = dayIndex(period, today); const lead = (period.start.getDay() + 6) % 7; // Monday first const recent = [...txns].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 5); + const billOn = new Map(); + for (const r of reminders) { + const i = dayIndex(period, parseDay(r.date)); + if (r.type === "upcoming" && i > t && i < period.days && !billOn.has(i)) billOn.set(i, r.vendor); + } return ( - -
-
- {Array.from({ length: lead }, (_, i) => ( -
- ))} - {days.map(({ day, amount }) => { - const h = heat(amount, max); - return ( -
0.5 ? "#fff" : undefined } : undefined} - > - {day} - {amount > 0 && {kShort(amount, symbol)}} + +
+
+
+ {money(Math.round(total))}{" "} + + across {spentDays} {spentDays === 1 ? "day" : "days"} + +
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( +
+ {d}
- ); - })} + ))} + {Array.from({ length: lead }, (_, i) => ( +
+ ))} + {days.map(({ day, amount }, i) => { + const h = heat(amount, max); + const future = i > t; + const bill = billOn.get(i); + return ( +
0.5 ? "#fff" : undefined } : undefined} + > + {day} + {amount > 0 && {kShort(amount, symbol)}} + {future && bill && {bill}} +
+ ); + })} +
-
Recent transactions
+
+ Recent transactions + + See all + +
{recent.length === 0 ? (

No transactions this month yet.

) : ( recent.map((t) => ( -
+
+ {t.vendor_name ?? "Unknown"} - {t.category ?? "Uncategorised"} · {dayMonth(t.date)} + {dayMonth(t.date)} · {t.category ?? "Uncategorised"} - {money(t.total_amount ?? 0)} + {money(t.total_amount ?? 0)}
)) )} diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 4c152cb..d2ce389 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -99,7 +99,7 @@ export default function HomeContent() { loading={data.status === "loading"} /> - +
diff --git a/frontend/src/components/page/vendor-chip.tsx b/frontend/src/components/page/vendor-chip.tsx new file mode 100644 index 0000000..b8eeedd --- /dev/null +++ b/frontend/src/components/page/vendor-chip.tsx @@ -0,0 +1,16 @@ +import { vendorChip } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; + +// A vendor's initials on a soft tint of its own stable hue (lib/home-math vendorChip). +export function VendorChip({ name, className }: { name: string; className?: string }) { + const { initials, hue } = vendorChip(name); + return ( + + {initials} + + ); +} diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts index 5b28fa7..313f42b 100644 --- a/frontend/src/lib/home-math.ts +++ b/frontend/src/lib/home-math.ts @@ -140,3 +140,13 @@ export function suggestedQuestions(found: { kind: string; subject: string | null // "1st", "22nd", "13th" export const ordinal = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`; + +// The little initials chip shown for a vendor: two letters ("Swiggy Instamart" → "SI", "Swiggy" → "SW") and a hue +// that's the same every time for the same name, so a vendor keeps its colour across pages. +export function vendorChip(name: string): { initials: string; hue: number } { + const words = name.trim().split(/\s+/).filter(Boolean); + const initials = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? "").slice(0, 2)).toUpperCase() || "?"; + let hash = 0; + for (const ch of name.toLowerCase()) hash = (hash * 31 + ch.charCodeAt(0)) % 360; + return { initials, hue: hash }; +} From a60c4de98fd68ebc270e8712255ac31824384c1d Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:53:39 +0530 Subject: [PATCH 08/18] feat: Lumen found list with real actions; /ask?draft= prefills --- frontend/e2e/ask.spec.ts | 20 +++++ frontend/e2e/home.spec.ts | 16 +++- frontend/src/app/(app)/ask/chatbotContent.tsx | 25 ++++++- .../(app)/home/_components/lumen-found.tsx | 73 +++++++++++++------ frontend/src/components/ask/prompt-bar.tsx | 5 +- 5 files changed, 110 insertions(+), 29 deletions(-) diff --git a/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts index a1eeaa0..f62159a 100644 --- a/frontend/e2e/ask.spec.ts +++ b/frontend/e2e/ask.spec.ts @@ -204,3 +204,23 @@ test("a question suggestion is still sent to Lumen", async ({ page }) => { await page.getByRole("button", { name: "Show last month by category" }).click(); await expect(page.getByText("Answer to: Show last month by category")).toBeVisible(); }); + +test("draft prefills the composer without sending", async ({ page }) => { + const posts: string[] = []; + page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push("x")); + await openAsk(page, {}, "/ask?draft=Why%20is%20Food%20up%3F"); + await expect(composer(page)).toHaveValue("Why is Food up?"); + await expect(composer(page)).toBeFocused(); + await expect(page).toHaveURL(/\/ask$/); + await page.waitForTimeout(500); + expect(posts).toHaveLength(0); + await page.reload(); + await expect(composer(page)).toHaveValue(""); +}); + +test("a sent draft doesn't come back in the follow-up box", async ({ page }) => { + await openAsk(page, {}, "/ask?draft=Why%20is%20Food%20up%3F"); + await composer(page).press("Enter"); + await expect(page.getByText("Answer to: Why is Food up?")).toBeVisible(); + await expect(composer(page)).toHaveValue(""); +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 5795df5..26fa571 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -246,8 +246,8 @@ const spike = { kind: "spike", tag: "SPENDING", headline: "Food is up 35%", deta test("lumen found shows server signals and worth", async ({ page }) => { await openHome(page, { ...typical, summary: { found: [dupe, spike], worth_total: 2499 } }); const found = page.getByTestId("lumen-found"); - await expect(found).toContainText("2 things worth a look · ₹2,499 you may get back"); - await expect(found.getByTestId("found-tile")).toHaveCount(2); + await expect(found).toContainText("2 to look at · ₹2,499 you may get back"); + await expect(found.getByTestId("found-row")).toHaveCount(2); await expect(found).toContainText("Possible double charge: Croma ₹2,499"); await expect(found).toContainText("₹6,750 so far vs ₹5,000 by this point last month."); }); @@ -393,3 +393,15 @@ test("calendar marks today and names future bills", async ({ page }) => { await expect(card.getByRole("link", { name: new RegExp(`${monthLong}, day by day`) })).toHaveAttribute("href", "/spending"); await expect(card.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/spending"); }); + +test("lumen found rows do things", async ({ page }) => { + await openHome(page, { ...typical, summary: { found: [dupe, spike], worth_total: 2499 } }); + const rows = page.getByTestId("lumen-found").getByTestId("found-row"); + // the spike links to where you can act on it; a row with nowhere to go gets no dead link + await expect(rows.nth(1).getByRole("link", { name: "See in Spending" })).toHaveAttribute("href", "/spending"); + await expect(rows.nth(0).getByRole("link", { name: /^Review|^See/ })).toHaveCount(0); + // "Ask Lumen" opens Ask with the question typed, never sent + const ask = rows.nth(1).getByRole("link", { name: "Ask Lumen" }); + await expect(ask).toHaveAttribute("href", /^\/ask\?draft=/); + expect(decodeURIComponent((await ask.getAttribute("href"))!.split("draft=")[1])).toBe("Tell me more about this: Food is up 35%"); +}); diff --git a/frontend/src/app/(app)/ask/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx index e81baf7..ea8e101 100644 --- a/frontend/src/app/(app)/ask/chatbotContent.tsx +++ b/frontend/src/app/(app)/ask/chatbotContent.tsx @@ -70,7 +70,11 @@ export default function ChatbotContent() { const bottomRef = useRef(null); const lastQ = useRef(null); // the ?q= already handed off (StrictMode runs effects twice) const router = useRouter(); - const q = useSearchParams().get("q"); + const params = useSearchParams(); + const q = params.get("q"); + const draftParam = params.get("draft"); + const lastDraft = useRef(null); + const [draft, setDraft] = useState<{ text: string; key: number } | null>(null); const busy = messages.some((m) => m.pending); const refreshThreads = useCallback(() => { @@ -110,6 +114,21 @@ export default function ChatbotContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [q]); + // /ask?draft=… (Lumen found, Bills "How to cancel"): a new chat with the question typed in but NOT sent, so you can + // edit it first; the URL is cleaned so a reload starts empty + useEffect(() => { + if (draftParam === null) { + lastDraft.current = null; + return; + } + if (lastDraft.current === draftParam) return; + lastDraft.current = draftParam; + router.replace("/ask"); + newChat(); + setDraft({ text: draftParam, key: Date.now() }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftParam]); + async function ask(question: string, file?: File, replaceId?: string, inThread: string | null = threadId) { const id = crypto.randomUUID(); const started = Date.now(); @@ -218,7 +237,7 @@ export default function ChatbotContent() { Ask about spending, invoices or contracts. Answers come with their sources.

- void ask(text, file)} busy={busy} /> + { setDraft(null); void ask(text, file); }} busy={busy} />
{suggestions.map((text, i) => ( @@ -295,7 +314,7 @@ export default function ChatbotContent() {
- void ask(text, file)} busy={busy} /> + { setDraft(null); void ask(text, file); }} busy={busy} />

Lumen cites its sources and only proposes changes; nothing is applied until you approve it.

diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx index e528ed2..5feae32 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -1,9 +1,22 @@ import Link from "next/link"; -import type { HomeSummary } from "@/lib/api/client"; +import { Copy, ReceiptText, RefreshCw, TrendingUp, type LucideIcon } from "lucide-react"; +import type { FoundItem, HomeSummary } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { fill } from "@/lib/home-math"; import { PageCard } from "@/components/page/page-card"; +// How each kind looks and what its link does. A row only links somewhere it can be acted on (spec 2026-10-22 §4.1). +const KIND: Record = { + duplicate: { icon: Copy, tint: "bg-bad/10 text-bad", action: "Review duplicate" }, + review: { icon: ReceiptText, tint: "bg-primary/10 text-primary", action: "Review receipts" }, + spike: { icon: TrendingUp, tint: "bg-amber-500/10 text-amber-700", action: "See in Spending" }, + price_rise: { icon: RefreshCw, tint: "bg-primary/10 text-primary", action: "See the charges" }, +}; + +// What "Ask Lumen" types into Ask for you (not sent: you can edit it first). +export const askAbout = (item: FoundItem, money: (n: number) => string) => + `Tell me more about this: ${fill(item.headline, item.amounts, money).replace(/\.$/, "")}`; + // "Lumen found": duplicates, receipts to review, spending spikes and price rises (GET /api/home/summary, rule-based). // A money "worth" is only claimed for things you may get back (duplicates). export function LumenFound({ summary, loading }: { summary: HomeSummary | null; loading: boolean }) { @@ -11,33 +24,47 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null; const found = summary?.found ?? []; const total = found.length + (summary?.more ?? 0); return ( - + 0 && ( + + {total} to look at + {summary!.worth_total > 0 && ` · ${money(summary!.worth_total)} you may get back`} + + ) + } + loading={loading} + skeletonHeight={132} + data-testid="lumen-found" + > {found.length === 0 ? (

Nothing needs you right now.

) : ( - <> -

- {total} {total === 1 ? "thing" : "things"} worth a look - {summary!.worth_total > 0 && ( - · {money(summary!.worth_total)} you may get back - )} -

-
- {found.map((f, i) => ( -
- {/* the API sends tags in caps ("RECEIPTS"); labels are sentence case */} -
{f.tag.charAt(0) + f.tag.slice(1).toLowerCase()}
-

{fill(f.headline, f.amounts, money)}

-

{fill(f.detail, f.amounts, money)}

- {f.href && ( - - {f.kind === "review" ? "Review →" : "See →"} + found.map((f, i) => { + const { icon: Icon, tint, action } = KIND[f.kind] ?? KIND.review; + return ( +
+ + + +
+

{fill(f.headline, f.amounts, money)}

+

{fill(f.detail, f.amounts, money)}

+
+ {f.href && ( + + {action} + + )} + + Ask Lumen - )} +
- ))} -
- +
+ ); + }) )} ); diff --git a/frontend/src/components/ask/prompt-bar.tsx b/frontend/src/components/ask/prompt-bar.tsx index 1725343..65851ba 100644 --- a/frontend/src/components/ask/prompt-bar.tsx +++ b/frontend/src/components/ask/prompt-bar.tsx @@ -10,13 +10,16 @@ export function PromptBar({ onSend, busy, hero = false, + initial = "", }: { onSend: (text: string, file?: File) => void; busy: boolean; /** the large, centred version shown before the first question */ hero?: boolean; + /** text to start with (Ask's ?draft=); remount with a new key to change it */ + initial?: string; }) { - const [draft, setDraft] = useState(""); + const [draft, setDraft] = useState(initial); const [file, setFile] = useState(null); const inputRef = useRef(null); const fileRef = useRef(null); From 9c13ab8d16281c6423e3a09882ca0f48a533c265 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:05:42 +0530 Subject: [PATCH 09/18] feat: Home v2 layout, budget and coming-up cards --- frontend/e2e/home.spec.ts | 41 +++++++++++++-- .../app/(app)/home/_components/bills-card.tsx | 16 +++--- .../(app)/home/_components/budget-card.tsx | 51 ++++++++++++------- .../(app)/home/_components/calendar-card.tsx | 5 +- .../app/(app)/home/_components/spend-card.tsx | 4 +- frontend/src/app/(app)/home/homeContent.tsx | 30 +++++------ 6 files changed, 99 insertions(+), 48 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 26fa571..933a67d 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -168,12 +168,18 @@ test("long names truncate", async ({ page }) => { expect(Math.abs(a!.height - b!.height)).toBeLessThanOrEqual(1); }); -test("bills card lists next three", async ({ page }) => { +test("coming up lists the next four bills", async ({ page }) => { const r = (id: string, vendor: string, n: number) => ({ id, vendor, amount: 100, date: inDays(n), type: "upcoming" }); - await openHome(page, { ...typical, reminders: [r("4", "Rent", 9), r("1", "Netflix", 1), r("3", "Gym", 6), r("2", "Spotify", 3)] }); + await openHome(page, { + ...typical, + reminders: [r("4", "Rent", 9), r("1", "Netflix", 1), r("3", "Gym", 6), r("2", "Spotify", 3), r("5", "Jio", 12), r("6", "LIC", 20)], + }); const rows = page.getByTestId("bill-row"); - await expect(rows).toHaveCount(3); + await expect(rows).toHaveCount(4); await expect(rows.first()).toContainText("Netflix"); + const d = new Date(Y, M, now.getDate() + 1); + await expect(rows.first().getByTestId("date-chip")).toHaveText(`${d.toLocaleString("en-GB", { weekday: "short" })}${d.getDate()}`); + await expect(page.getByRole("link", { name: /^Coming up/ })).toHaveAttribute("href", "/bills"); }); test("budget card is honest with no budget", async ({ page }) => { @@ -198,7 +204,7 @@ test("side column order", async ({ page }) => { const titles = await side.locator(":scope > section").evaluateAll((els) => els.map((e) => (e.querySelector("h2, h3, a")?.textContent ?? "").replace(" ›", "").trim().toLowerCase()), ); - expect(titles.map((t) => t.split(" · ")[0])).toEqual(["budget", "upcoming bills", "let lumen draft your budget", "activity"]); + expect(titles).toEqual([`budget in ${monthLong.toLowerCase()}`, "coming up", "let lumen draft your budget", "activity"]); }); test("sky band is drawn behind the greeting", async ({ page }) => { @@ -261,6 +267,7 @@ test("budget card shows progress against the monthly budget", async ({ page }) = await openHome(page, { ...typical, budget: { monthly_budget: 28000, spent: 18412, categories: [{ category: "Food", limit: 8000, spent: 7480 }] } }); const card = page.getByTestId("budget-card"); await expect(card).toContainText("₹18,412 of ₹28,000"); + await expect(card).toContainText("66%"); await expect(card).toContainText("₹9,588 left"); await expect(card).toContainText("Food"); await expect(page.getByText("Let Lumen draft your budget")).toHaveCount(0); // has category budgets already @@ -405,3 +412,29 @@ test("lumen found rows do things", async ({ page }) => { await expect(ask).toHaveAttribute("href", /^\/ask\?draft=/); expect(decodeURIComponent((await ask.getAttribute("href"))!.split("draft=")[1])).toBe("Tell me more about this: Food is up 35%"); }); + +test("budget card shows the top four categories by spend", async ({ page }) => { + const c = (category: string, limit: number, spent: number) => ({ category, limit, spent }); + await openHome(page, { + ...typical, + budget: { monthly_budget: 100000, spent: 62182, categories: [c("Travel", 5000, 2140), c("Rent", 25000, 25000), c("Shopping", 8000, 16840), c("Groceries", 12000, 8120), c("Food", 6000, 5930), c("Health", 3000, 640)] }, + }); + const rows = page.getByTestId("budget-cat"); + await expect(rows).toHaveCount(4); + expect(await rows.evaluateAll((els) => els.map((e) => e.getAttribute("data-category")))).toEqual(["Rent", "Shopping", "Groceries", "Food"]); + await expect(rows.nth(1)).toHaveAttribute("data-over", "true"); + await expect(rows.nth(0)).not.toHaveAttribute("data-over", "true"); +}); + +test("an empty account keeps every card at its loading height", async ({ page }) => { + await openHome(page, { txns: [], delayMs: 1500 }); + const cards = page.locator('[data-testid="home-main"] > section, [data-testid="home-side"] > section, [data-testid="spend-card"]'); + await expect(cards.first()).toBeVisible(); + const loading = await cards.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().height))); + await expect(page.getByTestId("brief")).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(300); + const ready = await cards.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().height))); + expect(ready.length).toBe(loading.length); + ready.forEach((h, i) => expect(Math.abs(h - loading[i]), `card ${i}`).toBeLessThanOrEqual(1)); + await expect(page.locator("main")).not.toContainText(/NaN|of ₹0/); +}); diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx index 1989478..4efad3a 100644 --- a/frontend/src/app/(app)/home/_components/bills-card.tsx +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -3,28 +3,28 @@ import { parseDay } from "@/lib/home-math"; import type { Reminder } from "../use-home-data"; import { PageCard } from "@/components/page/page-card"; -// The next three recurring charges Lumen expects. +// "Coming up": the next four recurring charges Lumen expects, each with a little date chip. export function BillsCard({ reminders, today, loading }: { reminders: Reminder[]; today: Date; loading: boolean }) { const money = useMoney(); const next = reminders .filter((r) => r.type === "upcoming" && parseDay(r.date) >= today) .sort((a, b) => a.date.localeCompare(b.date)) - .slice(0, 3); + .slice(0, 4); return ( - + {next.length === 0 ? (

No bills coming up in the next month.

) : ( next.map((r) => { const d = parseDay(r.date); return ( -
- - {d.toLocaleString("en-GB", { weekday: "short" })} - {String(d.getDate()).padStart(2, "0")} +
+ + {d.toLocaleString("en-GB", { weekday: "short" })} + {d.getDate()} {r.vendor} - {r.amount == null ? "—" : money(r.amount)} + {r.amount == null ? "—" : money(r.amount)}
); }) diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index 4d3b9d0..b611e2a 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -9,25 +9,30 @@ import { cn } from "@/lib/utils"; import { PageCard } from "@/components/page/page-card"; // Overall budget = the profile's monthly budget (onboarding); per-category limits come from approved set_budget -// proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so. +// proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so. The top four categories +// by spend show as bars: green, amber from 90% of the limit, red when over. export function BudgetCard({ budget, monthName, today, loading }: { budget: BudgetState | null; monthName: string; today: Date; loading: boolean }) { const money = useMoney(); const total = budget?.monthly_budget ?? null; - const cats = (budget?.categories ?? []).slice(0, 3); + const cats = [...(budget?.categories ?? [])].sort((a, b) => b.spent - a.spent).slice(0, 4); const spent = budget?.spent ?? 0; const daysLeft = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate() + 1; + const pct = total ? Math.round((spent / total) * 100) : 0; return ( - + {total ? ( <> -
- {money(spent)} of {money(total)} +
+ {money(spent)} of {money(total)}
-
- total ? "bg-bad" : "bg-good")} style={{ width: `${Math.min(100, (spent / total) * 100)}%` }} /> +
+
+ total ? "bg-bad" : "bg-good")} style={{ width: `${Math.min(100, pct)}%` }} /> +
+ {pct}%
-

+

{spent > total ? `${money(spent - total)} over` : `${money(total - spent)} left · ${daysLeft} ${daysLeft === 1 ? "day" : "days"} · ~${money((total - spent) / daysLeft)}/day`} @@ -40,15 +45,27 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg ) : null} {cats.length > 0 && ( -

- {cats.map((c) => ( -
- {c.category} - c.limit && "text-bad")}> - {money(c.spent)} / {money(c.limit)} - -
- ))} +
+ {cats.map((c) => { + const used = c.limit > 0 ? c.spent / c.limit : 0; + const over = c.spent > c.limit; + return ( +
+
+ {c.category} + + {money(c.spent)} / {money(c.limit)} + +
+
+ = 0.9 ? "bg-amber-500" : "bg-primary")} + style={{ width: `${Math.min(100, used * 100)}%` }} + /> +
+
+ ); + })}
)} diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 3d8ec61..1caa140 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -12,7 +12,8 @@ const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "nume const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); // This month day by day (spec 2026-10-22 §4.1): what each day cost, darker = more, today outlined, and the bills -// still to come named on their days; the latest transactions beside it. +// still to come named on their days; the latest transactions beside it. The card is always as tall as a six-week +// month, so it never changes height between loading and loaded, or from one month to the next. export function CalendarCard({ txns, reminders, @@ -42,7 +43,7 @@ export function CalendarCard({ } return ( - +
diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index 2419abd..89e8109 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -170,7 +170,7 @@ export function SpendCard({ } loading={loading} - skeletonHeight={318} + skeletonHeight={348} className="relative" data-testid="spend-card" > @@ -196,7 +196,7 @@ export function SpendCard({
{empty ? ( -
+

Nothing spent yet this {range === "1W" ? "week" : range === "1M" ? "month" : range === "3M" ? "quarter" : "year"}. Upload a receipt or import a statement and your spending will show up here. diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index d2ce389..122b663 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -83,23 +83,23 @@ export default function HomeContent() { } /> -

+ setUploadOpen(true)} + period={chart.current} + previous={chart.previous} + cur={cumulative(data.txns, chart.current)} + prev={cumulative(data.prevTxns, chart.previous)} + forecast={data.forecast} + reminders={data.reminders} + today={today} + loading={data.status === "loading"} + /> +
- setUploadOpen(true)} - period={chart.current} - previous={chart.previous} - cur={cumulative(data.txns, chart.current)} - prev={cumulative(data.prevTxns, chart.previous)} - forecast={data.forecast} - reminders={data.reminders} - today={today} - loading={data.status === "loading"} - /> - +
From 09f27ca32e4bfda90f906dfb7a18c803fbe8f36c Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:11:55 +0530 Subject: [PATCH 10/18] fix: calendar amounts readable on phones --- frontend/e2e/home.spec.ts | 8 ++++++++ .../app/(app)/home/_components/calendar-card.tsx | 14 ++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 933a67d..76b93a3 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -438,3 +438,11 @@ test("an empty account keeps every card at its loading height", async ({ page }) ready.forEach((h, i) => expect(Math.abs(h - loading[i]), `card ${i}`).toBeLessThanOrEqual(1)); await expect(page.locator("main")).not.toContainText(/NaN|of ₹0/); }); + +test("calendar amounts stay readable at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openHome(page, { ...typical, txns: [tx("big", "Rent", thisMonth(1), 25640), tx("b", "Rent", lastMonth(1), 20280)] }); + const amount = page.getByTestId("cal-day-1").locator("b"); + await expect(amount).toBeVisible(); + expect(await amount.evaluate((e) => e.scrollWidth <= e.clientWidth)).toBe(true); +}); diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 1caa140..b808960 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -6,8 +6,9 @@ import { calendarBuckets, dayIndex, heat, parseDay, type Period, type Txn } from import { cn } from "@/lib/utils"; import type { Reminder } from "../use-home-data"; -// "₹2.2k" in the user's own currency: take the symbol from how useMoney prints zero -const kShort = (n: number, symbol: string) => (n >= 1000 ? `${symbol}${(n / 1000).toFixed(1)}k` : `${symbol}${Math.round(n)}`); +// "2.2k"; the user's currency symbol (from how useMoney prints zero) goes in front, except on phones, where a cell +// is too narrow for "₹25.6k" and the card's total already says the currency +const kShort = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n)}`); const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); @@ -72,14 +73,19 @@ export function CalendarCard({ data-today={i === t || undefined} data-future={future || undefined} className={cn( - "flex h-[50px] flex-col justify-between overflow-hidden rounded-[7px] border px-1.5 py-1 text-[10.5px] leading-tight text-muted-foreground", + "flex h-[50px] flex-col justify-between overflow-hidden rounded-[7px] border px-1.5 py-1 max-sm:px-1 text-[10.5px] leading-tight text-muted-foreground", future ? "border-dashed border-border" : "border-border/60", i === t && "outline-[1.5px] outline-primary outline-solid -outline-offset-1", )} style={h ? { background: `color-mix(in srgb, var(--primary) ${Math.round(h * 100)}%, transparent)`, color: h > 0.5 ? "#fff" : undefined } : undefined} > {day} - {amount > 0 && {kShort(amount, symbol)}} + {amount > 0 && ( + + {symbol} + {kShort(amount)} + + )} {future && bill && {bill}}
); From 201a5702da5d22f5887d9b042e707dcee6bdcff1 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:22:04 +0530 Subject: [PATCH 11/18] fix: e2e dev server builds into .next-e2e so it can't break npm run dev --- frontend/.gitignore | 1 + frontend/next.config.js | 3 +++ frontend/playwright.config.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/frontend/.gitignore b/frontend/.gitignore index 4b01206..ee88db3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,6 +8,7 @@ # next.js /.next/ +/.next-e2e/ /out/ # production diff --git a/frontend/next.config.js b/frontend/next.config.js index fdcda45..200d02c 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -19,6 +19,9 @@ if (missing.length > 0) { const nextConfig = { reactStrictMode: true, + // e2e runs its own dev server (playwright.config.ts) with dummy env; a separate build folder keeps it from + // clobbering the `npm run dev` server's cache in the same checkout ("missing required error components"). + distDir: process.env.NEXT_DIST_DIR || ".next", // The login page used to live at /signin; old bookmarks and already-sent email links still work // (query strings carry over, and browsers keep the #fragment Supabase puts on email links). async redirects() { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 547b4f9..3c6d3d5 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321", NEXT_PUBLIC_SUPABASE_ANON_KEY: "e2e-dummy", NEXT_PUBLIC_BACKEND_URL: "http://127.0.0.1:9", + NEXT_DIST_DIR: ".next-e2e", }, }, }); From 94916a4adae6ff0ade8d0755df7b1846c90e8508 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:22:25 +0530 Subject: [PATCH 12/18] chore: include the e2e build's route types in tsconfig --- frontend/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c3fdd48..b2b45c0 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"], "exclude": ["node_modules"] } From 1dbaf139f5862b81d606d9833f32a033d54af1a1 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:36:11 +0530 Subject: [PATCH 13/18] fix: Home reads reminders in the shape the API sends (crash on a dateless reminder; bills never showed) --- frontend/e2e/fixtures.ts | 15 ++++++++++-- frontend/e2e/home.spec.ts | 13 ++++++++++ frontend/src/app/(app)/home/use-home-data.ts | 25 +++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index fd5aa86..c574df0 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -82,8 +82,19 @@ export async function signIn( const data = all.slice((page - 1) * size, page * size); return route.fulfill({ json: { success: true, data, pagination: { page, has_next: page * size < all.length, total_items: all.length } } }); } - if (path === "/api/analytics/reminders") - return route.fulfill({ json: { success: true, reminders: home.reminders ?? [], count: 0 } }); + // the real shape (ai/pattern_detection.generate_reminders), built from the fixture's simpler rows; a row with + // no vendor is a category pattern ("You usually buy Groceries around the 5th") + if (path === "/api/analytics/reminders") { + const reminders = (home.reminders ?? []).map((r) => ({ + title: `Upcoming: ${r.vendor || r.category}`, + description: "", + reminder_type: "pattern_based", + predicted_date: r.date, + pattern_id: r.id, + metadata: { pattern_type: r.vendor ? "recurring" : "day_of_month", vendor_name: r.vendor || null, category: r.category ?? null, average_amount: r.amount }, + })); + return route.fulfill({ json: { success: true, reminders, count: reminders.length } }); + } if (path === "/api/analytics/forecast") { if (home.forecastDelayMs) await new Promise((r) => setTimeout(r, home.forecastDelayMs)); if (home.forecast === "fail") return route.fulfill({ status: 500, json: { error: "boom" } }); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 76b93a3..3ea1d8d 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -446,3 +446,16 @@ test("calendar amounts stay readable at phone width", async ({ page }) => { await expect(amount).toBeVisible(); expect(await amount.evaluate((e) => e.scrollWidth <= e.clientWidth)).toBe(true); }); + +test("a restock reminder (no vendor) neither crashes Home nor shows as a bill", async ({ page }) => { + await openHome(page, { + ...typical, + reminders: [ + { id: "g", vendor: "", category: "Groceries", amount: 900, date: inDays(1), type: "upcoming" }, + { id: "n", vendor: "Netflix", amount: 649, date: inDays(2), type: "upcoming" }, + ], + }); + await expect(page.getByTestId("bill-row")).toHaveCount(1); + await expect(page.getByTestId("bill-row")).toContainText("Netflix"); + await expect(page.getByTestId("calendar-card")).toBeVisible(); +}); diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index 054cd53..ef52d75 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -5,6 +5,29 @@ import { aiAnalyticsApi, budgetApi, homeApi, transactionApi, type BudgetState, t import { periods, type Range, type Txn } from "@/lib/home-math"; export type Reminder = { id: string | number; vendor: string; amount: number | null; date: string; type: string; category?: string }; +// GET /api/analytics/reminders as the backend sends it (ai/pattern_detection.generate_reminders). +type ApiReminder = { + title?: string; + predicted_date?: string | null; + pattern_id?: string | number; + metadata?: { vendor_name?: string | null; average_amount?: number | null; category?: string | null } | null; +}; +// Bills only: a recurring payment to a vendor with a date. Restock nudges ("You usually buy Groceries around the +// 5th") have no vendor and aren't bills. Rows with no date are skipped rather than crashing the page. +export function toReminders(rows: ApiReminder[]): Reminder[] { + return rows.flatMap((r, i) => + r.predicted_date && r.metadata?.vendor_name + ? [{ + id: r.pattern_id ?? i, + vendor: r.metadata.vendor_name, + amount: r.metadata.average_amount ?? null, + date: r.predicted_date, + type: "upcoming", + category: r.metadata.category ?? undefined, + }] + : [], + ); +} export type Forecast = { dates: string[]; values: number[] } | null; export type HomeData = { status: "loading" | "ready"; @@ -83,7 +106,7 @@ export function useHomeData(range: Range, today: Date): HomeData { useEffect(() => { let live = true; Promise.allSettled([ - aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []), + aiAnalyticsApi.getReminders(31).then((r: { reminders?: ApiReminder[] }) => toReminders(r.reminders ?? [])), homeApi.summary(), budgetApi.get(), ]).then(([reminders, summary, budget]) => { From ca088659d75b01358f403ca75388c1fb6871a422 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:43:26 +0530 Subject: [PATCH 14/18] fix: drop the Spending/Bills cards that read fields the backend never sent; interim Bills lists upcoming bills --- frontend/e2e/shell.spec.ts | 13 + .../(app)/ai-analytics/aiAnalyticsContent.tsx | 19 +- frontend/src/app/(app)/bills/page.tsx | 10 +- .../src/app/(app)/bills/upcoming-bills.tsx | 19 ++ .../ai-analytics/paymentCalendarCard.tsx | 208 -------------- .../ai-analytics/reminderDetailsCard.tsx | 171 ------------ .../ai-analytics/spendingTrendGraph.tsx | 254 ------------------ 7 files changed, 40 insertions(+), 654 deletions(-) create mode 100644 frontend/src/app/(app)/bills/upcoming-bills.tsx delete mode 100644 frontend/src/components/ai-analytics/paymentCalendarCard.tsx delete mode 100644 frontend/src/components/ai-analytics/reminderDetailsCard.tsx delete mode 100644 frontend/src/components/ai-analytics/spendingTrendGraph.tsx diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 1e47af9..f0c2c16 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -262,3 +262,16 @@ test("sidebar never scrolls sideways", async ({ page }) => { await expect(content).toBeVisible(); expect(await content.evaluate((e) => e.scrollWidth - e.clientWidth)).toBeLessThanOrEqual(0); }); + +test("spending and bills show no cards that can never load", async ({ page }) => { + await signIn(page, { + home: { reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10), type: "upcoming" }] }, + }); + await page.goto("/spending"); + await expect(page.getByText("Spending Comparison")).toBeVisible(); + for (const dead of ["Could not load payment calendar.", "Could not load reminders.", "Spending Trends"]) + await expect(page.getByText(dead, { exact: true })).toHaveCount(0); + await page.goto("/bills"); + await expect(page.getByTestId("bill-row")).toContainText("Netflix"); + await expect(page.getByText(/Could not load/)).toHaveCount(0); +}); diff --git a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx b/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx index 5a3b52a..91acece 100644 --- a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx +++ b/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx @@ -3,11 +3,8 @@ import { useState, useEffect } from "react"; import { DashboardShell } from "@/components/dashboard-shell"; import QuickStatsCard from "@/components/ai-analytics/quickStatsCard"; -import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard"; -import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard"; import AnomalyDetectionCard from "@/components/ai-analytics/anomalyDetectionCard"; import AISuggestionsCard from "@/components/ai-analytics/aiSuggestionsCard"; -import SpendingTrendGraph from "@/components/ai-analytics/spendingTrendGraph"; import CategoryPieChart from "@/components/ai-analytics/categoryPieChart"; import { aiAnalyticsApi } from "@/lib/api/client"; @@ -53,24 +50,16 @@ export default function AIAnalyticsContent() { } > + {/* The payment calendar, reminders and spending-trend cards read fields the backend never sent; they were + removed (owner, 2026-10-22). Bills gets a real calendar in UI revamp Phase 5c, Spending a rebuild in 5b. */}
- - -
+ +
- -
-
- -
-
- -
-
); } diff --git a/frontend/src/app/(app)/bills/page.tsx b/frontend/src/app/(app)/bills/page.tsx index b8af91a..7172d40 100644 --- a/frontend/src/app/(app)/bills/page.tsx +++ b/frontend/src/app/(app)/bills/page.tsx @@ -1,17 +1,15 @@ import type { Metadata } from "next"; import { DashboardShell } from "@/components/dashboard-shell"; -import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard"; -import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard"; +import { UpcomingBills } from "./upcoming-bills"; export const metadata: Metadata = { title: "Bills & subscriptions" }; -// Interim (UI revamp Phase 1): the payment calendar and reminders until Phase 5 redesigns Bills. +// Interim until UI revamp Phase 5c redesigns Bills: the upcoming bills Lumen has spotted. export default function Page() { return ( -
- - +
+
); diff --git a/frontend/src/app/(app)/bills/upcoming-bills.tsx b/frontend/src/app/(app)/bills/upcoming-bills.tsx new file mode 100644 index 0000000..5e75088 --- /dev/null +++ b/frontend/src/app/(app)/bills/upcoming-bills.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { aiAnalyticsApi } from "@/lib/api/client"; +import { BillsCard } from "../home/_components/bills-card"; +import { toReminders, type Reminder } from "../home/use-home-data"; + +// Interim Bills (until UI revamp Phase 5c): the same upcoming-bills list as Home, over the next two months. +export function UpcomingBills() { + const [reminders, setReminders] = useState(null); + useEffect(() => { + aiAnalyticsApi.getReminders(62).then( + (r: { reminders?: Parameters[0] }) => setReminders(toReminders(r.reminders ?? [])), + () => setReminders([]), + ); + }, []); + const today = new Date(); + return ; +} diff --git a/frontend/src/components/ai-analytics/paymentCalendarCard.tsx b/frontend/src/components/ai-analytics/paymentCalendarCard.tsx deleted file mode 100644 index f562382..0000000 --- a/frontend/src/components/ai-analytics/paymentCalendarCard.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Calendar as CalendarIcon, - ChevronLeft, - ChevronRight, -} from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; -import { analyticsApi } from "@/lib/api/client"; - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - ease: [0.4, 0, 0.2, 1] as any, - delay: 0.1, - }, - }, -}; - -export default function PaymentCalendarCard() { - const [currentDate, setCurrentDate] = useState(new Date()); - const [paymentDates, setPaymentDates] = useState<{ - upcoming: number[]; - forecast: number[]; - }>({ - upcoming: [], - forecast: [], - }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchPaymentDates = async () => { - try { - setError(null); - const response = await analyticsApi.getAllTimeSummary(); - if (response.success && response.payment_calendar) { - setPaymentDates(response.payment_calendar); - } else { - setPaymentDates({ upcoming: [], forecast: [] }); - setError("Could not load payment calendar."); - } - } catch (err) { - console.error("Failed to load payment calendar", err); - setPaymentDates({ upcoming: [], forecast: [] }); - setError("Could not load payment calendar."); - } finally { - setLoading(false); - } - }; - fetchPaymentDates(); - }, [currentDate]); - - const getDaysInMonth = (date: Date) => { - const year = date.getFullYear(); - const month = date.getMonth(); - const firstDay = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - return { firstDay, daysInMonth }; - }; - - const { firstDay, daysInMonth } = getDaysInMonth(currentDate); - const monthName = currentDate.toLocaleDateString("en-US", { - month: "long", - year: "numeric", - }); - - const previousMonth = () => { - setCurrentDate( - new Date(currentDate.getFullYear(), currentDate.getMonth() - 1) - ); - }; - - const nextMonth = () => { - setCurrentDate( - new Date(currentDate.getFullYear(), currentDate.getMonth() + 1) - ); - }; - - const isUpcoming = (day: number) => paymentDates.upcoming.includes(day); - const isForecast = (day: number) => paymentDates.forecast.includes(day); - - if (loading) { - return ( - - -

Loading calendar...

-
-
- ); - } - - if (error) { - return ( - - -

{error}

-
-
- ); - } - - return ( - - - - -
- - Payment Calendar -
-
- - -
-
-
- -
- {/* Month/Year */} -

- {monthName} -

- - {/* Calendar Grid */} -
- {/* Day Headers */} - {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map( - (day) => ( -
- {day} -
- ) - )} - - {/* Empty cells for days before month starts */} - {Array.from({ length: firstDay }).map( - (_, index) => ( -
- ) - )} - - {/* Days of the month */} - {Array.from({ length: daysInMonth }).map( - (_, index) => { - const day = index + 1; - const isUpcomingDay = isUpcoming(day); - const isForecastDay = isForecast(day); - - return ( -
- {day} -
- ); - } - )} -
- - {/* Legend */} -
-
-
- Upcoming -
-
-
- Forecast -
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/ai-analytics/reminderDetailsCard.tsx b/frontend/src/components/ai-analytics/reminderDetailsCard.tsx deleted file mode 100644 index 4d14355..0000000 --- a/frontend/src/components/ai-analytics/reminderDetailsCard.tsx +++ /dev/null @@ -1,171 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Bell, TrendingUp, Calendar } from "lucide-react"; -import { motion } from "motion/react"; -import { Badge } from "@/components/ui/badge"; -import { useState, useEffect } from "react"; -import { useMoney } from "@/lib/format"; -import { analyticsApi } from "@/lib/api/client"; - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - ease: "easeOut" as const, - delay: 0.2, - }, - }, -}; - -export default function ReminderDetailsCard() { - const money = useMoney(); - const [reminders, setReminders] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchReminders = async () => { - try { - setError(null); - const response = await analyticsApi.getAllTimeSummary(); - if (response.success && response.upcoming_payments) { - setReminders(response.upcoming_payments); - } else { - setReminders([]); - setError("Could not load reminders."); - } - } catch (err) { - console.error("Failed to load reminders", err); - setReminders([]); - setError("Could not load reminders."); - } finally { - setLoading(false); - } - }; - fetchReminders(); - }, []); - - if (loading) { - return ( - - -

Loading reminders...

-
-
- ); - } - - return ( - - - - - - Reminders & Forecasts - - - - {error ? ( -
- {error} -
- ) : reminders.length === 0 ? ( -
- No reminders or forecasts available -
- ) : ( -
- {reminders.map((reminder, index) => ( - -
-
-

- {reminder.vendor} -

-
- - {reminder.type === - "upcoming" ? ( - - ) : ( - - )} - {reminder.type === - "upcoming" - ? "Upcoming" - : "Forecast"} - - - {reminder.category} - -
-
-
-

- {money(reminder.amount ?? 0, null, { decimals: 2 })} -

-

- {reminder.date} -

-
-
-

- {reminder.description || - reminder.reason} -

-
- ))} -
- )} -
-
- - -
- ); -} diff --git a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx b/frontend/src/components/ai-analytics/spendingTrendGraph.tsx deleted file mode 100644 index fa5ed04..0000000 --- a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx +++ /dev/null @@ -1,254 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { TrendingUp, Activity } from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; -import { useMoney } from "@/lib/format"; -import { useProfile } from "@/components/profile/profile-provider"; -import { analyticsApi } from "@/lib/api/client"; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from "recharts"; - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - ease: [0.4, 0, 0.2, 1] as any, - delay: 0.5, - }, - }, -}; - -const CustomTooltip = ({ active, payload, label }: any) => { - const money = useMoney(); - if (active && payload && payload.length) { - return ( -
-

{label}

- {payload.map((entry: any, index: number) => ( -

- {entry.name}: {money(entry.value ?? 0)} -

- ))} -
- ); - } - return null; -}; - -export default function SpendingTrendGraph() { - const money = useMoney(); - const { profile } = useProfile(); - // Axis ticks: short amounts in the profile currency, e.g. "₹8K" / "CHF 8K". - const compact = (v: number) => - new Intl.NumberFormat(undefined, { style: "currency", currency: profile?.currency || "INR", notation: "compact", maximumFractionDigits: 1 }).format(v); - const [spendingData, setSpendingData] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchSpendingTrends = async () => { - try { - const response = await analyticsApi.getAllTimeSummary(); - if (response.success && response.monthly_trends) { - setSpendingData(response.monthly_trends); - } else { - setSpendingData([]); - } - } catch (err) { - setError("Failed to load spending trends"); - setSpendingData([]); - } finally { - setLoading(false); - } - }; - fetchSpendingTrends(); - }, []); - - if (loading) { - return ( - - -

Loading spending trends...

-
-
- ); - } - - if (error) { - return ( - - -

{error}

-
-
- ); - } - - return ( - - - - -
- - Spending Trends -
-
-
-
- - Actual - -
-
-
- - Forecast - -
-
-
- - Budget - -
-
-
-
- -
- - - - - compact(value)} - /> - } /> - - - - - -
-
-

- AI Insights -

-
-
- -
-

- November Spending Anomaly Detected -

-

- Your November spending of {money(8316)} is 454% - above your monthly average of {money(1500)}. - This represents an unusual spike with 37 - transactions (48% more than usual). -

-
-
-
-
-
- -
-

- Seasonal Pattern Recognition -

-

- Historical data shows spending increases - in Q3 (Aug-Oct). Your 2024 Q3 averaged - {money(1973)}/month. Plan for similar patterns - in 2026. -

-
-
-
-
-
- -
-

- Spending Discipline Observed -

-

- April-July 2025 maintained excellent - spending control, averaging just - {money(645)}/month. This shows strong budget - adherence during low-activity periods. -

-
-
-
-
-
-
-
- ); -} From 74995e90733faec3786123397cbdcf2401fb84f2 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:58:08 +0530 Subject: [PATCH 15/18] feat: quiet Upload/Export and light upload and export dialogs --- frontend/e2e/home.spec.ts | 42 ++++++++++++++++ frontend/src/components/exportDialog.tsx | 63 ++++++++++-------------- frontend/src/components/uploadDialog.tsx | 51 +++++++++---------- 3 files changed, 93 insertions(+), 63 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 3ea1d8d..fe1a715 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -459,3 +459,45 @@ test("a restock reminder (no vendor) neither crashes Home nor shows as a bill", await expect(page.getByTestId("bill-row")).toContainText("Netflix"); await expect(page.getByTestId("calendar-card")).toBeVisible(); }); + +// relative luminance of an element's background; the theme uses oklch(), so let a canvas turn it into sRGB first +const bgLuminance = (el: import("@playwright/test").Locator) => + el.evaluate((e) => { + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.fillStyle = getComputedStyle(e).backgroundColor; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3).map((v) => { + const x = v / 255; + return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }); + +test("Home's actions are quiet, not loud blue", async ({ page }) => { + await openHome(page); + const upload = page.getByRole("button", { name: "Upload", exact: true }); + await expect(upload).toBeVisible(); + expect(await upload.evaluate((e) => getComputedStyle(e).backgroundColor)).not.toBe("rgb(37, 99, 235)"); + const exp = page.getByRole("button", { name: "Export", exact: true }); + await expect(exp).toBeVisible(); + await expect(exp).toHaveText(""); +}); + +test("upload and export dialogs are light", async ({ page }) => { + await openHome(page); + for (const name of ["Upload", "Export"]) { + await page.getByRole("button", { name, exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + expect(await bgLuminance(dialog), `${name} dialog`).toBeGreaterThan(0.85); + expect(await dialog.evaluate((e) => e.outerHTML.match(/neutral-[89]00|#0b0612|text-white/g))).toBeNull(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + } +}); + +test("the upload dialog has one close button", async ({ page }) => { + await openHome(page); + await page.getByRole("button", { name: "Upload", exact: true }).click(); + await expect(page.getByRole("dialog").locator("button:has(svg.lucide-x)")).toHaveCount(1); +}); diff --git a/frontend/src/components/exportDialog.tsx b/frontend/src/components/exportDialog.tsx index 40af6cc..f6349bc 100644 --- a/frontend/src/components/exportDialog.tsx +++ b/frontend/src/components/exportDialog.tsx @@ -15,17 +15,8 @@ import { FileDown, Filter } from "lucide-react"; import { invoiceApi } from "@/lib/api/client"; import { toast } from "@/lib/toast"; -interface ExportDialogProps { - variant?: "default" | "secondary"; - size?: "default" | "sm" | "lg" | "icon"; - className?: string; -} - -export function ExportDialog({ - variant = "default", - size = "sm", - className = "", -}: ExportDialogProps) { +// Opened from a quiet round icon button (Phase 5 pages: no loud blue actions). +export function ExportDialog() { const [open, setOpen] = useState(false); const [format, setFormat] = useState<"csv" | "json">("csv"); const [exporting, setExporting] = useState(false); @@ -91,14 +82,13 @@ export function ExportDialog({ return ( - - + - + Export Invoices @@ -107,13 +97,13 @@ export function ExportDialog({
{/* Format Selection */}
-