STATUS (Aug 2026): Built. §1–4 and Appendices A/B below were followed as-is and are accurate. §5's implementation details were corrected during a plan-mode review before execution (most notably:
/api/resolvehas no district/private logic inside it, there's no shared slug helper, and BTO resolution ended up exact-match-only with no LIKE fallback). Seememory-bank/activeContext.mdfor the full as-built account, including two bugs caught during implementation (a temporal-dead-zoneReferenceErrorin the BTO JSON seeding, and an unhidden "Percentiles + Town Summary" section that needed a new id). Treat this file as historical context for why the feature exists and its locked-in design decisions, not as an up-to-the-line implementation reference.
Audience note: this plan is written to be executed by any coding agent without extra context. Before starting, read
CLAUDE.mdand all files inmemory-bank/(mandatory project directive). Follow the steps in order. Each step lists the files to touch, exactly what to do, and how to verify.
HDB (Singapore's public housing authority) launches new Build-To-Order (BTO) flats 3–4 times a year. These flats have no resale transactions (buyers must live in them ~5 years before selling, the "MOP"), so they are invisible to WorthIt today. This feature adds BTO launch data so buyers can:
- Search a BTO project by name (e.g. "Lakeview Cascadia") in the main search bar — core requirement.
- See its details: town, classification (Standard/Plus/Prime), flat types, floor areas, unit counts, indicative price ranges, waiting time, application window.
- See a BTO vs nearby resale comparison: HDB's indicative price range next to the median price
of actual resale transactions of the same flat type near the project site — computed from the
transactionstable WorthIt already has. This is the differentiating feature; HDB itself publishes such comparisons in its press releases, which validates the approach. - See the project on a map with nearby resale blocks (reusing the existing
/api/nearby-hdbmachinery).
Scope for this phase: launches from 2025 and 2026 only (Feb 2025, Jul 2025, Oct 2025, Feb 2026, Jun 2026 — plus the announced-but-unpriced Nov 2026 launch as an "upcoming" teaser).
Data acquisition is solved: each launch has an official HDB press release with an "Annex A" PDF containing per-project, per-flat-type supply and pricing. The June 2026 dataset is already extracted in full (see Appendix A). Data volume is tiny (7 projects × ~5 flat types per launch), so the seed is a hand-curated JSON file committed to the repo — no scraper.
- New
scripts/bto_launches.jsonseed file (June 2026 complete; backfill Feb/Jul/Oct 2025 + Feb 2026; Nov 2026 asupcoming). - New
bto_projectstable inresale.db, seeded from the JSON (same dual-seed pattern ashdb_block_coords). - New API endpoints:
GET /api/bto/launches,GET /api/bto/projects(autocomplete),GET /api/bto/project-overview. /api/resolvereturnstype: 'bto'for BTO project names → main search bar works.- Autocomplete dropdown shows BTO projects with a
btobadge. - Frontend BTO project page (route
/bto/<project-slug>) with details, comparison table, map, disclaimers. /btoindex page listing all launches (also the SEO hub page).- SEO: metadata branches for
/btoand/bto/<slug>, sitemap entries. - Tests: fixture table + rows, integration tests, resolve regression tests.
- Scraping
homes.hdb.gov.sg(it is bot-blocked; 503s non-browser clients — never fetch it from code). - SBF (Sale of Balance Flats) / open booking exercises.
- Application-rate / subscription data (published after each exercise; possible later enhancement).
- Short-lease 2-room Flexi pricing tiers (15y–45y) — store 99-year prices only.
- BTO markers on existing postal/town search maps (good phase-2 candidate; see §9).
- Grant calculations (all prices shown "excluding grants", matching how HDB quotes them).
| Decision | Rationale |
|---|---|
| Seed = committed JSON, curated by hand per launch | 3–4 launches/yr, ~10 projects each; a scraper of a government SPA is more fragile than 20 min of curation. |
Data lives in a bto_projects table inside resale.db |
Keeps the server read-only single-DB model. Must be re-seeded on every data refresh because resale.db is replaced wholesale (mv resale.db.new resale.db) — hence seeding in both download_data.py AND a server-startup fallback (pattern: seedHdbBlockCoords, server/index.js:60). |
BTO rows are never inserted into transactions |
Price ranges are not transactions. They must not pollute medians, trends, deal scores, or counts. |
| One denormalized table: one row per project × flat-type variant | Matches the project's existing style (the transactions table is denormalized); simplest for SQL. |
Store both bto_label (display, e.g. "2-Room Flexi (Type 2)") and resale_flat_type (join key, e.g. 2 ROOM) |
resale_flat_type must exactly match values in transactions.flat_type: 2 ROOM, 3 ROOM, 4 ROOM, 5 ROOM, EXECUTIVE, MULTI-GENERATION. Mapping: 2-room Flexi (any type) → 2 ROOM; 3Gen → MULTI-GENERATION. |
| Comparison ladder: comps within 1000m → widen to 2000m → fall back to whole town; 12-month window | BTO sites are often new estates where 500m has few resale blocks. Mirrors the existing valuation ladder concept (/api/valuation, server/index.js:1795) but simpler — no storey adjustment, no deal score. It's context, not a score. |
| Prices displayed "excluding grants" with explicit disclaimers | Matches HDB quoting convention; honest-copy requirement (this is a YMYL site — see the E-E-A-T pages). |
lat/lng per project curated in the JSON (geocode via OneMap once, by hand or helper script) |
~40 points total; a manual override beats automated geocoding of vague "bounded by X and Y" strings. |
The complete June 2026 data to paste into this file is in Appendix A. Backfill sources for the other launches are in Appendix B.
CREATE TABLE IF NOT EXISTS bto_projects (
launch_id TEXT NOT NULL, -- '2026-06'
launch_label TEXT,
application_start TEXT, -- 'YYYY-MM-DD' or NULL
application_end TEXT,
project TEXT NOT NULL, -- 'LAKEVIEW CASCADIA'
display_name TEXT,
town TEXT NOT NULL, -- 'BISHAN'
classification TEXT, -- 'Standard'|'Plus'|'Prime'
location_desc TEXT,
lat REAL,
lng REAL,
waiting_months INTEGER,
bto_label TEXT, -- '' for upcoming projects with no flat data yet
resale_flat_type TEXT, -- '2 ROOM' etc., or NULL for upcoming
floor_area_sqm REAL,
units INTEGER,
price_min INTEGER,
price_max INTEGER
);
CREATE INDEX IF NOT EXISTS idx_bto_project ON bto_projects(project);One row per entry in flats[]; a project with an empty flats[] (upcoming) gets exactly one row
with bto_label = '' and NULL flat fields, so it still appears in listings/search.
hdb_quoted_resale is NOT stored in the DB — the server reads it straight from the JSON at startup
into an in-memory map keyed by project name (it's display-only garnish).
Launch status is computed, not stored (server-side helper):
upcoming if application_start is NULL or in the future; open if today is within start/end;
closed otherwise.
Work through these in order. Run npm test after every step that touches server/index.js or tests.
Files: scripts/bto_launches.json (new)
- Create the file with the June 2026 launch from Appendix A and skeleton entries (label + sources + towns) for the four backfill launches and Nov 2026, per Appendix B. It is acceptable to land June 2026 fully and leave the backfill launches as skeletons for a follow-up curation pass — the feature must work correctly with however many launches are present.
- Fill
lat/lngfor each June 2026 project. Method: query OneMap search (https://www.onemap.gov.sg/api/common/elastic/search?searchVal=<street>&returnGeom=Y&getAddrDetails=Y) with the most specific street fromlocation_desc(e.g. "Berlayar Street", "Woodgrove Avenue", "Ang Mo Kio Avenue 2"), take the first result'sLATITUDE/LONGITUDE, and sanity-check the point is in the right town (compare against nearby rows inhdb_block_coordsfor that town). A throwaway script is fine; do not build permanent tooling for this.
Verify: node -e "const j=require('./scripts/bto_launches.json'); console.log(j.launches.length)" parses; every
June 2026 project has non-null lat/lng inside Singapore bounds (lat 1.2–1.5, lng 103.6–104.1).
Files: scripts/download_data.py, server/index.js
- Python (runs on every full DB rebuild, which replaces
resale.dbnightly): add aseed_bto_projects(conn)function that readsscripts/bto_launches.json, executes theCREATE TABLE/CREATE INDEXfrom §4.2 (drop + recreate is fine — the JSON is the source of truth), and inserts all rows. Call it whereseed_hdb_block_coords()is called. - Server startup fallback (protects a live DB that predates this feature): add
seedBtoProjects(DB_PATH)next toseedHdbBlockCoords(server/index.js:60) following its exact pattern — checksqlite_masterfor the table; if missing, open a separate writable connection, create + insert from the JSON, close it. Call it right afterseedHdbBlockCoords(DB_PATH)(server/index.js:53). Also load thehdb_quoted_resalemap from the JSON here (plainrequire, module-levelconst).
Gotchas:
resale.dbis opened read-only (server/index.js:50) — writes must use a separatenew Database(dbPath)like the existing seeder.- The Python seeder must run after the table's data would be wiped, i.e. inside the normal rebuild flow — the whole DB file is rebuilt from scratch, so just call it once during build.
- Remember
PRAGMA wal_checkpoint(TRUNCATE)is already part ofdeploy:data— no change needed there.
Verify: delete the local table (DROP TABLE bto_projects) → start server → table re-appears with the right row count logged.
Files: server/index.js. Add the three handlers together, after /api/valuation
(server/index.js:1795) and before the SEO endpoints. All are GET, all behind the existing DB
middleware (register them normally; the middleware already guards /api/).
A. GET /api/bto/launches — the /bto index page payload.
Returns launches (newest first) with projects grouped and flat rows nested:
{ "launches": [ { "launch_id": "2026-06", "label": "...", "status": "closed",
"application_start": "...", "application_end": "...",
"projects": [ { "project": "...", "display_name": "...", "town": "...", "classification": "...",
"location_desc": "...", "lat": ..., "lng": ..., "waiting_months": ...,
"total_units": 1221, // SUM(units)
"flats": [ { "bto_label": "...", "resale_flat_type": "...", "floor_area_sqm": ..., "units": ..., "price_min": ..., "price_max": ... } ] } ] } ] }B. GET /api/bto/projects?q=<text>&limit=10 — autocomplete. Mirror
/api/private/projects (server/index.js:1137): validate q (trim, length ≤ 100, else 400),
SELECT DISTINCT project, display_name, town, classification, launch_id FROM bto_projects WHERE project LIKE ?
with %q% (uppercased), parameterized (never string-interpolate — see the SQL-injection fix
history in memory-bank/progress.md).
C. GET /api/bto/project-overview?project=<name> — the project page payload.
- Fetch all
bto_projectsrows for the project (case-insensitive exact match onproject); 404{ error: 'Project not found' }if none. - For each distinct
resale_flat_typein the project's flats, compute nearby resale comps:findNearbyHdbBlocks(lat, lng, 1000)(server/index.js:706) → block/street pairs → querytransactions(dataset_source != 'URA_PRIVATE') for thatflat_type, last 12 months (reusemonthsAgoStr), matching(block || '|' || street_name) IN (...)— copy the pair-matching pattern from/api/nearby-hdb(server/index.js:1683).- If < 5 comps → retry at 2000m → if still < 5, fall back to town+type (12 months). Record which
rung was used as
comps_basis:'1000m' | '2000m' | 'town'. - Output per flat type:
{ resale_flat_type, comps_count, comps_basis, resale_median, resale_p25, resale_p75, resale_median_psm, median_remaining_lease, discount_pct }wherediscount_pct = Math.round((1 - ((price_min+price_max)/2) / resale_median) * 100)(null when no comps). Use the existingmedian()/percentile()helpers.
- Response:
{ project, display_name, town, classification, launch_id, launch_label, status, application_start, application_end, location_desc, lat, lng, waiting_months, flats: [...], comparison: [...], hdb_quoted_resale: [...] }. If lat/lng are null, returncomparisoncomputed via the town fallback only.
Exports: if any new pure helper is added (e.g. launchStatus()), export it via _test in
module.exports (server/index.js:2845).
Verify: curl 'localhost:3000/api/bto/project-overview?project=LAKEVIEW%20CASCADIA' | python3 -m json.tool
shows flats + a comparison array with plausible medians.
Files: server/index.js (/api/resolve, line 560), public/js/app.js (search(), line 438), public/js/api.js
Typing "Lakeview Cascadia" (any case) in the search bar must land on the BTO project page.
- In
/api/resolve: after the exact-town / district checks but before the partial-town match and private-project fallback, querybto_projectsfor a case-insensitive exact name match → return{ resolved: true, type: 'bto', project, display_name, town }. Then, only if nothing else matched at all, also try a BTOLIKE %q%match (single hit → resolve; multiple → prefer exact).⚠️ Ordering matters: BTO names often contain town names ("SEMBAWANG PORTICO", "KEBUN BARU RIDGE"). The current town partial-match (t.includes(inputUpper)with word-boundary) should not swallow these, but write the regression tests in Step 5.7 to prove it:SEMBAWANG PORTICO→bto, plainSEMBAWANG→town. - In
app.jssearch(): handleresolved.type === 'bto'→ callAPI.getBtoProjectOverview(name)→renderBtoResults(data)(Step 5.5). - In
api.js: addgetBtoLaunches(),searchBtoProjects(q, limit),getBtoProjectOverview(project)following the file's existing 5-line fetch pattern. - Autocomplete: in
app.jsupdateAc()(the async lookups around line 1660–1684), add aAPI.searchBtoProjects(q, 3)lookup alongside the private-project one. Item shape:{ icon: '🏗️', label: display_name, sub: '<TitleCase town> · <classification> BTO', value: display_name, type: 'bto' }. InrenderAc()(line 1694) the badge class ternary needs abtocase — use amber-like styling distinct fromtown/district/project.
Verify (manual, npm run dev): type "lakeview" → dropdown shows the BTO entry; Enter → BTO page renders; "sembawang" still resolves to the town.
Files: public/index.html, public/js/app.js, public/js/map.js (likely no change), public/css/styles.css (only if needed)
A. Project page — renderBtoResults(data) (new method in App, modeled on
renderPrivateResults, app.js:1457). Reuse the existing results section skeleton; hide the
sections that don't apply (trend chart, distribution chart, transactions table, valuation card).
Content, top to bottom:
- Header: display name; badges for classification (color-code: Standard = gray, Plus = blue, Prime = purple), town (link to
/hdb/<town-slug>), launch label, computed status (upcoming= amber "Applications not open",open= green "Applications open –",closed= neutral "Applications closed "). - Stat cards (reuse existing stat-card markup): total units, waiting time ("~X yrs Y mo"), price range across flat types, classification.
- Flats table: one row per flat variant — BTO label, floor area (convert with
App.sqmToSqft()— DB/JSON stores sqm, display is sqft; same rule as everywhere in this app), units, indicative price range. - "BTO vs nearby resale" comparison table — the centerpiece. One row per flat type:
BTO range | nearby resale median (+ p25–p75) | discount % (green badge when positive) | comps note
("N sales within 1km in last 12 mo, ~Yy lease left" — wording varies by
comps_basis; fortownbasis say "across "). Ifhdb_quoted_resaleexists, add a muted footnote line per row: "HDB's quoted comparable at launch: $X–$Y". - Mandatory disclaimer block (verbatim or near-verbatim):
Indicative price ranges from HDB at launch, excluding grants; actual prices vary by unit. Resale figures are actual transactions of nearby flats with older leases — a new BTO has a full 99-year lease but a ~X-year wait. Not financial advice.
- Map: reuse the private-project pattern —
TransactionMap.loadPreGeocoded-style pin at project lat/lng + nearby resale markers viaAPI.getNearbyHDB(lat, lng)chained after map load resolves (copy the ordering fromrenderResults; there was a race bug here before — seememory-bank/activeContext.md"Condo marker race fixed"). Skip the map entirely when lat/lng is null. - Call
this._onResultsShown()andthis.updateSeoForSearch('bto', data)at the end (see Step 5.6).
B. Launch index page — route /bto: simple render — for each launch (newest first): heading
with label + status, then a card grid of projects (name, town, classification badge, unit count,
price span, "~Xy wait") linking to /bto/<slug>. Entry link: add "BTO Launches" to the footer nav
in index.html next to About/Methodology/Data Sources, and a one-line link on the homepage near
the town-links SEO block.
C. Routing (app.js):
handleUrlRoute()(line 72): add/^\/bto$/→ render index;/^\/bto\/([^/]+)$/→ slug → project name (slug = lowercase, spaces→-; resolve back by matching againstsearchBtoProjectsresults or a dedicated exact-slug lookup — simplest is to slugifydisplay_nameclient-side and compare). Unknown slug → fall through to normal search of the de-slugged text.updateSeoForSearch(line 155): add abtocase pushing/bto/<slug>with title<Display Name> BTO — <TitleCase Town> <classification> (<Launch Label>) | WorthIt; GA4page_viewfires automatically through the existing code path.- Track GA4 events via the existing
App.track():view_resultswithresult_type: 'bto'.
Files: server/index.js (/api/seo/metadata line 2200, /api/seo/sitemap line 2112). The
Cloudflare edge function (functions/[[path]].js) needs no changes — it is path-agnostic and
already injects whatever the metadata API returns for any non-static route.
- Metadata branches (add near the top of the route matching, before the
/hdb/branches):/bto→ title "HDB BTO Launches 2025/2026 — Prices & Resale Comparison | WorthIt"; description listing latest launch towns;content_html= brief intro + per-launch project link lists (same internal-linking style as town pages); WebPage + BreadcrumbList JSON-LD./bto/<slug>→ match slug againstbto_projects(slugifydisplay_namethe same way as the client). Found: title " BTO Price — from $Xk () | WorthIt", description with price span + town + classification;content_htmlwith the flats table, the comparison summary (recompute or summarize cheaply — town-basis medians are fine here), links to the town page + sibling projects; JSON-LD WebPage + BreadcrumbList (Home → BTO → project) + FAQPage prose viafaqsToHtml()(line 2083) — e.g. "How much does a 4-room flat at X cost?", "When will X be completed?" (answer with waiting time), "Is X cheaper than resale flats nearby?". Not found: keep canonical =/bto+noindex, follow(existing soft-404 pattern, see themeta.robotshandling).- Include
dateModified/freshness note like other branches do (thelatestMonthvariable is already computed at the top of the handler).
- Sitemap: add
/bto(priority 0.7, weekly) + one URL per priced project (priority 0.6, monthly). Skipupcomingskeleton projects with no flats.
Files: tests/fixtures/seed.js, tests/integration/bto.test.js (new),
tests/integration/resolve.test.js, tests/integration/seo.test.js, tests/unit/helpers.test.js
- Fixture (
seed.js): add thebto_projectsCREATE TABLE (§4.2) and seed 2 projects:BEDOK VISTA CREST(fictional, launch2026-06, townBEDOK,Standard, lat/lng1.3253, 103.9303— inside the existing BEDOK NORTH ST 1 cluster so the 1000m comps ladder finds the 3-ROOM fixture rows), flats: 3 ROOM 65 sqm 200 units $250k–$344k; 4 ROOM 93 sqm 300 units $320k–$437k.TOA PAYOH SUMMIT(fictional, launch2026-11, townTOA PAYOH,upcoming: empty flat fields, null lat/lng).⚠️ Adding a table + rows does NOT change any transaction counts, so existing count assertions (status.test.jstotal 42,area-overview.test.jsBEDOK counts) must stay untouched — if they break, you did something wrong.
bto.test.js(~12 tests):/api/bto/launches: 200; launches sorted newest-first; project nesting shape;total_unitssum; upcoming project present with empty flats./api/bto/projects?q=vista: finds the project;q> 100 chars → 400./api/bto/project-overview?project=BEDOK VISTA CREST: 200;comparisonhas a3 ROOMentry withcomps_count ≥ 5,comps_basis: '1000m',resale_medianbetween 279000 and 300000 (the fixture 3-ROOM prices), and a positivediscount_pct; case-insensitive lookup works; unknown project → 404. Upcoming project → 200 with town-fallback/empty comparison and no crash.
resolve.test.jsadditions (regression-critical):lakeviewequivalent for fixture:BEDOK VISTA CREST→{ type: 'bto' }(exact, case-insensitive).BEDOKstill → town;BEDOK VISTA CRESTmust NOT resolve as town BEDOK (name-contains-town guard).vista crest(partial, unambiguous) →bto.
seo.test.jsadditions:/btometadata 200 with title;/bto/bedok-vista-cresthas project title +content_html; unknown/bto/nope→noindex, follow; sitemap contains/btoand the project URL but NOT the upcoming skeleton project.- Unit:
launchStatus()(or equivalent) via_testexports — upcoming/open/closed boundaries.
Verify: npm test — all existing tests still pass (expected new total: 213 + ~20). Do not
run smoke tests (they hit production, which won't have the feature yet).
- Run
npm testone final time — must be fully green. - Update
memory-bank/activeContext.md(new "Recent Changes" section describing what was built, marked local only — user handles commit + deploy) andmemory-bank/progress.md(What Works additions + new test count). - Update
CLAUDE.mdKey Patterns with a short "BTO launches" paragraph (seed JSON →bto_projectstable → endpoints →/btoroutes) and the new test count. - Do NOT deploy. Do NOT commit. Do NOT bump
?v=manually (scripts/bump-version.jshandles the version bump automatically during deploy). The user does all of these.
- Read
memory-bank/*before writing any code (project directive in CLAUDE.md). - DB stores sqm; every displayed area/price-rate goes through
App.sqmToSqft()/App.psmToPsf(). - All SQL uses parameterized placeholders. No string interpolation of user input, ever.
- Never write to the read-only
dbconnection; seeding opens its own writable connection and closes it. - Never insert BTO rows into
transactions. - Never fetch
homes.hdb.gov.sgfrom code. - Match existing code style (vanilla JS object-literal "classes", Tailwind utility classes,
dark:variants for every new UI element, existing toast/badge/card markup patterns). - New UI must work in light and dark theme and on mobile (test at 375px width).
- Typing "Lakeview Cascadia" (or fixture equivalent) in the search bar opens the BTO project page; autocomplete shows it with a
btobadge while typing. - "Sembawang" still resolves to the town, "Sembawang Portico" to the BTO project.
- Project page shows flats table (sqft), comparison vs nearby resale with discount %, map with project pin + nearby resale markers, status badge, disclaimer.
-
/btolists all seeded launches incl. Nov 2026 as upcoming; footer link exists. - Direct navigation / refresh on
/btoand/bto/<slug>works (SPA route handling). -
/api/seo/metadata?route=/bto/<slug>returns rich metadata; sitemap includes BTO URLs. -
npm testfully green; no existing assertion changed except where this plan says so (none expected). - memory-bank + CLAUDE.md updated; nothing deployed or committed.
Battle-tested across 6 launches (Jun 2026, Nov 2026 provisional, Feb 2026, and the Feb/Jul/Oct 2025 backfill). Follow this in order; every gotcha below was hit for real at least once.
- Find the press release; download the Annex A PDF (pricing) and the admin-details annex (dates).
- Extract both PDFs with
pypdf. - Resolve each project's HDB town (must match a value already in
transactions.town). - Geocode each project (try the project name itself first — see 8.4).
- Sanity-check every coordinate against existing
hdb_block_coordsfor that town. - Check for CCA (Community Care Apartment) rows — exclude from
flats[](see 8.5). - Check for a reused project name against every other launch already in the file (see 8.6).
- Write the launch object into
scripts/bto_launches.json(schema in §4 above). - Verify: JSON parses, coords in bounds, unit totals match the press release headline, no
duplicate
projectkeys anywhere in the file (script in 8.7). -
npm test— must stay green with no new test failures and no test file changes (seeding is pure data; if a test breaks, the data is wrong, not the test). - Reseed + verify live (8.8).
- Update
memory-bank/activeContext.mdwith what was added (one section per seeding session is fine — don't rewrite prior sections).
Search: HDB <Month> <Year> BTO sales exercise press release. The canonical press release lives at
https://www.hdb.gov.sg/hdb-pulse/news/<year>/<slug> — grab the exact slug from the search result,
you'll need it for the Annex URL.
Annex A URL pattern is not fully stable — try in this order, don't assume:
https://www.hdb.gov.sg/-/media/hdb-pulse/news/<year>/<press-release-slug>/Annex-A.pdf(worked for Jun 2026, Feb 2026 — try this first, it's the common case).- If that 404s (comes back as an HTML page, not a PDF — always check with
fileafter downloading, never assume a 200 status means you got the PDF), search"hdb.gov.sg" <month> <year> BTO "Annex-A" site:hdb.gov.sg— Google's index usually has the exact working filename. Two real variants hit during backfill:- Jul 2025 needed a date suffix:
Annex-A-20250723.pdf(the publish date,YYYYMMDD). - Oct 2025 used a completely different naming scheme:
Annex-ABTO-sales-exercise-Oct-2025.pdf, and its admin-details annex was Annex C, not Annex B (that launch had no concurrent SBF exercise, which seems to shift the lettering).
- Jul 2025 needed a date suffix:
- The admin-details annex (application dates) is usually Annex B, but check the search result
title (e.g. "ANNEX B ADMINISTRATIVE DETAILS FOR ...") or "ANNEX C ..." — just search
"hdb.gov.sg" <month> <year> BTO "Annex-B" OR "Annex-C" site:hdb.gov.sgif unsure.
Download both PDFs to the scratchpad directory and verify with file <name>.pdf before parsing —
a failed fetch silently returns an HTML error page with a .pdf extension.
/Users/weisong/Video/WorthOrNot/venv/bin/python3 -c "
from pypdf import PdfReader
r = PdfReader('<path>.pdf')
for i, p in enumerate(r.pages):
print(f'===== PAGE {i+1} =====')
print(p.extract_text())
"(Install pypdf into the venv first if missing: venv/bin/python3 -m pip install --quiet pypdf.)
Annex A gives you, per project: classification (Standard/Plus/Prime — it's a section header, not a
per-row column), waiting time, and a table of flat type × floor area × units × price range. It also
has a second section, "COMPARISON OF NEW FLATS AND RESALE COMPARABLES NEARBY" — that's the
hdb_quoted_resale data (HDB's own nearby-resale comparison, shown as a footnote on the project page).
The admin-details annex's "Submission of Application" paragraph gives application_start/application_end.
Every project's town field must exactly match a value already in the transactions table —
check with:
node -e "
const Database = require('better-sqlite3');
const db = new Database('server/db/resale.db', { readonly: true });
console.log(db.prepare(\"SELECT DISTINCT town FROM transactions WHERE town IS NOT NULL ORDER BY town\").all().map(r=>r.town));
"Press releases and blogs use neighbourhood/estate names (e.g. "Simei", "Redhill", "Kim Keat", "Chencharu") that don't always match the HDB town name directly — cross-reference against this list. When in doubt, geocode the project (next step) and check which existing town's blocks are physically nearby.
Try the project name itself first — most BTO project names resolve directly to a real registered address via OneMap, often more precisely than any street search:
curl -s -G "https://www.onemap.gov.sg/api/common/elastic/search" \
--data-urlencode "searchVal=<PROJECT NAME>" --data-urlencode "returnGeom=Y" \
--data-urlencode "getAddrDetails=Y" --data-urlencode "pageNum=1" | python3 -m json.toolThis worked directly for the large majority of projects across all 6 launches seeded so far (e.g. "SEMBAWANG PORTICO", "REDHILL PEAKS", "CHENCHARU VINES" all returned exact block addresses). Only fall back to searching the location description's street/landmark name (from the press release or a BTO preview blog) when the bare project name returns nothing.
Always sanity-check the result against existing HDB blocks in the same town:
node -e "
const Database = require('better-sqlite3');
const db = new Database('server/db/resale.db', { readonly: true });
const rows = db.prepare(\"SELECT hbc.lat, hbc.lng FROM hdb_block_coords hbc JOIN transactions tx ON tx.block=hbc.block AND tx.street_name=hbc.street_name WHERE tx.town=? LIMIT 5\").all('<TOWN>');
console.log(rows);
"A new BTO site can legitimately be 1–2km from the nearest sampled existing block (new estates are often at town edges — Bayshore, Woodlands North, Tengah, etc. all check out this way) — that's normal, not a red flag. What would be a red flag: multiple kilometres off, or in the wrong general region of the island entirely.
HDB does sometimes reuse the exact same project name across two different launch exercises — confirmed with "Redhill Peaks" (Oct 2025 phase 1, 1,021 units, 2RF+4R only; Feb 2026 phase 2, 1,052 units, 2RF+3R+4R), split across two exercises due to site-prep timelines for separate land parcels of the same estate. Before adding any project, grep the whole file for its name:
node -e "
const j = require('./scripts/bto_launches.json');
const seen = {};
for (const l of j.launches) for (const p of l.projects) {
if (seen[p.project]) console.log('COLLISION:', p.project, 'in', l.launch_id, 'and', seen[p.project]);
seen[p.project] = l.launch_id;
}
"If a collision is found: the app's schema treats project as a globally unique lookup key
(/api/resolve and /api/bto/project-overview both do WHERE UPPER(project) = ? with no
launch_id filter) — two rows sharing that key silently conflate both launches' flats, prices, and
waiting times into one broken record. Disambiguate both entries (not just the new one — go back
and fix the earlier one too) as project: "NAME (MONTH YEAR)" / display_name: "Name (Month Year)",
and add a one-line note in location_desc on both explaining the split.
node -e "
const j = require('./scripts/bto_launches.json');
const l = j.launches.find(x => x.launch_id === '<YYYY-MM>');
let total = 0;
for (const p of l.projects) {
const u = p.flats.reduce((s,f)=>s+(f.units||0),0);
total += u;
const inBounds = p.lat==null || (p.lat>=1.2 && p.lat<=1.5 && p.lng>=103.6 && p.lng<=104.1);
console.log(p.project, p.town, 'inBounds='+inBounds, 'units='+u);
}
console.log('TOTAL:', total);
"Cross-check the total against the press release's headline unit count. This has matched exactly
every single time so far (Feb 2025: 5,032; Jul 2025: 5,547; Feb 2026: 4,692) and is strong evidence
the extraction is correct. If it's off, the gap is usually a CCA row you correctly excluded (Oct
2025: 8,937 in flats[] + 207 excluded CCA units = 9,144 exactly) — reconcile the difference
explicitly in a _curation_note rather than silently absorbing it.
node -e "
const Database = require('better-sqlite3');
const db = new Database('server/db/resale.db');
db.exec('DROP TABLE IF EXISTS bto_projects');
db.close();
"
# then restart the dev server (npm run dev / node server/index.js) — it reseeds on startup.Then spot-check a couple of the new projects live:
curl -s 'http://localhost:3000/api/bto/project-overview?project=<PROJECT NAME>' | python3 -m json.tool
curl -s 'http://localhost:3000/api/resolve?q=<project name>' | python3 -m json.tool # exact-match resolve worksIn production, the nightly refresh workflow rebuilds resale.db from scratch (Python's
seed_bto_projects() always drops+recreates the table from the JSON) and re-seeds automatically —
no manual step needed there once the JSON is committed and deployed.
For a launch that hasn't officially happened yet (no HDB press release or Annex A exists): only
add project rows if third-party BTO preview trackers report firm, unambiguous per-flat-type unit
counts for that specific project — if a source only gives an aggregate/combined figure for
multiple sites (e.g. two neighbouring projects reported as one combined total), do not invent a
per-type split; add a single aggregate flats[] entry with resale_flat_type: null instead (the
comparison-table logic already skips null types cleanly — see /api/bto/project-overview's
.filter(Boolean) on the type Set). Set price_min/price_max: null on every flat (frontend
renders "Price TBD"), classification: null if not yet announced, application_start/end: null,
and add a _provisional_note per project naming the source type (never claim it's official). The
frontend's "upcoming" status (driven purely by application_start being null or in the future)
automatically shows the amber "Provisional" warning banner — no extra flag needed. Replace this
data with the real thing once HDB's official Annex A is published, following 8.1–8.8 normally.
nearby_btoin/api/nearby-hdb+ dashed-ring BTO markers on postal/town search maps ("a BTO is launching 400m from this block") — highest-value follow-up.- Application rates per project (demand signal) once published.
- "What did flats from the launch resell for" pages as old launches pass MOP and enter the resale data.
- Launch-index SEO pages per exercise (
/bto/2026-june).
Sources:
- Press release:
https://www.hdb.gov.sg/hdb-pulse/news/2026/hdb-launches-6952-flats-across-7-projects-in-june-2026-bto-sales-exercise - Annex A (pricing):
https://www.hdb.gov.sg/-/media/hdb-pulse/news/2026/20260617-HDB-Launches-6952-Flats-Across-7-Projects-in-June-2026-BTO-Sales-Exercise/Annex-A.pdf - Locations:
https://www.mynicehome.gov.sg/get-started/hdb-bto-sales-launch/
Launch: launch_id: "2026-06", application 2026-06-17 → 2026-06-24. 6,952 units, 7 projects.
(2-room Flexi prices below are 99-year-lease prices, excluding grants.)
| project | town | class | location_desc | wait (mo) | flats: label / resale_flat_type / sqm / units / min–max |
|---|---|---|---|---|---|
| SEMBAWANG PORTICO | SEMBAWANG | Standard | Bounded by Admiralty Lane and Sembawang Drive | 31 | 2RF T1 / 2 ROOM / 40 / 50 / $142k–$184k · 2RF T2 / 2 ROOM / 48 / 150 / $157k–$225k · 3-Room / 3 ROOM / 69 / 100 / $250k–$344k · 4-Room / 4 ROOM / 93 / 300 / $320k–$437k · 5-Room / 5 ROOM / 113 / 275 / $465k–$579k |
| SEMBAWANG BROOK | SEMBAWANG | Standard | Bounded by Admiralty Street and Sungei Sembawang | 33 | 2RF T1 / 2 ROOM / 40 / 58 / $139k–$187k · 2RF T2 / 2 ROOM / 48 / 203 / $164k–$218k · 3-Room / 3 ROOM / 69 / 87 / $257k–$333k · 4-Room / 4 ROOM / 93 / 464 / $302k–$428k · 5-Room / 5 ROOM / 113 / 319 / $420k–$571k · 3Gen / MULTI-GENERATION / 120 / 29 / $468k–$567k |
| WOODGROVE ACRES | WOODLANDS | Standard | Along Woodgrove Avenue | 42 | 2RF T1 / 2 ROOM / 40 / 31 / $137k–$170k · 2RF T2 / 2 ROOM / 48 / 126 / $164k–$211k · 3-Room / 3 ROOM / 69 / 80 / $260k–$325k · 4-Room / 4 ROOM / 93 / 162 / $353k–$437k · 5-Room / 5 ROOM / 113 / 257 / $472k–$582k |
| KEBUN BARU RIDGE | ANG MO KIO | Plus | Along Ang Mo Kio Avenue 2 | 37 | 3-Room / 3 ROOM / 66 / 95 / $380k–$492k · 4-Room / 4 ROOM / 89 / 390 / $543k–$693k |
| KEBUN BARU BREEZE | ANG MO KIO | Plus | Bounded by Ang Mo Kio Avenue 1 and Ang Mo Kio Rise | 52 | 2RF T1 / 2 ROOM / 38 / 261 / $191k–$275k · 2RF T2 / 2 ROOM / 48 / 116 / $255k–$349k · 4-Room / 4 ROOM / 90 / 202 / $547k–$746k |
| LAKEVIEW CASCADIA | BISHAN | Prime | Bounded by Upper Thomson Road | 51 | 2RF T1 / 2 ROOM / 40 / 118 / $216k–$287k · 2RF T2 / 2 ROOM / 49 / 358 / $257k–$361k · 4-Room / 4 ROOM / 90 / 745 / $534k–$742k |
| BERLAYAR RISE | BUKIT MERAH | Prime | Bounded by Berlayar Street and Berlayar Drive (next to Telok Blangah MRT) | 54 | 2RF T1 / 2 ROOM / 40 / 172 / $247k–$341k · 2RF T2 / 2 ROOM / 48 / 644 / $296k–$406k · 3-Room / 3 ROOM / 67 / 172 / $435k–$591k · 4-Room / 4 ROOM / 90 / 988 / $592k–$810k |
hdb_quoted_resale (HDB's own published nearby-resale comparables, per project — optional field):
- SEMBAWANG PORTICO / SEMBAWANG BROOK: 2R $322k–$390k · 3R $500k–$528.9k · 4R $600k–$680k · 5R $685k–$820k (~93y lease)
- WOODGROVE ACRES: 2R $365k–$423k · 3R $510k–$556k · 4R $650k–$750k · 5R $777k–$815.2k (~92y lease)
- KEBUN BARU RIDGE / BREEZE: 4R $830k–$1,080k (~91y lease; no 2R/3R comparables quoted)
- LAKEVIEW CASCADIA: 4R $840k–$950k (~71y lease, 102 sqm; no 2R comparable quoted)
- BERLAYAR RISE: 3R $740k–$771k · 4R $938.9k–$1,068k (~91y lease; no 2R comparable quoted)
Note: Berlayar Rise waiting time was published as "49/54" (two phases) — store 54.
Same extraction recipe per launch (press release → Annex A PDF → tables like Appendix A):
| launch_id | exercise | starting point |
|---|---|---|
| 2025-02 | Feb 2025 BTO (~5,000+ flats) | search "HDB February 2025 BTO sales exercise press release" on hdb.gov.sg |
| 2025-07 | Jul 2025 BTO | search "HDB July 2025 BTO sales exercise press release" |
| 2025-10 | Oct 2025 BTO (9,144 flats, 10 projects) | https://www.hdb.gov.sg/cs/infoweb/about-us/news-and-publications/press-releases/october-2025-bto-sales-exercise |
| 2026-02 | Feb 2026 BTO (~4,600 flats; Bukit Merah, Sembawang, Tampines, Toa Payoh) | search "HDB February 2026 BTO sales exercise press release" |
| 2026-11 | Nov 2026 BTO (announced; towns from the Jun 2026 press release "upcoming supply" section) | skeleton entry only — application_start: null, no prices until launch |
Annex URL pattern (recent launches): https://www.hdb.gov.sg/-/media/hdb-pulse/news/<year>/<date>-<press-release-slug>/Annex-A.pdf.
Older 2025 releases live under /cs/infoweb/about-us/news-and-publications/press-releases/… with annex links in the page body.
The extracted Jun 2026 PDFs used to build Appendix A were parsed with pypdf — the same approach works for backfill.
{ "launches": [ { "launch_id": "2026-06", // YYYY-MM of the sales exercise "label": "June 2026 BTO", "application_start": "2026-06-17", // null for upcoming launches "application_end": "2026-06-24", "sources": ["<press release URL>", "<Annex A PDF URL>"], "projects": [ { "project": "LAKEVIEW CASCADIA", // UPPERCASE — matching convention of the app "display_name": "Lakeview Cascadia", "town": "BISHAN", // must match a transactions.town value exactly "classification": "Prime", // Standard | Plus | Prime "location_desc": "Bounded by Upper Thomson Road", "lat": null, // curator fills (see step 5.1) "lng": null, "waiting_months": 51, // HDB "estimated waiting time"; use max if a range like "49/54" "flats": [ { "bto_label": "2-Room Flexi (Type 1)", "resale_flat_type": "2 ROOM", "floor_area_sqm": 40, "units": 118, "price_min": 216000, "price_max": 287000 }, { "bto_label": "4-Room", "resale_flat_type": "4 ROOM", "floor_area_sqm": 90, "units": 745, "price_min": 534000, "price_max": 742000 } ], // optional — HDB's own published resale comparables (from Annex A), for display next to ours "hdb_quoted_resale": [ { "resale_flat_type": "4 ROOM", "min": 840000, "max": 950000, "note": "~71y lease, 102 sqm" } ] } ] }, { "launch_id": "2026-11", "label": "November 2026 BTO (upcoming)", "application_start": null, "application_end": null, "sources": [], "projects": [ /* towns only, flats: [], prices unknown → page shows "details at launch" */ ] } ] }