Ventura Claw Leads

repo: ~/Projects/ventura-claw-leads · 68 commits · 0 in last 24h, 0 in last 7d ·

Search the build

68 commits indexed

  1. 564a115 2026-05-31 Add root /favicon.{ico,svg} route (static is mounted under /static, so browser /favicon.ico 404'd)
  2. 61d2e0e 2026-05-31 Add per-site favicon (kills /favicon.ico 404)
  3. 34665fb 2026-05-19 neighborhood: add sort + density grid controls
  4. 7c04e29 2026-05-19 admin: add rel=noopener noreferrer on target=_blank links
  5. 350a516 2026-05-19 gitignore: exclude editor/snapshot backup files
  6. a6b54c3 2026-05-07 Fix legal-page ZIP in CAN-SPAM footer: 91335→91403
  7. fbd4be4 2026-05-07 rel=alternate JSON + HTTP Link header on /find paginated routes
  8. a182931 2026-05-07 Footer centered: 5-col equal grid + text-align center on every column
  9. d613a8f 2026-05-07 Right-rail visibility: brass-color + flex-shrink:0 on home rail 'See all N →' links
  10. 94e6ea5 2026-05-07 /find a11y: combobox pattern + aria-current on pagination + cleaner select labels
  11. bdc2730 2026-05-07 Auto-fire codex-3way on every commit (hook + viewer)
  12. 6ca9edb 2026-05-07 Geocode the final 38 stragglers via Nominatim — map now 100% covered
  13. ab2ce52 2026-05-07 /for-businesses: Find-your-business CTA + claim-flow narrative + search box
  14. 5a49679 2026-05-07 Inject neighborhood into seeded headlines (170 distinct phrasings, was ~60)
  15. a8cee0a 2026-05-07 Tighten falsy-zero check in neighborhood.ejs total display
  16. 199f582 2026-05-07 Fix 3 bugs in backfill-corridor-geo.js caught by codex-3way debate
  17. 6e61684 2026-05-07 Backfill 206/244 missing lat/lng on corridor-seeded rows
  18. fb3a3e4 2026-05-07 Home gets Organization+WebSite+FAQPage JSON-LD; neighborhood gets ItemList
  19. dbc5da9 2026-05-07 Cross-link neighborhood pages to vertical+city combo pages
  20. d421ce8 2026-05-07 Add 37 vertical+city combo pages to sitemap; unclaimed banner on listing detail
  21. 05ae6c7 2026-05-07 Add per-vertical sample rails to home page (food/beauty/retail/creative)
  22. 5cc49d8 2026-05-07 Sitemap covers paginated pages; head supports rel=prev/next + filtered canonicals; map-CTA on home + find
  23. 3f90e3f 2026-05-07 Add pagination to /find — page=N param + Prev/Next nav
  24. c64097d 2026-05-07 Per-vertical SEO meta uses real total + city names; visible count shows truth
  25. 1acea14 2026-05-07 Add scripts/dedupe-corridor-shells.sql + hide 119 noisy seed rows
  26. f62acb4 2026-05-07 add in-process 120s cache for /api/businesses.geo to prevent RSS spikes
  27. 602dc99 2026-05-07 increase max_memory_restart from 300M to 600M to stop OOM-triggered restart loop
  28. 8485ef7 2026-05-07 Drop misleading 'verified listings' meta-description copy
  29. 6572248 2026-05-07 Generalize corridor seed to all 8 verticals (1,938 directory shells)
  30. 50d32a1 2026-05-07 Seed food vertical from ventura_corridor public data (OSM + LA BTRC)
  31. 92a37d6 2026-05-07 feat(seo): image sitemap support for Google Image search
  32. d818054 2026-05-07 feat(seo): per-business dynamic Open Graph images
  33. c5e7571 2026-05-07 feat(admin): surface share-count on dashboard stat row
  34. f5d2cd9 2026-05-07 feat(ux): hand-drawn SVG glyphs replace emoji on vertical hero gradients
  35. a52f7bd 2026-05-07 feat(share): per-business share-count w/ session-dedup tracking
  36. 826d347 2026-05-07 yolo tick 29: weekly-digest worker — Monday-morning summary email per claimed business
  37. 91c32a2 2026-05-07 yolo tick 28: home 'Recently joined' rail (conditional ≥3 claimed)
  38. f40cfda 2026-05-07 yolo tick 27: 'More <vertical> nearby' rail at the bottom of every profile
  39. f41ef50 2026-05-07 yolo tick 26: per-neighborhood landing pages — /neighborhood/sherman-oaks etc.
  40. 870584a 2026-05-07 yolo tick 25: /find auto-suggest dropdown — debounced AJAX, keyboard nav
  41. e1590f8 2026-05-07 yolo tick 24: home featured grid — photo-priority within tier + render hero per card
  42. c73de6a 2026-05-07 yolo tick 23: og:image + Twitter Card image + 'Share this listing' button
  43. 77c0ec3 2026-05-06 yolo tick 22: /admin/leads pagination — 50/page with prev/next chips
  44. eb723c7 2026-05-06 yolo tick 21: per-business profile-view counter (with bot+session+owner dedup)
  45. 2bd5453 2026-05-06 yolo tick 20: breadcrumb nav + BreadcrumbList JSON-LD on /business/:slug + admin/team last-active sort
  46. 863a780 2026-05-06 yolo tick 19: sharp resize + WebP convert + EXIF strip on photo upload
  47. 65c0b91 2026-05-06 yolo tick 18: per-business cover photo upload (multer + 2MB cap + type whitelist)
  48. 8634cfd 2026-05-06 yolo tick 17: per-vertical SEO landing pages + home FAQ section
  49. 53d59c1 2026-05-06 yolo tick 16: profile activity badge (≥3 threshold) + /find empty-state with vertical pivot
  50. 4e2663d 2026-05-06 yolo tick 15: home-page 'How it works' 3-step explainer
  51. 294e68a 2026-05-06 yolo tick 14: /admin/leads search-within-messages
  52. 65ee0ec 2026-05-06 yolo tick 13: /map pin hover preview + /admin/billing recent activity feed
  53. abf7235 2026-05-06 yolo tick 12: 30-day lead-trend sparkline on admin dashboard
  54. 2c5302b 2026-05-06 yolo tick 11: admin invite-teammate flow — multi-user per business
  55. f3a4936 2026-05-06 yolo tick 10: home-page social proof + /admin/leads date-range filter
  56. 0d288c7 2026-05-06 yolo tick 9: lead read/unread tracking + dashboard unread count
  57. 1a8b9b4 2026-05-06 yolo tick 8: vertical-themed hero panels (no external image fetches)
  58. d4d9dd2 2026-05-06 yolo tick 7: bug fix — drop DEFAULT now() from business_interest.delivered_at
  59. 81d7dec 2026-05-06 yolo tick 6: /admin/leads.csv export + mobile audit clean
  60. 20a0eb6 2026-05-06 yolo tick 5: sort + density slider on /find (per global standing rule)
  61. 713a5b4 2026-05-06 yolo tick 4: /robots.txt + /sitemap.xml — Google indexing infra
  62. e42956b 2026-05-06 yolo tick 3: 46-test npm test suite covering auth, compliance, stripe-lib, verticals, routes
  63. 9c3866d 2026-05-06 yolo tick 2: /unsubscribe handler — RFC 8058 one-click + landing page
  64. 520e445 2026-05-06 yolo tick 1: JSON-LD LocalBusiness + ItemList structured data
  65. cc3cfef 2026-05-06 v0.2 #3: admin/claim flow + lead inbox + profile editor + billing UI
  66. 319e023 2026-05-06 v0.2 #4: Stripe subscription billing — products, prices, webhook live
  67. a6b4e19 2026-05-06 v0.2 #1+#2: lead-delivery cron + /map Leaflet view
  68. 77e482c 2026-05-06 v0.1: ventura-claw-leads scaffold — directory + lead-routing for unregulated Ventura Blvd small businesses

Authors

Agents used

  • none detected

Skills used

  • /find55
  • /business23
  • /leads21
  • /map11
  • /neighborhood10
  • /billing10
  • /lng9
  • /team9
  • /claim9
  • /profile7
  • /businesses6
  • /beauty5
  • /retail5
  • /creative5
  • /webhooks5
  • /unsubscribe5
  • /for-businesses4
  • /partials4
  • /stripe4
  • /favicon3
  • /next3
  • /empty3
  • /page3
  • /uploads3
  • /photo3
  • /dashboard3
  • /auth3
  • /invite3
  • /accept-invite3
  • /compliance3

Creative ideas + design notes

Commits with substantial prose (≥120 chars) — the rationale behind each move.

34665fb · 2026-05-19 · neighborhood: add sort + density grid controls
The /neighborhood/:slug page renders a primary business-grid but was
missing the sort <select> + density slider that /find already has.
Standing rule — every site that renders a product/business grid MUST
ship both controls, persisted to localStorage.

Adds a server-side sort whitelist (featured / newest / updated /
name_asc / name_desc / vertical) mirroring /find's options exactly, and
a frontend JS block that hydrates the saved value from localStorage
BEFORE the first grid load. The 'vcl-grid-sort' + 'vcl-grid-density'
keys are deliberately shared with /find so a user's preference carries
across both surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7c04e29 · 2026-05-19 · admin: add rel=noopener noreferrer on target=_blank links
The two admin "View your live profile" links opened in a new tab without
the rel pair, leaking window.opener and Referer to the public profile
page. Internal but still worth fixing — matches the rel pattern already
used on the public business.ejs external links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
350a516 · 2026-05-19 · gitignore: exclude editor/snapshot backup files
Adds *.bak, *.bak.*, *.pre-*, *.orig, *.rej, *.swp, *~ so accidental
snapshot files never land in commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a6b54c3 · 2026-05-07 · Fix legal-page ZIP in CAN-SPAM footer: 91335→91403
Steve confirmed 2026-05-07. (.env MAILING_ADDRESS also corrected
locally but .env is gitignored so not in this commit.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fbd4be4 · 2026-05-07 · rel=alternate JSON + HTTP Link header on /find paginated routes
New /api/find route mirrors /find query+filter+pagination, returns JSON.
HTML head adds <link rel="alternate" type="application/json"> when
alternateJsonUrl is in render context. /find route now sets HTTP Link
header with rel=canonical, rel=prev, rel=next, rel=alternate (RFC 8288).

Smoke-tested locally:
- GET /api/find?vertical=beauty&page=2 returns {query,pagination,results}
- HEAD /find?vertical=beauty&page=2 carries 4-entry Link: header
- HTML head emits 3 <link rel> tags including the new alternate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a182931 · 2026-05-07 · Footer centered: 5-col equal grid + text-align center on every column
Was 4-column 2fr/1fr/1fr/1fr (5 cols of markup, brand col stretched), all
left-aligned. Steve called out the asymmetry. Now 5 equal columns,
each col is a flex column with align-items:center, max-width 1100px so
content is centered without sprawling, fineprint stacks vertically
center-aligned. Tablet breakpoint switched from repeat(2,1fr) to
auto-fit minmax(160px,1fr) so the 5 cols collapse cleanly without an
awkward 2+2+1 row.
d613a8f · 2026-05-07 · Right-rail visibility: brass-color + flex-shrink:0 on home rail 'See all N →' links
Likely candidate for Steve's 'cannot see item on right' feedback. The
4 per-vertical home rails (food/beauty/retail/creative) used class=
'muted' (grey #5b5750 light / #9c958a dark) on the See-all links —
low contrast and easy to miss against an h2 brand heading on the
same flex row.

Three fixes:
- color:var(--brass) — pop against the body text
- font-size 13→14 — slightly larger affordance
- flex-shrink:0 + white-space:nowrap — keeps the link from wrapping
  weirdly when h2 fills the row at narrow widths

Now visible at all viewports across all 4 rails.
94e6ea5 · 2026-05-07 · /find a11y: combobox pattern + aria-current on pagination + cleaner select labels
Search input now implements the WAI-ARIA combobox pattern: role='combobox',
aria-controls='find-suggest', aria-autocomplete='list', aria-expanded
toggling on open/close, and aria-activedescendant tracking the active
suggestion as the user arrows through. Each <li> in the suggest list
now has a stable id + aria-selected. Screen readers will announce
each suggestion as the user moves the focus marker.

Removed emoji-in-option-text from the Category select — '🍽 Food &
drink' reads as 'fork-and-knife Food and drink' on JAWS/NVDA. Plain
labels match what's spoken.

Pagination now has aria-current='page' on the 'Page N of M' indicator
and explicit aria-label='Previous page' / 'Next page' on the arrow
links so the rel=prev/next attribute isn't the only signal.

Sort/Category/City selects now use proper for=/id= label association
instead of just implicit wrapping (Safari + older AT prefers explicit).
bdc2730 · 2026-05-07 · Auto-fire codex-3way on every commit (hook + viewer)
scripts/post-commit-hook.sh runs the 3-way debate in background after
every commit (with skips for trivial diffs and non-code paths). Hook
output lands at .git/codex-3way-hook/latest.summary.md as a symlink to
the latest run's summary, so the next yolo-loop tick can just read
that file and adjudicate findings without manually invoking the skill.

Three tracked scripts:
- post-commit-hook.sh : the actual hook logic (skip rules + bg fire)
- install-hooks.sh   : per-clone setup (symlinks .git/hooks/post-commit)
- last-debate.sh     : print the latest summary for ad-hoc inspection

Skip rules:
- only fires when .js/.ejs/.sql/.ts/.tsx/.json/.sh files changed
- skips diffs <5 lines (avoids burning compute on doc tweaks)
- self-recursion guard via CODEX_3WAY_HOOK_RUNNING env var

Non-blocking: nohup + disown so commit returns immediately.
6ca9edb · 2026-05-07 · Geocode the final 38 stragglers via Nominatim — map now 100% covered
scripts/geocode-nominatim.js hits OSM Nominatim (free, 1 req/sec policy,
real User-Agent) for the rows the corridor cross-reference couldn't
match. First pass got 32 of 38; the 6 misses had unusual address
suffixes ('Office #20', 'Space #203', 'Front', '17047 1/2'). Tightened
the cleanStreet regex to strip those and got the last 6.

Map markers: 1,811 → 1,849 (every active business is now pinned).
Zero corridor-seeded rows without lat/lng. Idempotent — only updates
where latitude IS NULL.

Took ~75 seconds total to geocode all 38 (rate-limited).
ab2ce52 · 2026-05-07 · /for-businesses: Find-your-business CTA + claim-flow narrative + search box
The pricing page hadn't been updated since before the corridor seed.
It was framed as 'email us to get listed' — but with 1,819 listings
already seeded, most owners hitting the page are looking to claim,
not request a new entry.

Adds: hero CTAs ('Find your business →' + 'See pricing'), an
explainer paragraph stating 1,800+ listings are pre-seeded with a
free claim button, and a 'Two ways to get on Ventura Claw' section
that splits the path: (1) claim existing, (2) email if missing.
Closes with an inline search box that submits to /find — the
shortest path from /for-businesses to the claim flow.

codex-3way (D+P+Pragmatist) round-1 reviewed the headline rewrite
(prior commit 5a49679); 4 PROSECUTOR findings all false positives
(city is enum-constrained by CITY_MAP, can never be null/empty/
user-controlled). DEFENDER SHIP verdict accepted.
5a49679 · 2026-05-07 · Inject neighborhood into seeded headlines (170 distinct phrasings, was ~60)
Templated headlines like 'Hair salon on Ventura Blvd.' (408 rows) and
'Sit-down restaurant on Ventura Blvd.' (425 rows) now read 'Hair salon
in Sherman Oaks.' / 'Sit-down restaurant in Encino.' — a single
deterministic UPDATE that rewrites both headline + description in
place. Distinct headline strings: ~60 → 170 (3x more variety, ~11
rows per phrasing avg).

Plus matching change to scripts/seed-corridor-all.js so future
re-seeds bake the city in at insert time instead of needing a
post-hoc UPDATE.
a8cee0a · 2026-05-07 · Tighten falsy-zero check in neighborhood.ejs total display
Use 'typeof !== undefined' instead of truthiness so a legit 0
doesn't fall back to businesses.length. Cosmetic — both equal 0
for empty neighborhoods so no user-visible change. Caught by
codex-3way PROSECUTOR sweep on HEAD~5..HEAD.
199f582 · 2026-05-07 · Fix 3 bugs in backfill-corridor-geo.js caught by codex-3way debate
PROSECUTOR (kimi-k2.5) flagged these in round 1 of the 3-way debate;
DEFENDER (qwen3:14b) said ship and PRAGMATIST (gemma3:12b) blocked on
a hallucinated SQL injection. After per-finding adjudication against
the actual code, three of PROSECUTOR's findings were genuine:

1. streetKey('') returns '' which collapses every empty-addr row into
   a single |city bucket. If both a VC row and a corridor row had
   empty street, they'd match incorrectly. Now skipped on both index
   and lookup sides.

2. Catch handler called process.exit(1) without closing PG pools.
   Could hang the process up to idleTimeoutMillis. Now best-effort
   close before exit.

3. (Marginally) the 244-row UPDATE loop has no transaction. Idempotent
   on retry so deferred to a follow-up — not blocking ship.

Two of PROSECUTOR's findings were false positives (m.zip.slice was
already guarded; missing PGPASSWORD doesn't affect Mac trust auth)
and got correctly knocked down by DEFENDER.
6e61684 · 2026-05-07 · Backfill 206/244 missing lat/lng on corridor-seeded rows
scripts/backfill-corridor-geo.js cross-references each VC row missing
lat/lng against the ventura_corridor PG (15,551 corridor rows with
coordinates) using a normalized street+city key with name+zip fallback.
244 - 38 = 206 rows recovered (84%); the 38 stragglers have street
addresses that just don't appear in the corridor lat/lng index.

Map marker count: 1,719 → 1,811 (+92, since some of the 206 weren't
yet active or had been hidden in the dedupe pass; net visible +92).
Reversible — re-running the seed picks up corrected lat/lng from the
corridor source on next run.
fb3a3e4 · 2026-05-07 · Home gets Organization+WebSite+FAQPage JSON-LD; neighborhood gets ItemList
Three JSON-LD additions, all purely additive:

1. home.ejs: @graph with Organization, WebSite (with potentialAction
   SearchAction → /find?q={query} for sitelinks search box), and
   FAQPage wrapping the existing 5 FAQ items. Validates as 3 graph
   nodes with all 5 questions correctly mapped.

2. neighborhood.ejs: ItemList JSON-LD added next to existing
   BreadcrumbList. Same shape as /find — first 30 businesses
   exposed as ListItem with rank+url+name, full count in
   numberOfItems.

Once Google recrawls, brand searches for 'Ventura Claw' may surface
sitelinks search box + FAQ snippets (taking 4-5x more SERP space).
dbc5da9 · 2026-05-07 · Cross-link neighborhood pages to vertical+city combo pages
/neighborhood/<slug> now renders 8 vertical chips + an inline 'Jump to
a category' paragraph, each linking to the matching /find?vertical=X&
city=Y combo page with crawlable anchor text. 5 neighborhoods × 16
combo links = 80 new internal links pointing into the 37 combo pages
seeded into sitemap.xml last tick — converts them from sitemap-only
orphans into part of the primary navigation graph.

Also fixed: lede was using businesses.length (capped at LIMIT 200) for
the count. Now uses the unfiltered totalCount from a separate aggregate
query. Sherman Oaks for example now reports 482 in its lede instead of
the capped 200.
d421ce8 · 2026-05-07 · Add 37 vertical+city combo pages to sitemap; unclaimed banner on listing detail
Two coordinated SEO + monetization improvements:

1. sitemap.xml now lists 37 'vertical in city' combos (8v×5c minus 3
   thin combos with <3 businesses) — '/find?vertical=beauty&city=
   Sherman+Oaks' etc. The /find route already renders unique H1 +
   meta + canonical for each combo (e.g. '155 beauty businesses on
   Ventura Blvd in Sherman Oaks'), so these are real landing pages
   from Google's perspective, not duplicates.

2. /business/<slug> now shows a top-of-page 'unclaimed' banner with
   anchor link to the existing #claim form for the 1,819 corridor-
   seeded shells. The empty 'About' section now reads with context
   ('listed from public records, owners can claim this page free')
   instead of the generic 'this business hasn't added a description'.
   Closes the gap between 'we have 1,819 unclaimed listings' and
   'visitors can find the claim CTA above the fold on mobile'.
05ae6c7 · 2026-05-07 · Add per-vertical sample rails to home page (food/beauty/retail/creative)
Home was showing only category-chip counts plus the existing 'Featured'
rail (claim+tier-gated, only 9 cards). With 1,819 corridor-seeded
listings, the home page should preview the directory's depth, not just
gate-keep behind a search box.

Top 4 verticals by count get a 6-card preview rail with 'See all N →'
link. Cards prioritize photographed/claimed/premier rows then random,
so the rail rotates per request and rewards owners who fill out their
page. Today's home: 28 unique business links vs. 9 before — a 3x lift
in click targets without changing any other section.

JSON-LD LocalBusiness + BreadcrumbList already exists on
/business/<slug>; no structured-data work needed this tick.
5cc49d8 · 2026-05-07 · Sitemap covers paginated pages; head supports rel=prev/next + filtered canonicals; map-CTA on home + find
Three SEO improvements stacked together since they're a single user
journey:

1. sitemap.xml now lists /find?vertical=X&page=2..N for each paginated
   vertical (beauty 4p, food 3p, retail 2p, creative 2p) — 7 new URLs
   so Google crawls every business via the listing pages, not just the
   1,819 direct /business/<slug>.

2. head.ejs gains rel='prev'/rel='next' link tags + a canonicalPath
   override. The /find route now passes the full filter+page query
   string as canonical — /find?vertical=beauty&page=2 self-canonicalizes
   instead of folding into /find.

3. Home + /find both get a 'browse the map →' CTA. Map already accepted
   ?vertical= filter; the /find→/map link preserves it so a user
   filtering for fitness on the list view sees only fitness pins on the
   map. 1,719 markers were unreachable from the home hero before this.
3f90e3f · 2026-05-07 · Add pagination to /find — page=N param + Prev/Next nav
Beauty has 636 rows but the LIMIT 200 made everything past row 200
unreachable. Add ?page=N (200/page, capped at 50) with stable id
tiebreaker on ORDER BY so a row never appears on two pages. Pagination
nav at the bottom of the grid uses rel='prev'/rel='next' for SEO.

Top of file says 'Showing 200 of 636 businesses' on capped pages, plain
'56 businesses found' when the page contains everything. Beauty=4
pages, food=3, retail=2, creative=2; the others fit on one page.
c64097d · 2026-05-07 · Per-vertical SEO meta uses real total + city names; visible count shows truth
The find route was using businesses.length for both meta description and
the on-page count, but that's capped by LIMIT 200 — beauty's meta said
'200 beauty businesses' when actual total is 636. Now compute the
unfiltered total and top-3 cities once per request and use them in:
- meta description: '636 beauty businesses on Ventura Blvd in Woodland
  Hills, Sherman Oaks, Encino. ...'
- visible page header: 'Showing 200 of 636 businesses' when capped,
  '56 businesses found' when not.

Two extra COUNT/GROUP BY queries per /find hit, both indexed.
Distinct meta per vertical lets each /find?vertical=X URL rank
independently for '<vertical> <city>' searches in Google.
1acea14 · 2026-05-07 · Add scripts/dedupe-corridor-shells.sql + hide 119 noisy seed rows
Hides booth-renter personal-name LLCs sharing addresses with proper
storefronts (52 rows) and the talent-agency production-shell LLCs that
share creative-vertical office suites (67 rows). Visible directory
shrinks from 1,938 → 1,819 active rows. Reversible: status='hidden'
not deletion. Run script standalone after any future corridor reseed.
8485ef7 · 2026-05-07 · Drop misleading 'verified listings' meta-description copy
Home page meta-description claimed all listings were verified, but with
1,938 corridor-seeded shells now in the directory most are unclaimed.
Replace with a more accurate framing that signals owners can claim and
update their own pages.
6572248 · 2026-05-07 · Generalize corridor seed to all 8 verticals (1,938 directory shells)
scripts/seed-corridor-all.js extends the food-vertical seed with
per-vertical OSM-category + BTRC-NAICS-label maps:
  beauty       669 (hairdresser/barber/nail/cosmetics)
  retail       302 (clothing/jewelry/furniture/optical/florist/books)
  creative     309 (photo/design/florist/promoter/sound/performing arts)
  food         512 (already shipped)
  cleaning      54 (janitorial/drycleaning/buildings)
  fitness       52 (fitness centres + name-filtered amusement+rec)
  pet_services  33 (pet care excl. veterinary)
  auto_detail    7 (Car Washes label + name-filtered car_repair)

Hand-curated mapping deliberately excludes the green-list landmines
(regulated medical/legal/financial/real-estate/contracting, mechanical
auto, veterinary). Idempotent ON CONFLICT (slug) DO NOTHING.
50d32a1 · 2026-05-07 · Seed food vertical from ventura_corridor public data (OSM + LA BTRC)
scripts/seed-corridor-food.js pulls 1,062 corridor food rows from the
ventura_corridor PG (89 OSM POIs + 973 LA BTRC NAICS-classified rows),
filters out NAICS-mistagged rows ('Barber shops' fuzz-matched to
'amenity: bar') and obvious owner-name registrations, normalizes city
casing, and inserts as tier='free' claim_status='unclaimed'
source='public_records' shells. /find?vertical=food now renders 512
real Ventura Blvd businesses across Sherman Oaks/Studio City/Encino/
Tarzana/Woodland Hills, up from 5 demo placeholders.
92a37d6 · 2026-05-07 · feat(seo): image sitemap support for Google Image search
- sitemap.xml now declares xmlns:image and emits <image:image> entries
  inside each /business/:slug URL block when the business has a photo
- image entries include <image:loc> (absolute URL) and <image:title>
  (business name); falls back to no image entry when photo absent
- skips og.png as image source — those are share cards, not real photos,
  and would dilute Image-search relevance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
d818054 · 2026-05-07 · feat(seo): per-business dynamic Open Graph images
- new lib/og-image.js builds 1200x630 SVG (vertical-keyed gradient + business
  name + neighborhood + venturaclaw wordmark) and renders to PNG via sharp
- new GET /business/:slug/og.png route (24h immutable cache, 404 on bad slug)
- business page passes ogImage = photo_path absolute URL OR /og.png fallback
- head.ejs already supports ogImage; business.ejs include block now resolves
  the absolute URL at the route layer rather than in the template
- 92KB output, 46/46 tests stable, deployed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c5e7571 · 2026-05-07 · feat(admin): surface share-count on dashboard stat row
Adds a 6th stat card (Shares · all-time) alongside Total/Last 30d/Delivered/
Pending/Unread/Profile views/Shares. Closes the loop on tick 30 so claimed
business owners see when their listing is being shared, not just messaged
or viewed. Raw stat query extends with a single subselect on businesses.share_count.
Tests stable at 46/46.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f5d2cd9 · 2026-05-07 · feat(ux): hand-drawn SVG glyphs replace emoji on vertical hero gradients
- new partial views/partials/vertical-glyph.ejs renders an inline line-art
  SVG per vertical (food/beauty/retail/fitness/pet_services/auto_detail/
  cleaning/creative). Uses currentColor so it picks up theme-aware tinting.
- swapped 4 card-grid glyph sites (find/home recently-joined/home featured/
  business similar-nearby/neighborhood) and the profile-page hero banner.
- CSS .biz-card-glyph-svg sized via clamp() with drop-shadow + light-mode
  override for dark glyphs on light gradients.
- 46/46 tests stable; deployed to prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a52f7bd · 2026-05-07 · feat(share): per-business share-count w/ session-dedup tracking
- POST /api/businesses/:slug/share-track (CSRF-bypassed at server mount)
- 30-min per-session dedup mirrors view-counter pattern
- frontend share button fires fetch() after navigator.share or clipboard copy
- profile sidebar shows 'shared X times' once count >= 10 (anti-zero guard)
- DB migration 010 adds businesses.share_count INT NOT NULL DEFAULT 0
- migration applied to prod; 46/46 tests stable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
826d347 · 2026-05-07 · yolo tick 29: weekly-digest worker — Monday-morning summary email per claimed business
scripts/weekly-digest.js:
- Pulls claimed-with-email businesses
- Skip when msgs_7d=0 AND views_7d=0 (nothing to summarize, don't spam silent listings)
- assertSendCompliance pre-flight (CAN-SPAM gate fail-closed)
- isSuppressed scrub per recipient
- mintUnsubscribeToken + List-Unsubscribe headers (RFC 2369/8058)
- complianceFooter with mailing address
- Audit each send to comms_send_audit (decision='sent' / 'failed' /
  reason='suppression')

Email body:
  - 3-stat header row (msgs_7d / views_7d / unread, brass-italic)
  - 'This week's new messages' table (top 5 names + emails)
  - 'Open dashboard' CTA
  - Live-profile link in muted footer
  - Compliance footer + unsubscribe link

DEFAULT MODE: --dry-run (no actual emails). Pass --live to send.

Cron NOT registered yet — Steve's call when to flip it on:
  0 8 * * 1   /usr/bin/node /root/Projects/ventura-claw-leads/scripts/weekly-digest.js --live >> /var/log/vcl-weekly-digest.log 2>&1

Tested local --dry-run with 2 claimed businesses (1 active, 1 quiet):
  · active → 'Your Ventura Claw week — 2 new messages, 13 profile views'
  · quiet → silent skip
  Output: sent=1 suppressed=0 skipped_no_activity=1 errors=0

46/46 tests still pass.
91c32a2 · 2026-05-07 · yolo tick 28: home 'Recently joined' rail (conditional ≥3 claimed)
routes/public.js GET /: extra query — newest 6 businesses where
claim_status IN ('self','claimed') AND claimed_at IS NOT NULL, ORDER BY
claimed_at DESC. Pulls photo_path so the rail can show real photos
without an extra query.

views/public/home.ejs: new section between FAQ and Featured. Renders only
when ≥3 businesses are claimed (a 1-2 entry rail looks anemic on a
half-empty home page; better to hide). Compact card layout — 240px
minmax columns vs the standard 300px on /find. Same hero treatment as
elsewhere (photo or gradient + glyph).

Drives FOMO + creates social proof: 'businesses ARE actually onboarding'
is a signal new visitors look for.

Verified: with 4 claimed → rail renders with title + 6 cards. With 0
claimed → rail hidden entirely. 46/46 tests pass.
f40cfda · 2026-05-07 · yolo tick 27: 'More <vertical> nearby' rail at the bottom of every profile
routes/public.js GET /business/:slug: extra query when the business has
lat/lng — fetches 3 nearest same-vertical active businesses sorted by
squared cartesian distance on lat/lng. Skip the rail entirely when this
business has no coords (rare since seed-geocode runs on every business).

Squared-cartesian is good enough for a 12-mile corridor — proper
Haversine would give true great-circle distance but for a near-flat
slice of LA the absolute error is < 0.01% per pair. Avoids needing the
earthdistance/cube postgres extensions on prod.

views/public/business.ejs: new section after profile-body — H2 'More
<vertical> nearby', sub-text, 3-card grid (mobile collapses to 1 col).
Cards reuse the same hero treatment as /find (photo or vertical
gradient + emoji glyph).

public/css/public.css: .similar-nearby + .similar-grid + responsive
breakpoint at 768.

Live: 'More food & drink nearby' renders on Olive & Salt with 3 nearby
food businesses. Real engagement boost — visitors who land on one
profile via search-engine traffic now have a within-vertical browse path
without backtracking to /find. 46/46 tests still pass.
f41ef50 · 2026-05-07 · yolo tick 26: per-neighborhood landing pages — /neighborhood/sherman-oaks etc.
routes/public.js:
- GET /neighborhood/:slug — whitelist of 5 valid slugs (sherman-oaks,
  studio-city, encino, tarzana, woodland-hills); anything else 404s.
  Match in DB by slugified neighborhood OR slugified city, so /neighborhood/
  sherman-oaks catches both city='Sherman Oaks' and neighborhood='Sherman
  Oaks' rows. Same ORDER BY as /find: tier-priority + claimed-first +
  photo-having + alpha. 200-row LIMIT (matches /find).
- Sitemap.xml: 5 new URLs added (one per neighborhood) at priority 0.7,
  daily changefreq. Total now 50 URLs (was 45).

views/public/neighborhood.ejs: dedicated landing page —
  - BreadcrumbList JSON-LD (Home › Find › <Neighborhood>) for rich-results
  - <H1>Businesses in <Neighborhood></H1>
  - 8 vertical-filter chips that pre-fill the neighborhood (clicking 'Food'
    on /neighborhood/sherman-oaks goes to /find?vertical=food&city=Sherman+Oaks)
  - Same business-card grid as /find (photo or gradient hero)
  - Empty state for neighborhoods that haven't onboarded businesses yet

views/partials/footer.ejs: new 'Neighborhoods' column with 5 direct links
— internal-link signal that helps Google's crawler reach the new pages.

Verified live: 5 neighborhood URLs return 200, bogus slug 404s, sitemap
shows 5 /neighborhood/* entries, footer renders the column. Real
local-SEO landing-pages now exist for each segment of the corridor.
46/46 tests still pass.
870584a · 2026-05-07 · yolo tick 25: /find auto-suggest dropdown — debounced AJAX, keyboard nav
routes/public.js: GET /api/businesses/suggest?q=… returns top 8 active
businesses matching name/headline/neighborhood/city via ILIKE wildcard.
ORDER BY: prefix matches first (LOWER(business_name) LIKE 'ol%'), then
tier-priority (premier > standard > rest), then claimed > unclaimed,
then alphabetical. Cache-Control: no-store. Skips queries < 2 chars.

views/public/find.ejs: hidden <ul role=listbox> below the search input.
JS:
  - debounce 150ms on input event
  - drop stale responses (currentQ guard prevents out-of-order races)
  - render <li> with name + vertical chip + neighborhood line
  - ArrowDown/Up/Enter/Escape keyboard nav (first item active by default)
  - mousedown handler so click registers before input loses focus
  - close on outside-click
  - XSS-safe via escHtml() — no innerHTML of unescaped data

public/css/public.css: .find-suggest with shadow + scroll-cap at 360px,
2-column grid (name left + vertical right + location row spans both).

Verified live: /api/businesses/suggest?q=ol returns Olive & Salt Trattoria
first (prefix match), 5 total. Markup present. 46/46 tests pass.
e1590f8 · 2026-05-07 · yolo tick 24: home featured grid — photo-priority within tier + render hero per card
routes/public.js GET /: featured query expanded to SELECT photo_path, plus
new ORDER BY tier within priority that prefers businesses-with-photos to
businesses-without-photos within the same tier:

  ORDER BY tier='premier' DESC, tier='standard' DESC,
           (photo_path IS NOT NULL) DESC,
           RANDOM()

So premier listings still always sort first; standard next; within each
tier the rotation favors visually-richer cards that have a real photo
uploaded. Pure positive incentive to upload (better placement) without
penalizing photo-less listings (they still appear, just lower in the
random pool).

views/public/home.ejs featured grid: same per-card hero treatment as /find
— photo when present, vertical-themed gradient + emoji glyph as fallback.
Featured cards now visually match the directory's card pattern.

46/46 tests still pass. The home page transforms once businesses upload
photos: the gradient placeholders give way to actual interior shots,
storefronts, etc.
c73de6a · 2026-05-07 · yolo tick 23: og:image + Twitter Card image + 'Share this listing' button
OPEN GRAPH / TWITTER CARD
- views/partials/head.ejs: optional ogImage param. When passed (and only
  then), emits og:image + og:image:width + og:image:height + twitter:image
  + twitter:card='summary_large_image' + twitter:title + twitter:description.
  When ogImage absent, defaults to twitter:card='summary' (no large image).
- views/public/business.ejs: passes ogImage=publicUrl+photo_path when the
  business has a cover photo. Same path also added to LocalBusiness JSON-LD
  image field for Google.
- Net: a business with a photo now gets a properly attributed
  social-share preview card on iMessage / Slack / Twitter / LinkedIn /
  Facebook / Discord. Without a photo, falls back to the summary card.

SHARE THIS LISTING
- views/public/business.ejs: 'Share' button next to 'Send a message' /
  'Visit website' / 'Call'. Uses navigator.share when available (Mobile
  Safari, Chrome on Android), falls back to navigator.clipboard.writeText
  with a '· copied' status flash.
- Pure progressive enhancement — works without JS for users with
  copy-link muscle memory.

46/46 tests still pass. Verified on prod: og:image renders when photo set,
summary card otherwise; Share button present on every profile.
77c0ec3 · 2026-05-06 · yolo tick 22: /admin/leads pagination — 50/page with prev/next chips
Replaces the hardcoded LIMIT 200 with cursor-friendly offset pagination.
50 rows/page, capped at 200 pages (10k rows = sane upper bound).

routes/admin.js: ?page= param (1-indexed, clamped 1..200). Fetches PER_PAGE+1
rows so we can detect 'has next' without a separate COUNT query — leads.length
> PER_PAGE means there's another page. Returns hasPrev/hasNext flags +
page + perPage to the view.

views/admin/leads.ejs: pagination footer at the bottom of the table:
  'Showing 51-100 of 247'  [← Prev] [Next →]
URL helper preserves filter+range+q across pagination clicks (chip state
already preserved across pagination, pagination state already preserved
across chip clicks — the search form drops it intentionally since a new
search resets to page 1).

46/46 tests still pass. Combined freely with All/Unread + 7d/30d/90d/All
+ ?q= search box — orthogonal axes.
eb723c7 · 2026-05-06 · yolo tick 21: per-business profile-view counter (with bot+session+owner dedup)
DB:
- migrations/009_business_views.sql: business_views (business_id, day, n)
  with PK on (business_id, day) so daily upsert is atomic. Index on
  (business_id, day DESC) for the dashboard 30-day SUM.
- POST-DEPLOY GRANT (vcl role on prod, table owned by postgres):
    GRANT ALL ON business_views TO vcl;

Counter logic in routes/public.js GET /business/:slug:
  - Skip if user-agent matches /bot|crawler|spider|slurp|preview|fetch|
    monitor|pingdom|uptime|headlesschrome/
  - Skip if logged-in business owner viewing own profile
  - Otherwise: 30-min per-session dedup via req.session.viewed_<bizId>,
    then INSERT … ON CONFLICT (business_id, day) DO UPDATE SET n = n+1
  - Fire-and-forget; render doesn't block on the counter write

Surfaces:
- Admin dashboard: new 'Profile views' stat card (last 30 days · today)
  in the 5-card row alongside Total/30d/Delivered/Unread.
- Public profile sidebar: when activity threshold met (≥3 msgs OR ≥25 views
  in 30 days), brass-bordered side card shows BOTH stats stacked, divided
  by a dotted line. Below threshold: nothing rendered (no '0 views' embarrassment).

E2E verified all four scenarios:
  fresh session     → n=1 ✓
  re-hit same session → n=1 (dedup) ✓
  Googlebot UA     → n=1 (bot skipped) ✓
  another fresh session → n=2 ✓

46/46 tests still pass.
2bd5453 · 2026-05-06 · yolo tick 20: breadcrumb nav + BreadcrumbList JSON-LD on /business/:slug + admin/team last-active sort
BREADCRUMB ON PROFILE
- views/public/business.ejs: visible <nav class=breadcrumb> +
  matching schema.org BreadcrumbList JSON-LD. 5 levels:
    Home › Find a business › <Vertical> › <City> › <Business Name>
  Each level (except current) is a clickable URL pointing at the right
  filter on /find. The JSON-LD makes the breadcrumb eligible for
  Google's rich-result breadcrumb chips above the SERP snippet.
- 2 JSON-LD blocks now per profile (LocalBusiness + BreadcrumbList) —
  validates as clean schema.org.

ADMIN/TEAM SORT
- routes/admin.js GET /admin/team: ORDER BY changed from created_at ASC
  to a 3-tier sort: self always first → last_login_at DESC NULLS LAST
  → created_at ASC tiebreak. So the team page surfaces actively-using
  teammates at the top, dormant ones below, never-signed-in at bottom.

46/46 tests still pass. Verified live: 2 JSON-LD blocks render on prod
with valid Schema.org @types.
863a780 · 2026-05-06 · yolo tick 19: sharp resize + WebP convert + EXIF strip on photo upload
Tick 18 saved uploads at original dimensions and original format. This tick
adds the post-processing pipeline:

lib/photo-upload.js:
- Multer cap raised 2MB → 5MB (sharp will compress down anyway)
- After multer accepts the file (saved to .tmp), sharp:
    .rotate()   — honor EXIF orientation BEFORE strip (otherwise iPhone
                  portrait shots end up sideways)
    .resize({ width:1600, height:1600, fit:'inside', withoutEnlargement:true })
    .webp({ quality:85, effort:4 })
    .withMetadata({ exif:undefined })  — strip EXIF for privacy + size
    .toFile(<final>.webp)
- Temp file unlinked after successful conversion. On sharp failure,
  temp is cleaned and req._photoUploadError = 'image_decode_failed'.
- Filename always ends in .webp regardless of input format.
- req.file.path/filename/mimetype/size all rewritten so the route
  handler stores the FINAL webp path in DB.

views/admin/profile.ejs:
- copy updated (5 MB / auto-resize / WebP conversion explained)
- new error message for image_decode_failed

Verified locally with a 3000×2000 35KB JPG: output 1600×1067 3.7KB WebP
— 89% size reduction. EXIF stripped (no metadata in output). 46/46 tests
still pass.

Sharp install on Kamatera: production npm install picks up the prebuilt
linux-x64 binding (libvips 8.x). No build tools needed.
65c0b91 · 2026-05-06 · yolo tick 18: per-business cover photo upload (multer + 2MB cap + type whitelist)
DB:
- migrations/008_business_photo_path.sql: ADD COLUMN photo_path TEXT NULL.

Upload pipeline:
- lib/photo-upload.js: multer.diskStorage to public/uploads/business-photos
  with timestamped per-business filenames (b-<userId>-<ts>.<ext>). 2 MB cap,
  jpg/png/webp mime + extension whitelist. Errors stash on req._photoUploadError
  so the route handler can show a typed message after CSRF passes.
- server.js: multer middleware mounted on /admin/profile/photo BEFORE
  csrfMiddleware (multipart fields including _csrf must be parsed first; the
  earlier inline-multer attempt failed because csrfMiddleware ran before the
  multipart body was parsed and req.body._csrf was empty).
- routes/admin.js POST /admin/profile/photo: just reads req.file + writes
  photo_path. Best-effort cleanup of the previous photo (path-bound to
  PHOTO_DIR so a manipulated DB value can't escape that directory).
- routes/admin.js POST /admin/profile/photo/delete: clear photo + unlink file.

Public surfaces:
- views/admin/profile.ejs: 'Cover photo' section above the field form. Shows
  current photo (200x120 thumb) + 'Remove photo' button. File input
  accept='image/jpeg,image/png,image/webp' + Upload button.
- views/public/business.ejs: when photo_path is set, render <img> in the
  hero slot instead of the vertical-themed gradient. Fallback unchanged.
- views/public/find.ejs: same swap on biz-card-hero. Routes/public.js GET /find
  expanded to SELECT photo_path so cards can render it.
- public/css/public.css: .vertical-hero-photo + .biz-card-hero-photo with
  object-fit:cover for clean cropping at any aspect ratio.
- server.js: + /uploads static mount with 30d cache.

Verified e2e local: claim → upload 1x1 PNG → photo_path written, file saved
to disk, /business/:slug renders <img src='/uploads/...'>, static GET → 200.
Migration applied prod via sudo -u postgres. 46/46 tests pass.
8634cfd · 2026-05-06 · yolo tick 17: per-vertical SEO landing pages + home FAQ section
PER-VERTICAL TITLE / H1 / META
- routes/public.js GET /find: when ?vertical= or ?city= is set (or both),
  build a custom <title>, meta description, and H1 specific to that filter.
  Eight vertical-keyed landing pages now distinguish themselves to Google
  ('Food & drink on Ventura Blvd' vs 'Beauty in Sherman Oaks on Ventura
  Blvd' vs the generic 'Find a business — Ventura Claw'). Long-tail SEO
  for every (vertical × neighborhood) combination — already in sitemap.xml
  from tick 4, just now actually distinct content.
- views/public/find.ejs: H1 swapped to <%= customH1 %>, route picks the right
  string. Fallback for the unfiltered case = 'Search Ventura Blvd'.

HOME FAQ
- views/public/home.ejs: 5-question FAQ section between featured grid and
  category browser, addresses the questions visitors actually have:
    · How does this differ from Yelp/Google Maps?
    · Why aren't doctors, lawyers, or contractors listed?
    · How do leads get to the business?
    · How much does it cost — for me, for the business?
    · I own a business on Ventura Blvd. How do I claim my listing?
- public/css/public.css: <details>/<summary> styling — brass + rotation
  on the indicator, max-width on the answer copy, semantic markup so the
  FAQ is searchable + accessible (collapse/expand via keyboard).

Verified live: /find?vertical=food → 'Food & drink on Ventura Blvd' title
+ H1; /find?vertical=beauty&city=Sherman+Oaks → 'Beauty in Sherman Oaks on
Ventura Blvd'. Home / shows FAQ. 46/46 tests still pass.
53d59c1 · 2026-05-06 · yolo tick 16: profile activity badge (≥3 threshold) + /find empty-state with vertical pivot
PROFILE ACTIVITY BADGE
- routes/public.js GET /business/:slug: extra query for msgs_30d count.
  Threshold = 3 to show — anything below stays silent (a fresh listing with
  '0 messages received' is worse than no badge at all). Stat lives in a
  brass-bordered side card above 'Quick info' on the profile sidebar.
- Social proof for visitors weighing whether the business is reachable.

/FIND EMPTY-STATE PIVOT
- views/public/find.ejs: when filters yield zero results, replace the
  bare 'no matches' line with the same 8-vertical chip grid the home page
  uses. Pivots a dead-end search into 'browse by category' instead.
- Reuses .verticals-grid + .vertical-chip CSS from the home — zero new
  styles, just a reflow of the existing component into the empty state.

Verified live: badge correctly suppressed at 0 msgs (Olive & Salt has 0,
no badge rendered); will appear once a business crosses 3/month. Empty
state renders 8 chips when ?q=zzznoresults. 46/46 tests pass.
4e2663d · 2026-05-06 · yolo tick 15: home-page 'How it works' 3-step explainer
views/public/home.ejs: section between hero and verticals — Find →
Message → Reply. Each step has brass-italic '01/02/03' label, serif
headline, and a 1-sentence body that names the actual product mechanic
(5-min lead delivery, reply-to consumer, no middleman).

public/css/public.css: .how-it-works section + .how-grid (3 cols
desktop, 1 col phone via the existing 768px breakpoint). Steps inherit
the existing serif/sans + brass typography system; no new design tokens.

Concrete visitor explanation right above the category browser, addresses
the most common 'wait, how does this work / will the business actually
reply?' confusion. 46/46 tests still pass.
294e68a · 2026-05-06 · yolo tick 14: /admin/leads search-within-messages
routes/admin.js GET /admin/leads now accepts ?q=… (lowercased ILIKE on
consumer_name / consumer_email / message / zip). Combines freely with
?filter=unread + ?range=7|30|90|all — q is preserved across all the
existing chip clicks (rangeHref + filterHref helpers updated to round-trip).

views/admin/leads.ejs adds a search form between the title and the chip
row. Hidden inputs persist filter+range when the user submits a fresh
search. 'Clear search' button appears when q is non-empty.

Useful for active businesses needing to find a specific lead by name/email
or recall a project mentioned in the message body. 200-row LIMIT preserved
so search results stay snappy.

46/46 tests still pass.
65ee0ec · 2026-05-06 · yolo tick 13: /map pin hover preview + /admin/billing recent activity feed
/MAP HOVER PREVIEW
- views/public/map.ejs: every Leaflet marker now has bindTooltip with the
  business name + vertical kicker + neighborhood. Renders on hover (no
  click needed). Tooltip themed to match the site (var(--bg)/--fg/--border)
  with a subtle drop shadow.
- Tooltip arrow stripped via .leaflet-tooltip.pin-hover::before {display:none}
  for a cleaner look against light + dark themes.
- popup (click) still works with the full action buttons.

/ADMIN/BILLING ACTIVITY FEED
- routes/admin.js: GET /admin/billing now also queries subscription_events
  filtered by payload->>'metadata'->>'business_id' = current business id,
  newest 20. JSON-path filter is the right approach since the same Stripe
  account fires events to all sister projects (NPH, lawyer, etc.).
- views/admin/billing.ejs: 'Recent activity' table above 'Manage
  subscription'. Per-row: timestamp, event_type (mono), detail (amount in
  USD if amount_paid/amount_due present, else status). Status='active'
  renders green.

46/46 tests still pass. /map and /admin/billing both 200/302 on prod.
abf7235 · 2026-05-06 · yolo tick 12: 30-day lead-trend sparkline on admin dashboard
Pure inline SVG, zero chart libraries, works whether the business has 1
lead or 1,000. Bars scale linearly to peak day; peak day rendered in brass
to make it pop. Empty days get a 1px baseline bar (faint --border color)
so the time axis stays readable at zero.

routes/admin.js GET /admin: query expanded with a generate_series CTE that
produces a contiguous 30-day date axis (PT timezone), LEFT JOIN'd to
business_interest grouped by day. Returns trend = [{day, n}, ...] (length
30 always) + trendPeak, no matter how sparse the data.

views/admin/dashboard.ejs: inline <svg viewBox="0 0 480 60"> sparkline
above 'Recent leads' section. Each bar has a <title> tooltip with the
day label + lead count. Footer shows date-range bookends. Header line:
'peak X · total Y'.

Verified live: e2e claim+seed-3-leads-on-different-days+fetch-dashboard
shows the sparkline with the right bars at the right offsets. 46/46
tests still pass.
2c5302b · 2026-05-06 · yolo tick 11: admin invite-teammate flow — multi-user per business
DB:
- migrations/007_business_invitations.sql: business_invitations table —
  HMAC token, business_id FK, recipient email, invited_by_user_id, 7-day
  expires_at, consumed_at, ip_hash. Partial index on active.
- POST-DEPLOY GRANT (always required when sudo -u postgres applies a
  migration; vcl needs perms on the new table+sequence):
    GRANT ALL ON business_invitations TO vcl;
    GRANT USAGE,SELECT,UPDATE ON SEQUENCE business_invitations_id_seq TO vcl;

Auth lib:
- lib/auth.js: issueInviteToken / consumeInviteToken / markInviteConsumed
  alongside the existing claim-token helpers. 7-day TTL (vs 24h for claim).

Admin routes:
- GET /admin/team: list active business_users + pending invitations,
  flash messages for invited/removed/error states.
- POST /admin/team/invite: validate email, idempotent on existing member,
  mint token, send transactional email via Purelymail SMTP, audit in prod.
- POST /admin/team/invite/:id/cancel + /team/:id/remove (with last-user +
  self-remove guards).

Public routes:
- GET/POST /accept-invite/:token: set-password form, bcrypt, session-login,
  redirect to dashboard with welcome flash.

Views: admin/team.ejs, public/accept-invite.ejs, dashboard.ejs invite link.

Live test 2026-05-06: /admin/team → 302 auth-redirect, /admin/team/invite
POST → 403 CSRF, /accept-invite/<garbage> → 400 invalid (post-GRANT). 46/46
tests pass.

File tree

91 files tracked. Click any to browse the source at HEAD.

Other build journals

← Ventura Claw  ·  all 4 projects  ·  Ventura Corridor →

Export

commits.csv · feed.atom · project.json · commits.json

rendered in 1ms