soldi build log

Daily ledger of vertical slices shipped toward the PRD MVP. Each entry is the slice that landed, the verification evidence, and what's queued next.

2026-06-01 direction change: adopting the closer — v2 prototype design system and expanding scope to Comps + Pipeline + Sequences. See docs/ROADMAP.md, docs/DESIGN_SYSTEM.md, and docs/SPEC_{COMPS,PIPELINE,SEQUENCES}.md. Next build slice is the design-system migration (foundation), then the 3 new pages.


2026-06-01 — Slice 04: Keystone — cron auction resolution + buy-it-now

What shipped

  • worker/settlement.ts — pure settleOutcome({status,endTimeIso,nowMs,topBid})skip | sold | unsold (uses parseDbTime). settlement.test.ts (+7 tests).
  • worker/scheduled.tssettleDueAuctions(env,now) + exported scheduled() handler; cron ["* * * * *"] in wrangler.jsonc. Settles auctions past end_time: SOLD → status ended_sold + winning ids, converts leader hold→charge (held_balance/balance down, total_spent/leads_won up, streak bump), wallet_transactions 'charge', inserts portfolios row; UNSOLD → ended_unsold. Status-guarded UPDATEs (idempotent) + per-auction try/catch (resilient).
  • worker/routes/buynow.tsPOST /auctions/:id/buy-now: instant settlement (insert is_buy_now bid, release prior leader hold, charge buyer, ended_sold, portfolio row). Errors 401/404/409/402.
  • Frontend: buyNow() in api.ts; LeadDetail "Buy it now" button enabled with submitBuyNow (mirrors submitBid; toast + balance refresh). Skeleton extracted to keep the file < 400 lines.

Verification

$ bun run verify  → typecheck clean · Tests 69 passed (69) · build green
# wrangler dev --test-scheduled on :8787 (fresh bootstrap)
buy-now A_CHI_TAXLIEN_03 → bought:true, status ended_sold; buyer 50000→39200,
   totalSpent 10800, leadsWon 1, streak 1; portfolios row (status 'new') created.
real bid 13835 (held 13835) → force end_time past → /__scheduled trigger →
   auction ended_sold (winning_user_id set); buyer 39200→25365, held→0,
   totalSpent 24635, leadsWon 2, streak 2; portfolios=2. Idempotent on re-run.

Next up: Portfolio KPIs + weekly Leaderboard from D1 (now that settlement populates real data); then CI + a Playwright bid/buy-now e2e.


2026-06-01 — Ops: cloud-docs response persistence (KV)

Added KV-backed submission capture to the soldi-docs worker:

  • KV namespace RESPONSES (29c8518…) bound in tools/cloud-docs/wrangler.jsonc.
  • Worker: POST /api/responses stores {id, submittedAt, country, userAgent, answers} under resp:<id>; GET /api/responses[/:id] lists/fetches. All open (light, non-confidential — no auth, no token to paste). Everything else → assets.
  • Questionnaire: added a "Submit to soldi →" button that POSTs the export object to /api/responses (graceful fallback to local download when offline).
  • Verified live: POST→{ok,id}, list/by-id round-trip (country US, answers intact), invalid JSON→400, open GET→200. Read responses: curl https://soldi-docs.camolechowski.workers.dev/api/responses.

2026-06-01 — Ops: reproducible setup + codebase-wide simplify (round 2)

Setup hardened: pinned wrangler dev to :8787; added bootstrap (reset+migrate+seed), start (build+dev), verify (typecheck+test+build), db:reset:local. Clean-slate bootstrap → verify → start proven: health OK, 12 auctions from D1, SPA 200.

Simplify round 2 (8 agents, disjoint lanes incl. worker):

  • worker/index.ts 402→36 lines, split into worker/routes/{auctions,bid,auth}.ts
    • shared worker/users.ts (UserRow/publicUser/USER_SELECT_SQL/currentUser); currentUser(c) takes the Context directly; STATUS_WHERE map; deduped bids SQL; removed dead SessionVars.
  • frontend: deduped FeedState, QUALITY_ROWS map, hoisted per-render consts, StatusDot primitive, postJson helper in api.ts, run() helper in session.tsx, deleted dead formatPriceDelta.
  • index.css: removed 23 dead back-compat aliases (verified zero orphaned utility usages across src/).
  • Verified: typecheck clean · 62/62 tests · build green · 0 orphaned classes · login + marketplace screenshots confirm no visual regression. Snapshot: .backup_src_round2.tgz.

2026-06-01 — Ops: UI polish pass + cloud docs deployed

Polish workflow (25 agents): per-page apply of the make-it-sexy sub-skills (cutting-edge-ux-patterns, fluid-micro-interactions, high-end-ui-assembly, modern-visual-aesthetics; rsc-streaming-architectures correctly skipped for a Vite SPA) then make-it-simpler, then a final unscoped DRY pass. Net: micro- interactions/reveal staggers, deduped FeedState/SectionHeading/Collapse, DRYed bid-reload + min-bid rounding, null-safe badges. No new deps; index.css frozen except the solo final pass. Verified: typecheck clean · 62/62 tests · build green · screenshot. Pre-pass snapshot at .backup_src_phaseUX.tgz.

Cloud docs deployedtools/cloud-docs/ (Workers Static Assets). build.ts renders docs/*.md + BUILD_LOG.md → soldi-branded HTML and folds in the client questionnaire. Live (personal CF account, creds from .env): https://soldi-docs.camolechowski.workers.dev (routes: /roadmap, /design-system, /spec-comps, /spec-pipeline, /spec-sequences, /build-log, /questionnaire.html).

Gotcha: an assets-only Worker (no main) returned a persistent edge error 1105 / 503 on workers.dev despite a successful upload and an enabled subdomain. Fix: add a minimal main entry (src/index.tsenv.ASSETS.fetch(req)) with an ASSETS binding — the canonical Workers-Static-Assets form. Use this in the tools/ wrangler template.


2026-06-01 — Phase A: Design-system migration (closer-v2 reskin, still "soldi")

What shipped (foundation written inline; 4 page-restyles fanned out in parallel)

  • src/index.css — rewrote @theme + :root to the closer-v2 metallic palette (bull/bear/warn/hot/accent-lavender/gold + surface/text/border tiers), added Fraunces/Instrument Serif/Hanken Grotesk/JetBrains Mono tokens, the 56px grid wash (body::before), new shadows/radii, keyframes (price-flash, urgent-pulse, reveal-up), and a button variant system (.btn-primary now bull, .btn, .btn-ghost, .btn-danger, .pill, .chip, .qscore). Old token names kept as aliases so utilities keep resolving during the migration. No Robinhood green.
  • index.html — swapped font <link> to Fraunces/Instrument Serif/Hanken Grotesk/ JetBrains Mono; body bg #0B0D0E.
  • Restyle agents (disjoint files): shell (TopNav wordmark = "soldi" in Instrument Serif + bull dot; tab-bar nav with disabled Comps/Pipeline/Sequences "soon" tabs; LiveTicker; Layout), floor+cards+ui (Marketplace "The floor", AuctionCard/Grid, QualityBadge→.qscore conic circle, PriceDisplay/Countdown/ HotBadge/DistressTag), lead-detail (bidding panel — logic preserved), and portfolio+misc (Portfolio/Leaderboard/Activity/Login).
  • Name stays "soldi" everywhere (worker, package, wordmark). Only "closer" reference left is an internal doc comment noting the prototype's origin.

Verification (commands + key output)

$ bun run typecheck   → tsc -b clean
$ bun run test        → Test Files 5 passed (5) · Tests 62 passed (62)   (logic intact)
$ bun run build       → ✓ built; dist/assets/index-*.css 35.00 kB
# wrangler dev :8787 + chrome-devtools screenshots:
#   / (marketplace)        → soldi wordmark, tab bar, "The floor" (Fraunces),
#                            conic quality circles, distress/equity/age chips,
#                            mono prices, bull quick-bid buttons.
#   /lead/A_PHX_PROBATE_07 → Instrument Serif address, Fraunces section heads,
#                            bid input prefilled to min ($250), bull "Sign in to
#                            bid" CTA, restyled bid history. Bidding UI intact.

Next up

Phase B — fan out the 3 new pages (Pipeline read · Comps UI+mock · Sequences read) per docs/ROADMAP.md, each with its own migration + Hono route + page, worktree-isolated, verified per vertical.


2026-06-01 — Slice 03: Bidding end-to-end (increments, holds, anti-snipe)

What shipped

  • worker/bidding.ts — pure, unit-testable rules: minBidIncrement ($5 or 5%, whichever greater), minNextBid, applyAntiSnipe (final-2-min window → +2min, max 5 extensions), validateBid, and parseDbTime (normalizes SQLite's space-separated datetime() output to ISO-UTC).
  • worker/index.tsPOST /api/v1/auctions/:id/bid: auth-gated, Zod body, loads auction + current leader, validates, applies anti-snipe, and writes atomically via DB.batch — insert bid, bump current_price/bid_count/ end_time/snipe_extensions, place the new leader's hold, release the prior leader's hold, and record hold/release wallet_transactions. Returns the refreshed auction + extended flag. Error map: 401 unauthorized, 404 not_found, 409 auction_ended/already_leading, 422 bid_too_low, 402 insufficient_funds.
  • worker/bidding.test.ts — 20 unit tests (increment floor/percent, snipe window/boundary/cap/ended, db-time parsing both formats, all validation paths).
  • src/lib/api.tsplaceBid() helper.
  • src/pages/LeadDetail.tsx — live bid form (input prefilled to min next bid), error-code→toast mapping, "Extended! 2:00 added" toast on anti-snipe, 5s auction polling (PRD §12), balance refresh after a successful bid.

Verification (commands + key output)

$ bun run typecheck   → tsc -b clean (exit 0)
$ bun run test        → Test Files 5 passed (5) · Tests 62 passed (62)
$ bun run build       → ✓ 62 modules transformed; built in ~0.6s

# local D1 + wrangler dev on :8787 (after rm -rf .wrangler/state/v3/d1
#   && bun run db:migrate:local && bun run db:exec:local migrations/0002_seed.sql)
POST /auctions/A_CHI_PREFCL_01/bid  (no auth)       → 401
POST … {amount:13000}  (< minNext 13335)           → 422 bid_too_low
POST … {amount:60000}  (> available 50000)          → 402 insufficient_funds
POST … {amount:13335}  (valid, bidder A)            → 201 currentPrice 13335, bidCount 6
POST … {amount:15000}  (A already leader)           → 409 already_leading
GET  /auth/me (A)                                    → heldBalance 13335, balance 50000
POST … {amount:15000}  (bidder B outbids A)          → 201
GET  /auth/me (A)  → heldBalance 0   |  (B) → heldBalance 15000   (prior hold released)
wallet_transactions(A) → hold 13335 then release 13335 (reference A_CHI_PREFCL_01)
# anti-snipe: forced A_DAL_CODE_09 end_time to +60s, bid as B
POST … {amount:7850}  → {extended:true}; end_time 04:14:51 → 04:16:51 (+2:00); snipe_extensions 0→1

Gotcha caught during verify

D1's datetime() returns space-separated timestamps (2026-06-01 04:13:27, no T, no Z), which Date.parse turns to NaN in workerd. First anti-snipe test silently failed (extended:false, no extension) AND the auction_ended guard would never trip (NaN <= now is false). Added parseDbTime to normalize both SQLite and ISO formats; re-verified the extension fires (+2min, ext 0→1).

Next up

Design-system migration (foundation slice) per docs/DESIGN_SYSTEM.md — port the closer v2 tokens/typography/components into index.css + index.html and restyle the existing pages, no data changes. Then the 3 new pages (Pipeline, Comps, Sequences) per docs/ROADMAP.md Phase B.


2026-05-29 — Slice 01: Marketplace feed end-to-end from D1

What shipped

  • migrations/0002_seed.sql — 13 leads + 12 auctions across IL/FL/AZ/TX/GA/CA, all distress types, quality 34–91, freshness from 15min to 32h old, 1 discount auction, 1 placeholder user, 5-bid history on the Phoenix probate auction. All times anchored to datetime('now', ...) so "ending soon"/"new" tabs stay realistic across runs.
  • worker/mappers.ts — snake_case D1 row → camelCase frontend Auction DTO with nested lead and bids[], including QualityBreakdown JSON parsing guarded by Zod and a fallback for malformed JSON. Shared AUCTION_SELECT_SQL for list + detail.
  • worker/index.tsGET /api/v1/auctions?status=active|discount|all&limit=N (Zod-validated query) and GET /api/v1/auctions/:id returning the auction with full bid history joined to users.display_handle. Proper 404 for unknown ids.
  • worker/mappers.test.ts — 8 unit tests covering the row→DTO mapping, JSON parse fallbacks, discount flags, status coercion, bid attachment.
  • src/lib/api.ts — tiny typed fetch helpers (fetchAuctions, fetchAuction) with AbortSignal support.
  • src/pages/Marketplace.tsx — replaces MOCK_AUCTIONS with live API. Loading skeleton, error card, empty-tab card. Tab filters now run against real D1 data.
  • src/pages/LeadDetail.tsx — fetches the single auction by id, renders a bid history panel when present, loading/error/missing states.

Verification (commands + key output)

$ bun run typecheck
$ tsc -b
# (clean)

$ bun run build
$ tsc -b && vite build
✓ 60 modules transformed.
dist/index.html                   0.82 kB │ gzip:  0.46 kB
dist/assets/index-Cf1hfNuH.css   23.05 kB │ gzip:  5.25 kB
dist/assets/index-CLIXHb-m.js   252.42 kB │ gzip: 79.57 kB
✓ built in 574ms

$ bun run test
 Test Files  3 passed (3)
      Tests  28 passed (28)

Local D1 + wrangler dev smoke (after rm -rf .wrangler/state/v3/d1 && bun run db:migrate:local):

$ curl -sS http://localhost:8788/api/v1/health
{"ok":true,"service":"soldi","time":"2026-05-30T04:13:07.901Z"}

$ curl 'http://localhost:8788/api/v1/auctions?status=all&limit=100'  → count: 12
  A_CHI_TAXLIEN_03   Chicago, IL    q=62 price=$54   ends 04:54 (ending-soon)
  A_PHX_TAXLIEN_13   Phoenix, AZ    q=80 price=$29   status=discount
  A_CHI_PREFCL_01    Chicago, IL    q=87 price=$127
  A_PHX_PROBATE_07   Phoenix, AZ    q=89 price=$238
  A_MIA_DIVORCE_06   Miami, FL      q=81 price=$172
  A_DAL_ABSENTEE_08  Dallas, TX     q=65 price=$62
  A_ATL_PREFCL_10    Atlanta, GA    q=76 price=$111
  A_CHI_PROBATE_02   Chicago, IL    q=78 price=$142
  A_MIA_TAXLIEN_05   Miami, FL      q=91 price=$210
  A_HOU_PROBATE_11   Houston, TX    q=83 price=$132
  A_SPRING_VACANT_04 Springfield,IL q=34 price=$25
  A_DAL_CODE_09      Dallas, TX     q=70 price=$69

$ curl http://localhost:8788/api/v1/auctions/A_PHX_PROBATE_07
  bids: 5 (PhoenixVolume @ $190 → $205 → $218 → $225 → $238)
  qualityBreakdown: {equity:25, motivation:25, propertyValue:20, contactQuality:12, dataCompleteness:7}

$ curl /api/v1/auctions/does_not_exist  → HTTP 404 {"error":"not_found"}
$ curl '/api/v1/auctions?status=discount' → count: 1 (A_PHX_TAXLIEN_13)

$ curl / → HTTP 200, <title>soldi - Distressed Property Lead Auctions</title>
$ curl /lead/A_CHI_PREFCL_01 → HTTP 200 (SPA fallback)

Gotcha caught during verify

First seed run inserted only 9/12 auctions silently. Root cause: SQLite datetime() takes modifiers as separate arguments — '+23 hours 30 minutes' is not a valid single modifier and returns NULL, which then violated end_time NOT NULL under INSERT OR IGNORE. Fixed three rows to use datetime('now','+23 hours','+30 minutes') form. Re-applied migrations after wiping .wrangler/state/v3/d1; now 12/12.

Next up

Auth + identity (the second slice that unlocks bidding, watching, portfolio, wallet). Concretely: a register/login pair on /api/v1/auth, password hashing with the Web Crypto API (PBKDF2 — no node bcrypt in Workers), a signed session cookie or short JWT, a useSession() hook, and a GET /user/profile endpoint backed by the existing users row. The bidding slice depends on having a user_id to charge/hold against.


2026-05-30 — Slice 02: Auth + identity end-to-end

What shipped

  • worker/auth.ts — Web Crypto PBKDF2-SHA256 password hashing (100k iterations, 16-byte salt, 32-byte hash; stored as pbkdf2$<iters>$<salt-b64>$<hash-b64>), constant-time compare, HMAC-SHA256-signed session token (<body-b64>.<sig-b64>, 30-day TTL, expiry embedded in payload), buildSessionCookie / clearSessionCookie / readSessionCookie helpers (HttpOnly, SameSite=Lax), and generateId / generateHandleFromName helpers.
  • worker/types.tsEnv now carries SESSION_SECRET. Local secret in .dev.vars (gitignored), required by both /auth/register and /auth/login.
  • worker/index.ts — five new endpoints under /api/v1:
    • POST /auth/register (Zod-validated {email, password>=8, name}, 409 on dup email, sets cookie, also writes a wallet_transactions signup_bonus row crediting the $500 demo balance).
    • POST /auth/login (verifies hash, updates last_active_at, sets cookie, 401 on bad creds).
    • POST /auth/logout (clears cookie).
    • GET /auth/me (returns { user | null }, never 401 — used by the session hook on every page load).
    • GET /user/profile (401 if unauth; returns the same public-user shape).
    • Shared USER_SELECT_SQL + publicUser() redact password_hash and other internal flags before returning to the client.
  • worker/auth.test.ts — 14 unit tests: salting, wrong-password, malformed-hash, token round-trip, tampered body, foreign secret, expired token, malformed token, cookie shape, cookie clear, cookie read, id uniqueness, handle suffix.
  • src/lib/api.tsfetchMe / login / register / logout typed helpers with credentials: 'same-origin' and a shared readError that pulls the API's { error } code out of the body.
  • src/lib/session.tsxSessionProvider + useSession() hook with { user, loading, error } state plus login/register/logout/refresh actions; auto-fetches /auth/me on mount.
  • src/pages/Login.tsx — dark Robinhood-style login + register form (toggleable, ?mode=register deep-link), inline error mapping for the API error codes (invalid_credentials, email_taken, invalid_body, server_misconfigured), accent-glow focus rings.
  • src/layouts/TopNav.tsx — replaces the mock balance + initials chip with the real session. Shows a loading shimmer while /auth/me resolves, a real balance pill + initials button (with a popover for Sign out) once authed, and Sign in / Sign up links when not authed.
  • src/App.tsx — wraps the router in <SessionProvider> and adds the /login route.

Verification (commands + key output)

$ bun run typecheck
$ tsc -b
# (clean — exit 0, no output)

$ bun run test
 Test Files  4 passed (4)
      Tests  42 passed (42)

$ bun run build
 ✓ 62 modules transformed.
 dist/index.html                   0.82 kB │ gzip:  0.46 kB
 dist/assets/index-DGVY73fC.css   27.88 kB │ gzip:  5.92 kB
 dist/assets/index-DD7iVCN9.js   260.12 kB │ gzip: 81.49 kB
 ✓ built in 593ms

Local wrangler dev smoke (after wiping .wrangler/state/v3/d1 and re-running bun run db:migrate:local). The dev server bound to a random port (59545) this run — wrangler@3.114.17 no longer pins to 8788; check the boot banner.

$ curl /api/v1/auth/me              → {"user":null}
$ curl /api/v1/user/profile         → HTTP/1.1 401 Unauthorized
$ curl POST /auth/register {bad}    → HTTP/1.1 400 Bad Request
                                       (Zod issues for email/password/name)
$ curl POST /auth/register          → HTTP/1.1 201 Created
   {cam@soldi.test, hunter222, "Cam Olechowski"}
   Set-Cookie: soldi_session=…; HttpOnly; SameSite=Lax; Max-Age=2592000
   {user.id: U_MPT9JLPC_…, displayHandle: CamOlechowsk7389,
    balance: 50000, heldBalance: 0, createdAt/lastActiveAt set}

$ curl /auth/me  (with cookie)      → {user: {…full profile…}}
$ curl /user/profile (with cookie)  → HTTP/1.1 200 OK

$ curl POST /auth/register {dup}    → HTTP/1.1 409 Conflict  {"error":"email_taken"}
$ curl POST /auth/login  {wrong pw} → HTTP/1.1 401 Unauthorized {"error":"invalid_credentials"}
$ curl POST /auth/login  {correct}  → HTTP/1.1 200 OK + Set-Cookie + updated last_active_at
$ curl POST /auth/logout            → HTTP/1.1 200 OK + Set-Cookie: …; Max-Age=0
$ curl /auth/me (post-logout)       → {"user":null}

$ curl /api/v1/auctions?status=all&limit=200  → count: 12 (slice 01 still green)
$ curl / and /login                 → HTTP/1.1 200 OK  (SPA + fallback intact)

$ wrangler d1 execute soldi --local --command \
    "SELECT type, amount, description FROM wallet_transactions WHERE user_id = 'U_…';"
  → {type: 'credit', amount: 50000, description: 'Welcome bonus'}

Gotcha caught during verify

Wrangler 3.114 no longer defaults wrangler dev to port 8788 — it picks a random ephemeral port (this run: 59545) and prints it in the boot banner. The first curl against the assumed 8788 failed ("Couldn't connect"). Pulled the port out of the wrangler stdout ([wrangler:inf] Ready on http://localhost:59545) before re-running the smoke battery. Worth pinning dev.port in wrangler.jsonc on a later polish slice so the port stops moving between runs.

Next up

Bidding — the slice that finally turns soldi into an auction house. Concretely: POST /api/v1/auctions/:id/bid with Zod-validated amount, PRD §8 increment table enforcement, balance + held-balance accounting (reserve the new highest bid, release the prior leader's hold), PRD §9 anti-snipe (+30s if the bid lands in the last 30s, capped at 12 extensions / 6 min), insert into bids, update auctions current_price / bid_count / end_time / snipe_extensions, and return the refreshed auction. Frontend: a bid input + submit on LeadDetail wired through the session, optimistic bid-history prepend, balance refresh, and error toasts for the failure modes (insufficient_funds, bid_too_low, auction_ended, unauthorized). Tests: a worker-side unit test for the increment + anti-snipe rules.