Prepared July 2026 — based on an independent exploration of the codebase (no code changed).
WorthIt (worthit.canlah.app) is a Singapore resale property price tracker: ~370K HDB transactions from data.gov.sg plus 138K URA private-property records in one SQLite DB, served by an Express API on Fly.io behind a static SPA on Cloudflare Pages. Users search by town, postal code, district, or private project name and get an area overview (median, $psf, range), dual-line HDB/private trend charts, a price-distribution histogram, a deal-score-colored transaction map, and a filterable comparables table. Data auto-refreshes daily via GitHub Actions.
Personas: (1) HDB resale buyers validating an asking price, (2) sellers benchmarking their flat, (3) upgraders/browsers comparing areas and watching trends. All three converge on one question the app's own product brief states: "is this price fair?"
- Postal-code check — enter 6-digit code → OneMap resolves → 500m-radius block query → nearby comparables, pinned block, map. The most "personal" journey today.
- Town/district research — browse a whole town's stats, per-flat-type cards, trends, and transactions; multi-select flat types.
- Private project lookup — project overview with district context, EC/MOP detection.
- Share/return — deep links (
/hdb/tampines/4-room,/postal/523876), share button, SEO pages for organic entry.
The notable gap: every journey ends at "here is the market data." None ends at "here is your answer." The user still does the last mile of judgment — comparing, computing, and remembering — in their head.
Key discovery: the DB build (scripts/download_data.py) precomputes town_stats, monthly_medians, and storey_adjustments tables, but server/index.js never queries any of them. That is planned-but-unshipped valuation machinery, which shapes the first recommendation.
What it is: A form (asking price, postal code/block, flat type, floor area, storey range, remaining lease) that returns a fair-value estimate, a 0–100 Deal Score, and the percentile the asking price sits at among adjusted comparables — e.g. "$685K is ~4% above fair value for a mid-floor 4-room here; 68th percentile of the last 12 months within 500m."
Pain point: This is the app's founding promise — memory-bank/projectbrief.md lists "Deal Score (0–100)" and "Fair Market Value estimation" as key features — but today the deal score only exists as relative map-marker coloring. A buyer holding a specific asking price still has to eyeball the histogram and mentally adjust for floor and lease.
Implementation:
- The data layer already exists:
download_data.pyprecomputestown_stats,monthly_medians, andstorey_adjustments(storey-premium multipliers per town × flat type × storey range), and the server never uses them — wire them up. - New
GET /api/valuationinserver/index.js: select comparables via the existingfindNearbyHdbBlocks()500m pipeline + flat type + lease band; adjust each comp's $psm bystorey_adjustments; median × subject area = fair value; percentile from the same array (percentile code already exists in/api/area-overview). - Frontend: a collapsible "Check a price" panel in
public/index.htmlunder the search card; result card inpublic/js/app.jsreusing the existing green→blue→red scale frommap.js'sgetValueStyle(). New route/checkfor shareability. - Tests follow the existing pattern in
tests/integration/against the fixture DB.
What it is: Compare 2–3 towns (or town×flat-type combos) side by side — median, $psf, YoY, lease profile, volume — with both trend lines on one chart. URL: /compare/tampines-vs-bedok/4-room.
Pain point: Deciding between areas is the core upgrader journey, but today it means two searches in two tabs and memorizing numbers. (The SEO backlog already lists "comparison pages" — this is that, done as a real feature, and each comparison URL becomes an indexable long-tail page: "tampines vs bedok 4 room resale price".)
Implementation:
- No new server aggregation needed for v1: client calls the existing
/api/area-overviewonce per town in parallel (Promise.all, same pattern asApp.init()). app.js: a "+ Compare" button on town results; newrenderCompareResults();charts.jsrenderTrendChart()already handles two datasets — generalize to N labeled lines.- SEO: add a
/compare/branch to/api/seo/metadataand the sitemap (top ~50 adjacent-town pairs), same structure as the town×flat-type branch shipped in June 2026.
What it is: Enter a budget (e.g. $600K) and optional flat type → ranked table of towns where the 12-month median fits, with median, $psf, typical lease, and volume, each row linking to the existing /hdb/<town>/<flat-type> page; optionally a color-coded map of Singapore.
Pain point: Real buyers start from a budget, not a town. The app currently has no entry point for "where can I buy?" — the single highest-intent question for first-timers, and one no free SG tool answers well.
Implementation:
- New
GET /api/affordability?budget=&flat_type=— one GROUP BY town query over the last 12 months (or read the currently-unusedtown_statstable). Trivial with existing indexes (idx_transactions_town_flat_month). - Frontend: a third search mode — a small "By budget" toggle near the flat-type buttons; results reuse the price-type-cards grid style. Route
/budget/600k/4-room→ also a strong programmatic-SEO surface ("what HDB can I buy with 600k").
What it is: (a) a "Remaining lease" filter (e.g. 90+, 70–90, <70 yrs) alongside the flat-type buttons and in the transaction table; (b) a new chart: $psf vs remaining lease for the searched area, making lease decay visible.
Pain point: Lease decay is the HDB valuation question (CPF/loan restrictions kick in below ~60 years; older flats look deceptively "cheap"). remaining_lease_years is already in every row and shown in map popups, but users can't filter or analyze by it — a 60-year and a 95-year flat are averaged together in every median shown today, which quietly distorts the app's headline numbers for older towns.
Implementation:
- Server: add
lease_min/lease_maxparams to/api/area-overview, threaded through the same clause-builder pattern asaddFlatClause(). - Frontend: filter pills in
index.html; one new Chart.js scatter (bucketed line) incharts.jsfed by a smalllease_curveaggregation added to the area-overview response. - Bonus: sharpens the valuation comps in Feature 1.
What it is: (a) type an MRT station name ("Bishan MRT") and get the same nearby-blocks analysis as a postal search, centered on the station; (b) every transaction card/map popup shows "6 min walk to Bishan (450m)".
Pain point: "Near which MRT" is a top-3 buyer criterion in Singapore. The app already ships public/data/mrt_stations.json and an MRT map overlay — but stations are display-only; you can't search by one, and comparables don't tell you transit distance.
Implementation:
- Search: add a station branch to
/api/resolve(load the stations JSON server-side, name-match) returning the station's lat/lng — from there it flows through the exact existing postal-code pipeline (/api/area-overview?lat=&lng=→findNearbyHdbBlocks()). Very little new code. - Nearest-MRT labels: client-side haversine in
map.js/app.jsagainst the already-loaded stations JSON — zero server or DB changes. - Autocomplete: append stations to the existing dropdown source in
app.jswith an "MRT" badge (badge pattern already exists for private projects).
What it is: On any town/flat-type/project page, "Notify me" + email → a monthly (or on-data-refresh) email: new median, MoM/YoY change, notable transactions, deep link back.
Pain point: Property research spans months; today WorthIt is fire-and-forget with no reason to return. Alerts convert one-time SEO visitors into recurring users — and data already refreshes daily via GitHub Actions, so freshness is sitting there unexploited.
Implementation:
- Storage: a
subscriptionstable in the existing writablefeedback.db(deliberately kept out of read-only, wholesale-replacedresale.db— same reasoning as feedback). NewPOST /api/subscribecloned from/api/feedback(honeypot + rate limit + validation), plus a signed unsubscribe link. - Delivery: a step appended to
.github/workflows/refresh-data.yml(or a small Fly cron) that diffs medians and sends via a transactional email API; Cloudflare Email Service or Resend's free tier fits the $0/month constraint. - UI: a small bell button next to the existing share button; modal reuses the feedback modal's structure.
| # | Feature | Why this order |
|---|---|---|
| 1 | Check My Price | Fulfills the product's stated core promise; hard data plumbing (nearby-blocks radius query, storey adjustments, percentiles) already exists |
| 2 | Lease-decay filter (#4) | Small change; improves data honesty and Feature 1's comp accuracy |
| 3 | Comparison view (#2) | Doubles as the comparison SEO pages already in the backlog |
| 4 | Budget search (#3) | High-intent new entry point + programmatic SEO surface |
| 5 | MRT search (#5) | Mostly reuses the postal-code pipeline; assets already shipped |
| 6 | Price alerts (#6) | Retention play — worth it once traffic justifies it |
Added July 2026 after code-level investigation.
The 6-field form is almost entirely inferable from resale.db:
| Form field | Inferable? | How |
|---|---|---|
| Postal code / block | User already types this — it's the existing search box | hdb_block_coords has a postal column |
| Remaining lease | Yes, exactly — zero input | Every flat in a block shares lease_commence_date; 99 − (year − lease_commence_date) |
| Flat type | Yes → 1–3 chips | SELECT DISTINCT flat_type FROM transactions WHERE block=? AND street_name=?; if one, preselect |
| Floor area | Yes → standard-size chips | Distinct floor_area_sqm per block+type; default = most common |
| Storey | Only true unknown | Low/Mid/High chips from the block's actual storey_range values; default Mid + show sensitivity ("high floor ≈ +$18K") |
| Asking price | The only required keystroke | Accept 685k, $685,000, 0.685m |
Real UX: postal code + price. Two inputs. Everything else is pre-filled, tappable chips — never a blank field.
- Inline on postal-search results (primary) — the app already knows block, lease, types, and has 500m comps loaded. Slim card above the charts: "💰 Seen an asking price for this block?" with one price input.
- From any transaction row/card — "Check a price like this" prefills everything from that row; user overrides only the price.
- Standalone
/checkroute — smart form where typing a postal code live-fetches block facts and morphs remaining fields into chips.
Rejected option: pasting a PropertyGuru/99.co listing URL and scraping — brittle, ToS-hostile; the postal code on every listing is the stable identifier anyway.
- Resolve block via
hdb_block_coordsby postal (lookup pattern exists atserver/index.js:1969); pull block facts in one query. - Select comps —
findNearbyHdbBlocks(lat, lng, 500)→ same flat type, last 12 months, remaining lease ±10 years (extract shared logic from/api/area-overviewinto a helper). - Storey-adjust with the unused
storey_adjustmentstable: factor =avg_psm(town, type, subject_storey) / avg_psm(town, type, comp_storey). - Fair value = median adjusted $psm × subject area; report a p25–p75 range, not a single number.
- Deal Score: deviation
d = (asking − fair)/fair; v1score = clamp(50 − 250×d, 0, 100)(−20% → 100, fair → 50, +20% → 0). Bands: ≥70 Good deal, 45–69 Fair, <45 Premium. Plus percentile of asking price among adjusted comps. - Confidence: <8 comps → widen to 1km → else fall back to
town_stats+ "Low confidence" label. Returncomps[]so the UI shows its work.
Verdict card: colored score + label (same green→blue→red scale as map.js getValueStyle() so map and score agree), fair-value range, plain-English basis sentence, expandable comps list (reuses transaction-card markup), not-financial-advice disclaimer linking /methodology. Shareable /check/<postal>?price=685000&type=4-room; GA4 valuation_check / valuation_result.
/api/valuation+ integration tests (fixture DB already has block/storey/lease data)- Inline card on postal results — the 90% win
- Row-level "check similar" + standalone
/check+ SEO metadata branch
public/data/mrt_stations.json is frontend-only and .dockerignore excludes public/ — the Fly.io API container can't read it. Fix: canonical copy at server/data/mrt_stations.json (require()d at boot), keep the public/data/ copy for the map overlay, with a sync-check test guarding drift.
map.jsalready loads stations for the overlay. AddnearestStation(lat, lng)— brute-force haversine over ~180 stations × ≤200 markers is trivial.- Show
🚇 450m to Bishan (NS17) · ~6 min walk(80m/min) in all 4 popup types and on mobile transaction cards (nearby-HDB transactions now carrylat/lng). - v2 only if sort/filter by MRT distance is wanted: precompute
nearest_mrt/mrt_dist_montohdb_block_coordsindownload_data.py.
- Resolve (
server/index.js:523): MRT branch after exact town match, before fuzzy matching. Disambiguation (many stations share town names — Bishan, Tampines…): barebishan→ town (no regression);bishan mrt/bishan station/ codeNS17→ station. Response{ resolved: true, type: 'mrt', station, lines, lat, lng }. - Autocomplete (
app.js:1387): append stations to_acItemswith 🚇 icon +mrtbadge; item value"Bishan MRT"soselectAc()→search()hits the disambiguated path. Typing "bishan" shows both town and station — ambiguity becomes a visible choice. - Search flow (
app.js:406):resolved.type === 'mrt'branch mirrors the postal path — setsearchLat/searchLng→ existing/api/area-overview?lat=&lng=pipeline. Server tweak: optionalradiusparam (capped ≤1000m); use 800m for MRT (walkable ≈ 10 min). Station-styled map pin, MRT overlay auto-on; nearby private projects free viaaddNearbyProjects. - URL + SEO:
/mrt/<station-slug>—handleUrlRoute()addition,/api/seo/metadatabranch (same shape as/postal/), ~180 sitemap entries. "hdb resale price near mrt" is high-intent, uncontested long-tail. - Tests: resolve disambiguation, radius validation, SEO branch, stations-JSON sync check.
Effort: Phase A ~half a day; Phase B ~1–2 days (~80% reuses the postal-code plumbing).