Changelog
What's new
Public log of changes to the CityFunIndex API, the scoring data and the consumer site. Newest first.
Each entry is tagged API (changes to the public JSON API or spec), Data (changes that move scores) or Site (consumer-page changes). For the versioning policy that governs API changes, see the API versioning section.
-
API Preview # Bulk data now flows only through the keyed API
We retired the keyless data feed. The per-city JSON bundles under /data/ and the flat /sample.csv download are no longer published — the full dataset (every score plus the raw measured value behind each factor) is licensed and reachable only through the authenticated /v1 API. Nothing changes for readers: every city, leaderboard and comparison page still shows its scores in full, and the interactive Compare and Personalize tools now carry the numbers they need inside each page, so they load with no extra round-trip. If you were pulling /data/*.json or /sample.csv, see cityfunindex.com/pricing for an API key. Pinned with a build-time guard so the feed can never silently reappear.
-
Site Preview # Report download buttons never show up broken
An adversarial review of the Custom Report Builder caught one real edge case: if the service ever returned a success response with an empty download link (a malformed reply, never normal operation), the builder would still render a “Report built · Download” button pointing nowhere. Both the live-streaming path and the fallback path now require a real, non-empty link before showing the button — otherwise you get a clear “try again” instead of a dead click. Shipped with a regression test.
-
API Preview # AI answers stay verified through long, structured replies
A tenth adversarial review, focused on how the AI grounds and renders its longer answers, found three real issues. The grounding check that re-verifies every score, rank and count against live data was mis-reading Markdown section headings that begin with a number (“## 3 things that matter most”) as if they were rank claims — which could strip the “Verified against measured data” badge off a perfectly faithful brief. A custom report appendix was hard-coded to read “All 23 factors” even on the rare city missing a factor, so the header could disagree with the table beneath it. And two grounded tools failed open — quietly omitting the coverage caveat — when a city’s detail bundle was momentarily unpublished; they now fail safe and tell the model the local reading could not be verified right now. All three are fixed and pinned with regression tests.
-
API Preview # Consistent Data API errors, even on a backend hiccup
A read-only adversarial review of the keyed Data API turned up one real gap: if a backing store had a transient hiccup mid-request (a momentary Firestore or storage blip), the API could fall back to a bare “Internal Server Error” instead of the documented {"error": {…}} envelope every other failure returns — and that stray response also skipped the private, no-store cache header licensed responses always carry, so a shared cache could briefly retain it. Both are fixed: every error now returns the same machine-readable envelope your client already parses, with the correct cache headers, and never leaks internal detail. Shipped with a regression test.
-
Site Preview # Sturdier, more honest AI chat
A ninth adversarial review, this time on the AI chat itself — how it routes your message, streams an answer, remembers the conversation, and links out. Five fixes: the assistant now treats the on-screen transcript as untrusted context and re-checks every score and rank against live data, so an edited or replayed earlier reply can’t make it repeat a wrong number; a custom report that has already produced a working download is no longer reported as a failure if a follow-up bookkeeping step hiccups, and a retry can’t double-charge it; a trip or move question that’s too long to answer as a plain question now gets a clear way forward (name two cities, or shorten it) instead of a dead-end limit message; and if an answer ever invents a city link that doesn’t exist, the words stay but the dead link is removed so it can’t 404. All five ship with tests; the spend kill-switch also now records provider cost even when a multi-stage answer comes back blank.
-
Site Preview # Tougher, more faithful custom reports
An eighth adversarial review, this time on the paid custom-report pipeline (PDF, Word, Excel and Markdown). Three fixes: a city’s strongest/weakest factors that lean on an estimated reading now keep their “estimated” mark in the report’s headline summary, matching how they already render everywhere else; unusual control characters in the AI narrative can no longer crash the Excel and Word exports; and the relocation-brief cache now tells apart two requests that name a different set of cities but happen to share the same joined-up text, so you always get the brief you actually asked for. The delivery, access-control and quota checks in the same review came back clean. All three fixes ship with regression tests.
-
Site Preview # Sharper, more honest grounding in the AI assistant
A seventh round of the internal adversarial review, this time on the AI assistant’s reasoning core. Three correctness fixes: asking the assistant to find cities while giving every named factor an importance of zero now prompts you to weight at least one factor, instead of returning a meaningless equal-weighted list dressed up as a real ranking. When the assistant cites how a city ranks on a factor that only some cities have data for, the “Nth of M” now counts only the cities that actually have that reading — matching the rest of the site (“12th of 130”, not “of 134”). And the internal guard that double-checks every number the assistant states against the dataset now catches ordinal rank phrasings (“ranks 7th for safety”) it previously let slip. All three ship with regression tests; the prompt-assembly and Gemini-client checks in the same review came back clean.
-
API Preview # Sturdier Data API under corrupt data and bad configuration
A sixth round of the internal adversarial review, this time on the licensed Data API. If a dataset file is ever mid-republish or written in a damaged shape, an affected request now returns a clean, retryable “try again shortly” response instead of an opaque server error — and, because it is an outage on our side, it no longer counts against your monthly request allowance. Separately, a blank value for one of the service’s numeric settings now falls back to its default instead of failing to start. Both fixes ship with regression tests; the auth, quota-metering and paid/free data-boundary checks in the same review came back clean.
-
Site Preview # Gap-fill honesty across AI Find, comparisons and report exports
A fifth round of correctness fixes from the internal adversarial review, this time extending the AI assistant’s gap-fill honesty everywhere it cites a factor. When you ask the assistant to find or compare cities, and in the multi-city Custom Report’s “biggest differences” table (including the spreadsheet export, where the factor is now marked with an asterisk), a factor whose value is a regional or national stand-in — no direct local reading yet — is flagged as an estimate instead of being presented as a measured number. The assistant also stops telling you a city “isn’t covered” when it actually is but its detail is briefly between publishes; it now says the detail is temporarily unavailable. Behind the paywall, re-downloading a past report respects your current plan, and deleting a report that is already gone now reports honestly instead of claiming a phantom success. Each fix ships with a regression test.
-
Site Preview # Tie-honest factor neighbours and accurate source counts
A fourth round of correctness fixes from the internal adversarial review, this time across the website. On each per-factor city page, the “cities ranked just above and below” list now respects rank ties: a city tied with the one you’re reading is no longer mislabelled as ranking above or below it. The “Data sources” label on those pages now counts sources correctly — a single source described with a parenthetical aside (such as the walkability index) no longer reads as two. The personalised ranking explains your top factor in one more case it used to skip, and the API reference example now tracks the live data instead of drifting after a recompute. Each fix ships with a regression test.
-
Site Preview # Honest denominators and gap-fill flags in AI leaderboards
A third round of correctness fixes from the internal adversarial review, this time on the AI assistant’s grounding. When the assistant ranks cities on a single factor, each city now reports the true number it was ranked against — a factor with partial coverage ranks against the cities that actually have a reading, so the assistant says “5th of 130”, not an inflated “5th of 134”. A factor score that is a regional or national stand-in (no direct local reading yet) now carries a coverage note on the leaderboard, so a placeholder can never sit at the top of a “safest” or “best” list looking like a measured value. On the API, a key issued with a negative request quota — by a typo or a corrupted record — no longer bricks itself with a permanent, misleading “quota exceeded”. Each fix ships with a regression test.
-
Site Preview # Tie-honest leaderboards, safer keys and steadier AI caching
A second round of correctness fixes from the same internal adversarial review. The per-factor leaderboards are now fully tie-honest: when four or more cities share the top score, every co-leader stays on the podium instead of stranding one in the “also ranked” grid, and the leaderboard index says “N cities tied” rather than crowning a single city when the rounded scores match. A factor drawing on a single source no longer mislabels itself “Data sources” (plural) just because that source carries a parenthetical with a comma inside it. On the back end, the API key check now treats a corrupted or wrong-typed record as inactive instead of erroring, and the AI assistant ignores incidental spacing in a question when reusing a cached answer, so “safest city” and “safest city” resolve to one result. Each fix ships with a regression test.
-
Site Preview # Bug-batch hardening across AI, exports and leaderboards
A round of correctness and safety fixes from an internal adversarial review. The AI answer renderer now escapes quotes and refuses any link whose address smuggles an internal marker, closing two ways crafted text could have injected markup. The Custom Report Builder recovers from a connection that drops mid-stream by retrying over the standard request instead of dead-ending, and a build still running when you sign out is now cancelled cleanly rather than rendering into a signed-out screen. PDF and Word exports now parse tables the model writes without outer borders, so a “Factor | Score” table lays out as a table, not a run-on paragraph. A chat’s “last active” label no longer shows a stray “0y ago” in the days just before a year. And two leaderboard one-liners are now tie-honest: a per-factor board says “tied for the most” when two states share the lead, and a city’s “beats #1 on …” line never points at a placeholder (gap-filled) factor. Each fix ships with a regression test.
-
Site Preview # Live progress while a custom report builds
The Custom Report Builder now shows real progress while it works — a labelled bar that moves through “Gathering city data…”, “Writing your analysis…”, “Formatting…” and “Finalizing your download…” instead of a single frozen line. Behind it, a deep multi-city report on the Pro model now streams its result straight from the report service, so a generation that runs longer than a minute completes cleanly rather than timing out at the front door with a 502. If the stream is unavailable the builder quietly falls back to the original request, and a transient outage surfaces as a clear, retryable message — never a silent failure. Shipped with new tests covering the progress math and the stream’s success, cached and error paths.
-
Site Preview # Hardened the custom report exports
Tightened the new Custom Report Builder ahead of launch after an internal review. Excel exports now neutralize spreadsheet formula-injection — any cell text beginning with “=”, “+”, “-” or “@” (from a free-text focus or the model’s wording) is quoted so it opens as plain text, never a live formula. PDF table cells that ran long are now fit to the real column width with an ellipsis instead of being silently cut at a fixed character count, and a table cell that legitimately contains a “|” no longer breaks the PDF/Word layout. A report’s monthly quota is now charged only once a downloadable file actually exists, so a transient AI outage never spends a report you did not receive. Each fix ships with a regression test.
-
Site Preview # A free AI city read, plus a custom report builder
Every city page now opens with a short, always-on “city read” — a plain-language summary of what the Fun Score and the 23 factors say about that place, generated once per city and baked into the page. It is free, needs no sign-in, and is grounded only on the public scored bundle, so it cites our own factor and city pages rather than any licensed venue data. For subscribers, a new Custom Report Builder in the dashboard turns one or more cities into a downloadable report (Markdown, PDF, Excel or Word) through a chosen lens, with the city page’s “Build a custom report” button pre-selecting that city. The same link-repair pass that keeps those reads honest was hardened so any internal link the model phrases loosely is validated against the real factor and city list before it ships — an unresolvable one is unwrapped to plain text rather than left to 404.
-
Site Preview # An eighteenth round of small fixes
Two small correctness fixes. The /status page’s freshness line now handles a timestamp that lands in the future — which happens when the device viewing the page has a clock running ahead of our publish host. Rather than dressing that up as “last computed less than an hour ago,” it now flags the clock mismatch so the discrepancy is visible. And on /pricing, the annual saving shown next to an API plan’s yearly price now carries a thousands separator to match the price beside it, so the line reads “$5,000/year — save $1,000” rather than mixing “$5,000” and “$1000” in the same sentence. No data or scoring changed.
-
API Preview # A seventeenth round of small fixes
Faster freshness for the machine-readable feeds. The JSON Feed at /changelog.json and the sample dataset at /sample.csv now carry a short five-minute cache lifetime — matching the live status feed — so a reader or a script polling them picks up a new entry within minutes rather than waiting up to an hour for a stale copy to expire. The genuinely static specs (the OpenAPI document, the OpenSearch descriptor) keep their longer cache, since they change rarely. No data or scoring changed.
-
Site Preview # A sixteenth round of small fixes
Honest wording at the extremes of a city page’s factor breakdown. When the live national-rank data is momentarily unavailable, each factor falls back to a “better than X% of cities” line. For the very best and very worst city on a factor that line now reads “Better than nearly every city” and “Near the bottom nationally” instead of a misleadingly precise percentage — a top city can’t be “better than 100%” of cities (it can’t beat itself), and “better than 0%” read as a put-down. The exact “Ranks #N of M” remains the headline figure whenever the rank data loads.
-
Site Preview # A fifteenth round of small fixes
A structured-data hygiene fix for search engines. Every page embeds a machine-readable description of the site (the JSON-LD search engines read to build rich results). Inner pages named the parent “WebSite” entity by reference but only the homepage spelled it out in full, so a crawler had to also fetch the homepage to resolve the link. Each page now carries the complete WebSite definition it points at, matching how the Organization entity already worked — so every page’s structured data stands on its own. The homepage also now builds that node from the same shared helper, so the two can never drift.
-
Site Preview # A fourteenth round of small fixes
Two correctness fixes from the ongoing review. The AI relocation/travel brief compares several cities at once; its cached results now key on every city’s data version, not just one — so when a single city’s scores are recomputed, a fresh brief is generated rather than an older cached one being reused. And the copy-paste embed badge code on each city page now escapes the city name inside the snippet’s title, so the markup you copy stays valid HTML for any city name.
-
Site Preview # A thirteenth round of small fixes
Mobile fit-and-finish plus an AI trust-cue fix. On a touchscreen, the assistant’s small action buttons (copy, save) and the follow-up suggestion chips now grow to a comfortable 44-pixel tap target — the precise mouse layout is unchanged. On each city page, the little “typical-city marker” caption above the factor breakdown no longer pops in and nudges the bars down as the page finishes loading; its space is reserved from the first paint. And behind the scenes, the assistant’s “Verified against measured data” badge now also shows on answers served from cache, not just freshly generated ones, so the same answer is labelled consistently every time.
-
Site Preview # A twelfth round of small fixes
Two accessibility touch-ups from the ongoing review. In the AI assistant, the little Fun Score chip on each cited-city card now picks its text colour to stay readable on every score band — the lowest, red band was using dark text that fell just under the contrast bar, and now matches the white-on-red treatment the rest of the site already uses. And on the homepage search box, the option you’ve arrow-keyed to now gets a clearly stronger highlight than a plain mouse hover, so keyboard users can see exactly where they are in the list.
-
Site Preview # An eleventh round of small fixes
A few deeper correctness fixes from the ongoing review, mostly under the hood. The transit-quality factor’s service signal was being divided by population a second time even though the underlying transit-database value is already per person — corrected so cities are compared on equal footing once that data source switches on. The assistant’s city-comparison tool now asks for a sensible number of cities (2–5) instead of quietly dropping extras or “comparing” a single city. And creating an API key now enforces the per-account key limit atomically, so two requests at once can’t slip past it.
-
Site Preview # A tenth round of small fixes
One more consistency fix on the embeddable score badges. Both the full and compact embed badges now pick their colour band from the same rounded score they print, so the number and its colour can never disagree at a band edge. The site’s other surfaces already worked this way; this brings the badges in line and adds a guard so they stay that way.
-
Site Preview # A ninth round of small fixes
Three more fixes from the ongoing review. The light/dark theme now syncs across browser tabs — switch it in one tab and any others update their toggle and colours to match instead of going stale. Arriving at the homepage from a search link with stray spaces around the query no longer leaves that padding sitting in the search box. And the assistant’s factor lookup now ignores any nickname whose underlying factor has been retired, so it always answers cleanly.
-
Site Preview # An eighth round of small fixes
Four more fixes from the ongoing review, mostly on the account dashboard. When the API is briefly unreachable, revoking a key or hitting the key limit now keeps its error message on screen instead of flipping the panel to the offline view and hiding it. The assistant’s answer bubble is tidied back to its normal state after a regenerate. And the downloadable CSV dataset now neutralises spreadsheet formula-injection, so a cell can never be coerced into running a formula when the file is opened in Excel or Sheets.
-
Site Preview # A seventh round of small fixes
Three more fixes from the ongoing review. Starting a new chat (or signing out) while the assistant is regenerating an answer no longer strands keyboard focus on a vanished message — it returns to the composer. The system-status dot on the /status page now has enough contrast in dark mode when it signals a warning. And the embeddable Fun Score badge now paints its score disc with plain colours so it looks right in every browser a third-party site might embed it in.
-
Site Preview # A sixth round of small fixes
More fixes from the ongoing review. If the assistant ever returns a blank answer, the chat now shows a clear “no answer was returned” message and the Copy and Regenerate buttons behave sensibly instead of copying nothing. On the account dashboard in dark mode, the amber “most of your quota used” bar now has enough contrast to read clearly. And the assistant’s “least fun cities” overview now always returns the lowest-ranked cities, never any city that hasn’t been ranked yet.
-
Site Preview # Gap-fill honesty and a fifth round of small fixes
A fifth round of fixes from the ongoing review. The biggest: a city page no longer names a placeholder factor as one of its strengths. When we don’t yet have a real measurement for a factor in a city, we fill it with the national median so the overall score stays comparable — but that placeholder should never be advertised as something the city is “strongest on”. The hero line, search snippet and drawbacks now skip those cells, on both the server-rendered page and after it loads. The assistant also no longer mistakes a common English word for a US state (“things to do in Austin” no longer gets filtered to Indiana), and comparing two cities that share a name — Charleston, Portland, Jackson — now asks which one you mean instead of silently picking one. Smaller touches: factor pages say “Data source” or “Data sources” correctly, the map legend is hidden from screen readers as decoration, header and theme controls meet the 44-pixel touch target on phones, and an empty search no longer leaves a stale “no match” notice on screen.
-
Site Preview # Assistant routing, accessibility and dark-mode polish
A fourth round of small fixes. Asking the assistant about live music or live shows in two cities is now answered directly instead of being mistaken for a relocation question and sent to the upgrade prompt. The assistant also understands spelled-out state names, so “Portland, Oregon” resolves the same as “Portland, OR”, and typing in scripts that use an input method (e.g. Japanese or Chinese) no longer sends the message mid-composition when you press Enter. On the comparison page, reaching the city limit is now announced to screen readers, not just shown. The “why is this your #1” line on the personalize page no longer names a factor you never adjusted. Category colour chips keep readable text in dark mode, the dashboard menu returns keyboard focus to its button when you close it, and the mobile browser bar matches a saved dark theme from the first paint.
-
Site Preview # Contrast, focus and keyboard fixes
A third round of small fixes. The “Dashboard” button in the header now uses a deeper green on hover so its white label is comfortably readable in both light and dark themes. Pressing the up arrow in the homepage search with nothing yet highlighted now jumps to the last suggestion (matching the down arrow going to the first), the way keyboard users expect. And the assistant is now fully race-proof: starting a new chat or signing out while an answer is still arriving can no longer drop a stray reply into the fresh conversation.
-
Site Preview # More chat and dashboard polish
A second round of small fixes. A suggested “Compare A and B” chip under an answer now stays on the regular assistant — it no longer reads as a trip or a move and wrongly asks Data-plan members to upgrade. The ask box’s character limit now matches the mode you are in, so you are stopped at the real limit as you type instead of after you send. A sign-in prompt or a short validation note now cleanly replaces the welcome screen rather than stacking beside it. On a phone, opening the dashboard menu now fully hides the page behind it from screen readers and the keyboard, not just visually. And regenerating an answer keeps your keyboard place instead of dropping focus to the top of the page.
-
Site Preview # Privacy, accessibility and dashboard fixes
A focused fix batch. On a shared device, the AI assistant now wipes the on-screen conversation the moment the signed-in account changes — so one person’s questions can never linger on screen, or ride along as context, for the next. Switching away mid-answer cleanly cancels the in-flight request. Brand-green buttons that carry white text (dashboard, roadmap and the header “Dashboard” tab) now use the AA-contrast green so the label stays legible in dark mode. The dashboard’s mobile menu now traps focus while open and closes on Escape, and choosing a section moves focus to its heading for screen-reader and keyboard users. The light/dark control in Settings now stays in sync with the header toggle. Finally, the legacy /ai, /ask, /find and /brief links point straight to the assistant, and the sitemap no longer advertises those redirected paths.
-
Site Preview # The AI assistant now remembers the conversation
The assistant follows a back-and-forth now. Ask “tell me about Austin,” then just “what about its weather?” or “how does it compare to Nashville?” and it knows what you mean — it carries the last few turns as context so follow-ups, pronouns and “the second one” resolve to the city you were already talking about. Every answer is still grounded only in the measured data through the same tools; the conversation is used to understand what you are asking, never as a source of numbers. Use “New chat” any time to start fresh.
-
Site Preview # The AI assistant, redesigned
Rebuilt the AI assistant to look and feel like a real, modern AI app. There is now a tidy side rail with a one-click “New chat”, a big greeting that says hello by name once you are signed in, and a large, rounded ask box anchored at the bottom. Answers read as clean, full-width writing led by the spark mark — not cramped chat bubbles — while your own message sits quietly to the side. It is the same one assistant everywhere: the standalone AI page and the assistant view inside your dashboard now share one redesigned surface, so there is no longer a second, lesser chat box or a doubled header. It follows your light or dark theme throughout.
-
Site Preview # A real account dashboard, and one home for the AI
Rebuilt your account area from a single scrolling column into a proper dashboard with a sidebar and dedicated views — an overview with your plan, keys and usage at a glance, plus separate places for API keys, usage, your plan, your settings, and the AI assistant. Everything is one click away from the sidebar, and the right panel loads instantly without a flash. And the AI assistant view IS the assistant — the full chat now lives right inside your dashboard, so you can ask about any U.S. city without leaving the page. It is the same one assistant that powers the AI page (one chat, no duplicate boxes); the AI page is now just the front door that takes you here once you are signed in. The same dashboard works for everyone; free accounts see the full layout with calm, clearly-labelled empty states instead of a stripped-down version.
-
Site Preview # Pick your theme — a real light and dark mode
Added a proper light/dark theme you control, with a sun/moon toggle in the header. Light stays the default; flip to dark and the whole site switches together — every page, the maps, the leaderboards, the embed snippets and the AI assistant — and your choice is remembered the next time you visit. This also fixes a rough edge where the AI page rendered dark while the rest of the site was light; now there is one coherent theme everywhere instead of a mix. The toggle applies your saved choice before the page paints, so there is no flash of the wrong theme on load.
-
Site Preview # A smarter, more honest AI assistant
Three improvements to the grounded AI assistant. First, it now reads your message and picks the right tool for you: name two cities and ask about a move or a trip and you get a side-by-side brief, ask for the rundown on one city and you get a full report — no need to pick a mode first. Second, long-form answers (city reports, trip & move briefs, and “find my city” results) now have a Save as PDF button that prints a clean, branded copy with working links. Third, an honesty fix: when a factor is a regional or national estimate rather than a direct local reading, the assistant no longer presents it as a measured strength or weak spot — those highlights now come only from genuinely measured factors. The assistant is part of the Data and Pro plans; see /pricing.
-
API Preview # Issue your own API keys, and a single home for the AI tools
Two front doors opened. The /account dashboard now has a key manager: once your account carries a paid API plan you can mint an API key, name it, see when it was last used, and revoke it yourself. The raw key is shown exactly once and only its hash is stored, and your plan — never the request — decides what the key can do, so a client can never grant itself more access. And the four grounded AI tools now share one home at /ai, linked from the top of every page, so “where are the AI tools?” has a single answer instead of being scattered across the site. Both are wired and tested; self-serve key issuance from the dashboard and the AI tools themselves open when the API and AI tiers go live. See /pricing for plans.
-
Site Preview # Three AI tools you can use: a Trip or Move brief, Find My City, and Ask the Data
Gave the grounded AI features their own pages so they are not buried on the city report. /brief builds a sourced, balanced City Brief comparing two to five cities — and now does it two ways: a Trip brief for deciding where to visit (what you would actually do, getting around, when to go, the cost and safety of a visit) or a Move brief for deciding where to live (the hard tradeoffs, cost of being there, a typical year). /find takes a plain-English description of your ideal city, turns it into a weighting of the factors, re-ranks every city by the numbers, and explains why each shortlist match fits. /ask answers plain-English questions using only the measured data. Every tool narrates the data and never invents a figure; the ranking is always the algorithm’s, not the model’s. Briefs and Find run on the Pro plan, Ask on Data and Pro — and all three open when the AI tier goes live. See /pricing for plans.
-
Site Preview # Sign in, an account dashboard, a public roadmap, and the keyed API
Opened up the account side of the product. You can now sign in with Google at /account to see who you are, which plan you are on, where to manage billing, and — for data and API customers — the keyed API access card. Identity is handled by the CondorBox umbrella that runs our other products, so it is the same sign-in everywhere. Published a public roadmap at /roadmap that says plainly where the coverage is headed: 134 US cities today, 200+ US cities next, then cities around the world, and ultimately thousands of cities worldwide measured on one comparable scale. Added a /status page that reads the live recompute timestamp so anyone can check the rankings are fresh, and documented the keyed “/v1” API (the same endpoints behind a paid key) right on the /api page. Also recoloured the browser-tab favicon and the illustrated logo mark — in the header, footer, homepage hero and the downloadable press kit — to the brand green they should have been since the green refresh, so the tab icon and the on-page logo finally match. The last orange holdovers are gone.
-
Site Preview # One consistent score scale site-wide, plus a cleaner embed badge
Made every 0–100 score scale read the same way: low on the left (red, “Quiet”) rising to high on the right (green, “Exceptional”), like a number line. The homepage spread bar, the national and per-pillar maps, the state maps and the pillar-leaderboard band strip now all match the direction the per-city score scale and the embed badge already used, so a reader never has to re-orient between pages. Also tidied the embeddable badge: the “CityFunIndex” wordmark is a touch larger, and the rank now reads a plain “#74 of 134” — the cryptic “T#” tie marker is gone from the badge (it stays on the on-site surfaces that explain it). Smaller polish: the city page no longer repeats the “computed” date in two places, the downloadable CSV shows the Fun Score as the same whole number the site shows, and a couple of small uppercase labels were darkened one shade of green for legibility.
-
Site Preview # A round of correctness fixes to search, compare and the leaderboards
Shipped nine fixes from an exhaustive bug hunt. Homepage search: an ambiguous query like “san” or “new” no longer jumps to an arbitrary city — it seeds the box and opens the suggestion list so you choose; pressing Escape now closes the suggestions while keeping what you typed (it used to clear the field); and the first Down-arrow on a closed list now highlights the first suggestion. Compare: when a city fails to load, the page address now drops it too, so reloading or sharing the link no longer re-adds the broken city. Factor pages: a “safest from” style factor now ranks cities by the positive framing rather than reading “best cities for crime”, and the gap-to-typical line reads “1 point” instead of “1 points”. Pillar leaderboards: a tie at the bottom of a board is now described as a tie rather than naming one arbitrary last-place city, and the “Data source(s)” label pluralizes from the real source count.
-
Site Preview # The embeddable Fun Score badge now shows national rank and a score scale
Redesigned the full embeddable badge — the card you drop into a sidebar or page footer via the snippet on every city page. It used to carry just the wordmark, the score disc and the band label, which left the card reading sparse with too much empty space. It now packs two more reads, both computed at build time so the badge still ships zero JavaScript: the city’s national rank (e.g. “#11 of 134”, with a “T#” prefix when the rounded score ties), and a 0–100 score-scale bar whose colour gradient runs from the quiet band up to exceptional, with a marker pinned at the city’s score so a reader can see at a glance where the city sits on the national distribution. The iframe height in the copy-paste snippet grows from 150 to 168px to fit the richer layout. The whole card is still a single backlink to the full city report, the brand display face stays on the wordmark and score, and the badge remains noindex. No scores change; this is a presentation upgrade, so the version stays v2.1.0-dev.
-
Data Preview # The CSV download now flags gap-filled scores
Added a trailing gap_filled_pillars column to the /sample.csv flat file. It lists, semicolon-separated, which of that row’s pillar scores are §6.5 gap-fill placeholders — the cross-city median (≈50) standing in where a city had no measurement — rather than a real reading; an empty value means every score is measured. This brings the bulk CSV in line with the per-cell honesty the city pages, the /best leaderboards and the side-by-side compare already carry, so a spreadsheet or pandas user can tell a measured 50 from a placeholder 50 without cross-referencing the JSON bundles. The column is appended last, so existing readers of the identity, score and factor columns are unaffected. No scores change; this is a transparency-only addition, so the version stays v2.1.0-dev.
-
Site Preview # Brand refresh — Bricolage Grotesque headlines, a green accent palette and a redesigned city search (iter 906)
Three presentation upgrades, no data change. (1) Typography: the CityFunIndex wordmark and every h1/h2 headline now ship in Bricolage Grotesque — a characterful, editorial display grotesque with playful detail — replacing the display serif that shipped on May 28. The serif carried an optical-size axis that, at the large homepage hero, pushed its strokes toward dramatic high-contrast terminals (the lowercase “f” read as fussy); Bricolage is a low-contrast display sans built for sizes up to 96, so the hero stays confident from the 22px masthead mark to the ~64px headline. Body copy and the 23-factor data tables stay on Inter for small-size readability and tabular numerals. (2) Colour: the brand accent moved from terracotta to green — the same family as the top “exceptional” score band — across buttons, links, focus rings, the search field, the embeddable badge and the social-share cards. Green reads as the positive, on-theme brand colour; red is now reserved for the low end of the 0–100 score ramp, not the site chrome. (3) Homepage search: the city search is now a single rounded pill (magnifier + field + inline Search button) with a custom, keyboard-navigable suggestion list that shows each city’s Fun Score as a band-coloured chip — replacing the browser’s unstyleable native dropdown. UI and copy only; the version stays v2.1.0-dev.
-
Site Preview # Placeholder cells are now flagged when you compare cities
Extended the gap-fill disclosure to the side-by-side comparison. When a compared city has no measurement for a factor, its cell now reads “Placeholder” instead of a number — the published score there is the cross-city median (≈50) standing in, the same flag the city page and the leaderboards already show. A placeholder no longer wins the “Best” highlight, counts toward the agreement read, or drives the verdict — so a stand-in 50 can’t quietly tip a head-to-head — and a note beneath the table links the glossary. No scores change; this is a transparency-only addition, so the version stays v2.1.0-dev.
-
Site Preview # Placeholder cells are now flagged on the leaderboards too
Extended the gap-fill disclosure to the factor leaderboards. When a city has no measurement for a factor, its published score is the cross-city median (≈50) standing in as a placeholder — already labelled on the city page. Now every /best leaderboard marks those rows the same way: the row reads “Placeholder” instead of a Fun Score, its card carries a dashed accent, and a note beneath the ranking states how many cities are placeholders for that factor and links the glossary. A placeholder 50 sitting mid-pack is no longer indistinguishable from a measured 50. No scores change — this is a transparency-only addition, so the version stays v2.1.0-dev.
-
Data Preview # Crime scores now resolve for 16 more cities
Closed a data gap on the Crime factor (a negative factor — fewer reported violent and property offences per person scores better). Sixteen cities, mostly mid-size and smaller state capitals such as Montgomery, Little Rock, Des Moines, Providence, Sioux Falls and Charleston WV, had been falling back to the typical-city placeholder because no FBI reporting agency was wired to them. We looked each city’s municipal police department up in the FBI Crime Data Explorer, confirmed it returns a complete reporting year, and pinned it — so those cities now carry their real crime signal instead of a placeholder. Two stay on the placeholder on purpose: Savannah GA, whose department’s latest year in the feed is a partial-reporting collapse that would falsely read as ultra-safe, and Hilton Head Island SC, which has no municipal department — only a county-wide sheriff whose jurisdiction is far larger than the town. This is a data/coverage fix — the scoring algorithm and the calibration scale are unchanged, so the version stays v2.1.0-dev; only the affected cities’ Crime cells (and, where the change crossed a band edge, a few headline scores) move.
-
API Preview # Placeholder (gap-filled) factor cells are now labelled
Added an honest label for the handful of factor cells where a city had no genuine observation at all and the recompute substitutes the §6.5 gap-fill value — the median across cities that do have data, which lands the city near the 50th percentile. On the city page those rows now carry a “Placeholder” tag, and the public JSON and the documented PillarScore object now carry an optional `gapfill: true` flag on the same cells, with a new /glossary entry explaining it. This is distinct from the existing `thin` flag, which marks a genuine measurement of zero: a factor cell has no observation, or a zero observation, or neither — never both — and the two cases are now disclosed separately. The flag is additive and optional, the composite Fun Score is unaffected and the scoring algorithm is unchanged, so the version stays v2.1.0-dev.
-
Data Preview # Star-power scores now resolve for New York, DC and six more cities
Closed a data gap on the Star power factor (how many nationally notable people a city is the hometown of). Eight cities — including New York and Washington, DC — had been falling back to the typical-city placeholder because their Wikidata entity could not be matched automatically: New York and DC are labelled in ways the resolver did not catch, and independent cities and small resort towns (Norfolk, Gilbert, Jackson WY, Telluride, Stowe, Hilton Head Island) sit in containers that broke the lookup. Pinning each one to its Wikidata ID restores the real signal: New York now scores 93 on Star power (the highest in the country, as you would expect), DC 91, with the rest landing on their true counts. A side effect of the re-fetch refreshed Air quality for Pittsburgh and DC as well. This is a data-only fix — the scoring algorithm, the calibration scale and every other factor are unchanged, so the version stays v2.1.0-dev; only the affected cities’ Star-power cells (and, where the change crossed a band edge, a few headline scores) move.
-
API Preview # Raw per-factor measurements moved to the licensed Data plan
Tightened the free/paid boundary on the public data. The per-city JSON and the documented PillarScore object now publish the scored product only — each factor's percentile rank and 0–100 score, plus the date it was refreshed. The underlying raw measurement (the native-unit value behind each score — e.g. restaurants per square mile, transit departures per day) is the licensed dataset and is now reserved for the paid Data plan rather than shipped free in the public payload. Nothing about the scores themselves changed: the composite Fun Score, every per-factor percentile and every published 0–100 score stay free, fully crawlable and identical to before. The one derived bit kept from raw is a `thin: true` flag, set only where a factor's measurement was zero, so the city page can still disclose a no-data cell honestly. The OpenAPI spec, the /api data dictionary and the FAQ were all updated to describe the new shape and point raw-data buyers to /pricing.
-
Data Preview # Recalibrated the 0–100 scale to the full 134-city distribution (v2.1.0-dev)
Re-anchored the Fun Score scale so the published 0–100 numbers span the live 134-city distribution instead of the narrower set the previous anchors were frozen against. The calibration low/high anchors (the 2nd and 98th percentiles of the raw composite) moved to −2.40 / 59.35, which spreads the published scores across more of the 0–100 range and re-seats the leaderboard: Portland, ME now holds the #1 spot outright at 100 (the prior seven-way tie at the ceiling has collapsed to a single leader), with Seattle (99) and Boston (98) just behind. This is a pure re-scaling of the final published number — every underlying measurement, every per-factor percentile, and each city's rank on the 23 individual factors are unchanged; only the headline 0–100 score (and, for a handful of cities near a band edge, the Fun/Very-Fun-style band label) shifts. The change is codified in the scoring config and mirrored into the browser re-score engine, with a cross-language parity test pinning the two in lockstep. Algorithm version bumped v2.0.9-dev → v2.1.0-dev; the methodology worked example, the API examples and the OpenAPI spec were all refreshed to the new numbers.
-
Site Preview # Privacy-first analytics + honest "Coming soon" pricing CTAs (iter 917)
Two funnel-integrity changes shipped together. (1) Added Plausible, a cookieless, privacy-respecting analytics tool, loaded site-wide from a single source of truth (lib/analytics.ts) and gated to production only so dev/test never beacon. It sets no cookies, builds no profile, and needs no cookie banner — /privacy was rewritten to describe exactly that, and the firebase.json Content-Security-Policy was tightened to allow only plausible.io. (2) Replaced the paid-plan CTAs on /pricing with a non-clickable "Coming soon" state on every paid consumer tier (Data, Pro) and self-serve API plan (Startup, Business). Until billing goes live, a visitor can no longer dead-end on a checkout page that does not exist; the free tier still routes into the product and Enterprise still opens a contact thread. billing.ts gained a disabled CTA branch behind the existing BILLING_LIVE gate, and billing.test.ts pins both states: "Coming soon" while BILLING_LIVE is false, real checkout once it flips.
-
Site Preview # Homepage "Top of the Fun Index" adds a "Regional read" line — names the state with the most top-50 cities on the universal leaderboard, or calls out a regionally diverse top tier (iter 916)
Iter 915 shipped the per-pillar regional read on /best/<pillar>. Iter 916 lifts the same boardCluster helper to the universal-leaderboard cohort on the homepage. Same two-shape return (top-state vs spread), scaled for the wider field: inspects the top 50 cities with a domination threshold of 5 (10% concentration floor — proportional to the per-pillar helper's 3-of-10 = 30% floor). Top-state branch reads "California places 8 in the top 50 — the most of any single state" when one state dominates; spread branch reads "the top 50 spans 22 different states — no single state dominates the leaderboard" when concentration is below threshold. Rendered as a teal-accent <p class="rankings-cluster"> inside the rankings-head, directly under the "highest Fun Scores across the N cities" tagline + above the city grid. State codes resolved to display names at render time (STATE_NAMES lookup) — the helper stays jurisdiction-agnostic. Zero new tests: boardCluster's 9 iter-915 guards already pin the threshold boundary, the topN clamping, the topN slice contract, and absolute-top selection across multiple qualifying states.
-
Site Preview # Every /best/<pillar> leaderboard adds a "Regional read" line — names the state with the most top-10 cities, or calls out a regionally spread board (iter 915)
Per-pillar leaderboards already shipped a leader-note + map + spread bar, but the bare list never answered the natural follow-up: "where are the top cities concentrated?" New boardCluster helper inspects the top 10 cities on each board and returns one of two shapes: top-state when one state has ≥3 of the top 10 ("4 of the top 10 cities for Climate are in California — the most of any single state"), or spread when the top 10 is regionally diverse ("the top 10 spans 9 different states — no single state dominates this board"). Always non-null on boards with ≥2 cities, degrades gracefully when the board has fewer than 10 entries (clamps topN). Rendered as a teal-accent <p class="board-cluster"> immediately after the leader-note + before the map — the prose names the cluster, the map below visualises it. 9 guard tests in board-cluster.test.ts pin the empty-list null path, single-city null, top-state vs spread selection, the domination-threshold boundary (count == 3 → top-state, count == 2 → spread by default), topN clamping to ranked.length, the topN slice contract (tail cities NOT counted toward the cluster), and absolute-top selection across multiple qualifying states.
-
Site Preview # State pages add a "Calmest card" line — calm-mirror of iter 913 names the negative pillar where the state's average city is calmest vs the country (iter 914)
Iter 913 shipped the positive-side state calling card. Iter 914 closes the mirror: every multi-city state's hero now also carries a second line that names the single negative pillar where the state's average city has the biggest derived-mean lead (100 minus raw, so higher = calmer) over the national derived mean. California's calmest card might be clean air; Vermont's, low disaster risk; New Mexico's, low traffic. New stateCalmCard helper iterates negative pillars, computes state + national derived means, rounds BOTH before comparing the gap (round-once-then-display contract, applied to derived-space since derived is what users see — iter 912 lesson), and picks the biggest derived-gap pillar. Returns null on single-city states, on national pools under 2 cities, and on states that are worse than the national mean on every negative pillar. The card sits directly under the iter 913 positive sibling with a teal accent border + chip (rather than the positive's band-green) so the two cards read as parallel-but-distinct rather than as a duplicate paragraph. 8 guard tests pin the null paths, the polarity-flip contract (leaderDerived MUST equal 100 − raw, not raw — a regression would silently invert the entire discovery line), biggest-derived-gap selection, round-once-then-display on derived means, deferred-pillar skip, and missing-pillar-value handling.
-
Site Preview # State pages add a "Calling card" line — every multi-city state hero names the one pillar where its average city beats the national average by the biggest margin (iter 913)
Iter 911 named the city's strongest positive story relative to the leader. Iter 912 added the calm-mirror clause for the negative side. Iter 913 lifts the same discovery-stat treatment to the state-aggregate surface: every multi-city state's hero now carries one line that names the pillar where the state's mean city beats the national mean by the biggest margin. Hawaii's might be Climate; Alaska's, Outdoors; Louisiana's, Drinks. New stateCallingCard helper computes mean per positive pillar over the state's cities + over the full bundle, rounds both means before comparing the gap (round-once-then-display contract, same as iters 905-912), and picks the pillar with the biggest positive gap. Returns null on single-city states (mean of one is the city itself, no aggregate signal) and on states that are below the national mean on every positive pillar (rare but real for states where the index has only weaker cities). 7 guard tests in state-calling-card.test.ts pin: the four null paths, biggest-gap selection, round-once contract, deferred-pillar candidacy skip, and graceful handling of cities missing a pillar value. The card sits between the state-personality line and the state-leaders block, with the same dashed-border + band-green left border treatment as the city-page champion-lift — so the discovery-stat vocabulary reads uniform across city and state surfaces.
-
Site Preview # City "Beats #1" line gains a calm-mirror clause — every non-leader can also name a negative pillar where it's calmer than America's champion (iter 912)
Iter 911 shipped the redemption-arc line: one positive pillar where this city beats the national leader. Iter 912 adds the negative-side mirror — a tail clause that names one negative pillar where this city is calmer than the leader. Anchorage's line now reads "...beats Boston, MA on Outdoors (84 vs 58) — and is calmer on Crime (88 vs 64)." Same defect-class avoidance as the positive side: derive the 100−raw score on both sides, ROUND BEFORE COMPARING, and skip the clause entirely when the rounded derived gap is zero (40.1-vs-40.3 → both derive to 60 → gap 0 → no clause, not "calmer by 0 points"). Returns null on the calm clause when the leader is calmer on every negative pillar — possible at the top of the distribution where leaders are dialed-in on negatives — but the positive lift still ships, so the Beats #1 line remains stable. Uses the pillar's own label ("Crime", "Traffic", "Air Quality") so the prose "is calmer on X" reads naturally; NEGATIVE_BOARDS titles like "The safest cities in America" don't fit the sentence template. 4 new guard tests extend champion-lift.test.ts to pin: the happy path (label + derived scores), the no-calm-clause null path, the round-once contract on the derived gap, and the biggest-gap selection across multiple negatives. Same one-line discovery-stat vocabulary as iters 905-911; the city hero now carries both sides of the city's identity in a single sentence.
-
Site Preview # City pages add a "Beats #1" line — every non-leader city now names one pillar where it tops America's champion (iter 911)
Iter 911 ships a "redemption arc" line to every non-#1 city hero. Even the cities ranking #34 or #75 nationally have at least one positive pillar where they outscore the national leader — and naming that one pillar completes the city-hero from "here's how the city ranks + here's what it's strong at + here's what to watch for" into "...but here's the dimension where this city beats America's champion." Anchorage's #34 hero now reads: "Beats #1 Despite ranking #34 nationally, Anchorage beats Charleston, SC on Outdoors (84 vs 72)." New championLift helper picks the heaviest positive-pillar gap over the national leader (1 + count strictly above on universal_score, same tie-aware semantics as the rest of the site). Returns null on three honest paths: the #1 city itself (no champion to beat), cities with no positive gap on any pillar (dominated everywhere — rare, but a real possibility for the bottom of the distribution), and cities missing from the bundle. Rounds BOTH scores before comparing the gap so 72.3-vs-72.1 surfaces as "72 vs 72" → gap=0 → null (round-once-then-display contract, same as iters 905-910's closest-race + agree lines). 8 guard tests in champion-lift.test.ts pin: the three null paths, the biggest-gap selection, the round-once contract, deferred-pillar candidacy skip, sort-not-bundle-order leader determination, and the leader's name + state + slug surface correctly. New champion-lift CSS card mirrors compare-why / pz-driver shape (dashed border + chip label) with band-green left border — same "common ground" visual language as iter 909's Agree? line so the discovery-stat vocabulary reads uniform across compare / personalize / city pages.
-
Site Preview # Personalize page adds a "Driver" line — names your heaviest pillar and the #1 city's national rank on it (iter 910)
Iters 905-909 worked the compare and state surfaces. Iter 910 brings the same discovery-stat treatment to /personalize: the moment a reader moves any slider off the defaults, a single line appears between the results headline and the ranked list — "Your heaviest pillar is Eats — San Francisco, CA ranks #3 nationally on it." It names the lever the reader pushed hardest (the heaviest positive-pillar slider) AND the top-ranked city's national competition rank on that single pillar, so the personalized #1 always carries a verifiable, comparable receipt. New personalizeDriver helper picks the heaviest non-deferred POSITIVE_PILLARS slider, computes 1 + (count strictly above) on that pillar across all cities (the same tie-aware competition rank the rest of the site uses), and returns null on three null-paths: empty ranked list, every slider still at the default DEFAULT_SLIDER_VALUES, or the heaviest positive pillar is tied with another (no honest single "driver" to name). 6 guard tests pin those three null returns plus the rank-1 leader case, the non-leader competition-rank computation, the deferred-pillar candidacy skip, and the bumped-pillar-becomes- driver transition. New pz-driver CSS card mirrors the compare-why and compare-agreement cards (dashed border + chip label) with the accent teal left border so the discovery-stat ladder reads consistently across compare and personalize.
-
Site Preview # Compare page adds an "Agree?" line — the verdict's counterweight that names where the cities cluster (iter 909)
Iter 886 shipped the "Why?" line that explains the Fun Score gap by naming the top single-pillar drivers. The natural counterweight — where do the compared cities AGREE? — was missing, leaving readers to scan the 23-row table for tight pairs on their own. Iter 909 closes that loop: every 2-, 3-, or 4-city comparison now also surfaces a single line — "Within 5 points on 8 of 23 factors — closest matches are Eats (87 vs 89) and Drinks (75 vs 78)." — between the Why card and the table. A new compareAgreement helper iterates every non- deferred pillar all compared cities carry, counts how many lie within AGREEMENT_THRESHOLD (5 raw points — the same gap-floor compareWhy uses to call a driver meaningful, sign-flipped), then names the two tightest with rounded scores. Denominator is the number of pillars every city carries, not the registry total, so a 3-city comparison missing live_music on one city isn't docked for that pillar in either the count or the share. 11 guard tests pin: the below-2-city empty path, the no-agreement empty path, the singular "match is" / plural "matches are" pivot, the singular "factor" / plural "factors" pivot, the 2-cap on named matches, the missing-data skip in both denominator and candidacy, the deferred-pillar skip, the 3+ city " / " separator pivot, rounding parity with the table cells, the closest-first sort, and the THRESHOLD-exact boundary (raw gap of exactly 5 counts as inside). New compare-agreement CSS card mirrors compare-why with a band-green left border so "common ground" reads green at a glance and the three discovery cards (verdict / Why? / Agree?) form a deliberate visual ladder.
-
Site Preview # State pages add a "tightest calm race" mirror — both polarities now carry a discovery line (iter 908)
Iter 907 shipped the positive-side "closest race in <state>" one-liner. Iter 908 closes the mirror with the negative side: every state page now also surfaces "The tightest calm race in Tennessee is Cleanest air, where Gatlinburg (95) edges out Chattanooga (89) by 6 points." New stateClosestNegativeRace helper applies the iter-907 algorithm to the NEGATIVE_PILLARS set with the calm/safe polarity flip (low raw = calmer = derived 100 − raw). Returns the same StatePillarRace shape so the render template branches only on which data source it reads. Pillar label resolves through NEGATIVE_BOARDS so a reader sees "Cleanest air" rather than the raw "air_quality" key. 6 new guard tests pin the polarity flip explicitly (leaderScore must equal 100 − raw, not raw), the smallest-derived-gap selection, the rounding contract on derived scores, the calmest-leader identification, and the single-city / no-data null returns. Distinct "tightest calm race" vocabulary (vs positive's "closest race") so the two lines read as related-but-different stats, not duplicate paragraphs.
-
Site Preview # Every state page now opens with a one-line "closest race" discovery stat (iter 907)
The /best leaderboard hub already carries two derived discovery lines (iter 905) that pull readers straight to the most-contested positive and negative boards site-wide. The same pattern now runs at the state level: every multi-city state page surfaces a single line — "The closest race in Tennessee is Live Music, where Nashville (87) edges out Memphis (84) by 3 points." — between the calm-leaders block and the state map. A new stateClosestRace helper picks the positive pillar whose in-state top-2 cities have the smallest rounded gap, using the iter-905 round-once-then-display contract so a 0.4-raw spread reads as the honest "tied at 93" instead of "edges out by 0 points". 7 guard tests pin: smallest-gap selection across pillars, leader-as-higher-score, the tie path (gap===0 yields both names populated for "tied at" prose), the rounding contract, the missing- pillar skip, and the single-city / no-positive-data null returns.
-
Site Preview # State pages gain a "Calmest corners of <state>" companion to the existing pillar-leaders list (iter 906)
Every multi-city state page already opens its body with a "Where <state> shines" pillar-leaders block — top 5 POSITIVE pillars by state-mean, each naming the in-state city that leads that board. The negative side was missing the same affordance: a reader visiting /state/tennessee could see Nashville leads Eats, but had no surface that said "Gatlinburg is the calmest TN city for traffic". A new stateNegativeLeadersByPillar helper computes the mirror — top 5 NEGATIVE pillars by DERIVED state-mean (100 − raw, so higher = calmer), each annotated with the lowest-raw in-state city. The state page renders it as a "Calmest corners of <state>" section between the positive leaders and the state map, reusing the .state-leaders CSS class and linking each pillar to its /best/<negative-slug> board via NEGATIVE_BOARDS. Same StatePillarLeader shape both sides, so a render-layer template swap stays trivial. 11 guard tests pin the polarity flip — leaderScore must be DERIVED not raw, sort must be by derived-mean descending (calmest pillar first), single-city states still return [].
-
Site Preview # /best hub Safest & calmest section now carries its own "tightest race" discovery line (iter 905)
The /best leaderboard hub already surfaced a single "The closest race is Eats, where Gatlinburg edges out Stowe by a single point" line in the page hero (iter 901) — a derived stat that pulls a reader straight to the most-contested positive board. The same computation now runs against the negative side too: the Safest & calmest section opens with "The tightest race here is Cities with the cheapest night out, where Bismarck and Chicago are tied at 92". Two sections, two discovery hooks, mirror-symmetric. negativeBoards gained runnerScore/runnerName tracking (sorted ASC by raw negative so the runner is genuinely the second-safest, not just the second cities-bundle row); both lines use the same round-once-then-display approach so a raw gap of 0.4 between 93.2 and 92.8 never reads as "edges out by 0 points". A guard test pins both lines, both vocabularies (positive "closest race" vs negative "tightest race here"), and that every named score is an integer in [0, 100].
-
Site Preview # /methodology gains a "Jump to factor" chip strip above each factor list (iter 904)
The /methodology page is ~600 lines top-to-bottom and the factor definition section carries 23 list items (16 positive + 7 negative). A reader hunting one specific factor — "what does Crime score actually measure?" — used to scroll the section row-by-row. Each factor list now opens with a horizontal chip strip ("Jump to: Eats · Drinks · Coffee · …") that anchors straight to that factor's id="factor-{key}" landing (the iter-903 anchors). The chip vocabulary is the same one used in /best/<pillar>'s "Every leaderboard" nav, so the visual language is consistent across the two navigation surfaces a reader will hit when moving between leaderboard and methodology. A guard test pins chip-count = factor-count on both sides — adding a pillar without the chip (or removing one without retiring the chip) trips CI before it ships.
-
Site Preview # Every leaderboard ships a "How it is measured" callout above the ranking (iter 903)
Each /best/<pillar> page used to cite its data source in a single line of prose at the BOTTOM of the page, below 130+ ranked city rows. A reader landing cold from a homepage card had to scroll the entire leaderboard to learn what was being measured. The source citation now lives in a "How {pillar} is measured" callout between the page lede and the stat-strip — named data source plus a "Full methodology →" deep-link to a per-factor anchor on /methodology and a /glossary cross-link. Two changes ship together: /methodology now carries id="factor-{key}" on every factor list item (23 new anchors), so the callout's deep-link lands the reader on the exact factor they were reading about. A new guard test pins both sides — every non-deferred board ships the callout, every callout's href resolves to a real anchor, and every pillar key has the matching id on /methodology.
-
Site Preview # Leaderboard hub /best now shows the leader's score on every card (iter 901)
The /best leaderboard hub is the index page for every "best cities for X" board — 16 positive-pillar boards (Eats, Drinks, Live Music, …) and 7 negative-pillar boards (Safest, Lightest Traffic, Cleanest Air, …). Each card showed "Led by City, ST" with no anchor for the score. Now every card carries a tabular-numerals chip with the leader's calibrated score next to the city name: positive boards show the raw saturated score (e.g. Eats: "Led by Gatlinburg, TN 93"); negative boards show the same 100 − raw "safety score" / "clean-air score" flip the leaderboard pages use (e.g. Safest: "Led by Jackson, WY 100"). Twin of iters 899/900 on the homepage discovery section — the same enrichment now lands at the /best landing surface, the natural funnel before the per-pillar leaderboard pages.
-
Site Preview # Homepage Calm & Easy pills now name the leader city + derived score (iter 900)
The "Calm & Easy" row on the homepage is a 7-pill cluster, one per negative-pillar leaderboard (Safest cities, Lightest traffic, Cleanest air, Lowest disaster risk, Mildest weather, Most affordable, Cheapest night out). The pills were label-only links — a reader saw the category name and an arrow, but had no anchor for what " leader" meant or how strong the top city is. Each pill now names the leading city ("Jackson, WY" for Safest, "Telluride, CO" for Lightest traffic, "Bend, OR" for Cleanest air) and shows the derived "safety score" / "clean-air score" / etc. as a small tabular-numerals chip — the same 100 − raw flip that the /best/[slug] leaderboard pages use to present a negative pillar as a "higher is better" metric. Twin of iter 899 on the positive-pillar cat-cards: same enrichment pattern ("Led by X, ST {chip}") applied to the inverse half of the same homepage section, so both the positive and the negative discovery surfaces speak in magnitude-aware terms. New cat-leader-score.test.ts grows from 4 → 8 tests with four new pins: ≥5 calm-list pills carry a leader + chip; every derived score is an integer in [0, 100]; every leader matches the "City, ST" form; deferred-board pills render "coming soon" with no leader/chip surface.
-
Site Preview # Homepage "Find your kind of fun" cards now show the leader city's score (iter 899)
The homepage "Find your kind of fun" section is a 23-card grid — one per positive pillar — that names the leading city for that pillar ("Led by Gatlinburg, TN" for Eats, "Led by Stowe, VT" for Drinks). The leader name was a labels-only credit: a reader could tell which city topped the category but had no anchor for the magnitude — is Gatlinburg-on-Eats a runaway national best or a narrow lead over a dozen close challengers? The line now inlines the leader's calibrated 0-100 score next to the city name as a small tabular-numerals chip, so the homepage tells a magnitude-aware story at a glance: "Led by Gatlinburg, TN 93" for Eats, "Led by Stowe, VT 93" for Drinks, "Led by Honolulu, HI 86" for Beaches. The chip uses the --color-canvas surface so it sits as a quiet companion to the city name (the bolded primary) rather than competing for attention. Deferred-pillar cards continue to render the existing "Coming soon" italic treatment unchanged — only cards with a real leader get the enrichment. Twin of iter 897/898 on the city hero side: the same pattern of replacing a name-only credit with a name + magnitude pair now spans both the homepage discovery surface and the city hero.
-
Site Preview # City hero "Watch for elevated…" line now shows the negative score next to each pillar (iter 898)
Twin of iter 897 for the negative-pillar side. The complementary "Watch for elevated Crime and Economic Strain." line beneath the personality summary read identically for a city whose negatives sit just above the 70-of-100 floor and one whose negatives are national-worst at 95: both surfaced the same blunt warning with no sense of severity. Now Memphis opens "Watch for elevated Crime (92) and Economic Strain (89)." (severe), Anchorage opens "Watch for elevated Going-Out Costs (82) and Crime (77)." (concerning but comparatively moderate) — same shape, very different magnitudes, now visible at a glance. Personality-line.test.ts grows to 6 tests with two new pins: every rendered watch-for line must match the iter-898 shape (one or two pillars, each with a parenthesized integer score), and every parenthesized score must sit in [70, 100] — the 70 floor is the NEGATIVE_THRESHOLD constant in [slug].astro, so a future tweak to that floor without re-syncing the test trips CI before silently changing the rendered output.
-
Site Preview # City hero "Strongest on…" line now shows the score next to each factor (iter 897)
Every city page opens with a one-sentence personality summary — "Strongest on Live Music, Eats and Drinks." — naming the three positive factors on which that city scores highest. The list was labels only, so a reader couldn't tell whether a city's #1 strength was a national-best 95 or a middling 55: both read identically. The line now inlines the calibrated 0-100 score next to each factor name — Charleston opens with "Strongest on Sports (92), Culture (90) and Drinks (87)." (clearly an exceptional triple), while Anchorage opens with "Strongest on Outdoors (84), Coffee (75) and Bike & Micromobility (68)." — same shape, very different magnitudes, and now visible at a glance. The same enrichment lands in the <meta name="description"> string the city page derives from this line, so Google's SERP snippet carries the magnitudes too — not just the on-page hero. Pinned by a new personality-line.test.ts (4 tests) that walks every built /city/[slug]/index.html and asserts: every hero line carries three integer scores in parens, every parenthesized score sits in [0, 100], and the Nashville SERP description carries the same enriched line — so a regex refactor can't silently drop the scores.
-
Site Preview # State pages now plot every in-state city on a map (iter 896)
Twin of iter 895. The new "{State} on the map" block sits between the per-pillar leaders and the ranked city grid, showing every scored city in the state as a colour-banded dot on the state outline. Where the ranked list answers "which cities are the best?" and the leaders block answers "which city for which factor?", the map answers "where are these cities?" — a question a sortable list can never show. Top-1 in-state draws at r=9 so the leader pops out geographically; the rest at r=6. Dot fills come from the same CATEGORY_BANDS palette the homepage spread bar and pillar leaderboards use, so the visual story is consistent across surfaces (green Exceptional → red Quiet). The map is driven by a new reusable <StateMap> component that filters us-states.geo.json to the one state's feature and runs the outline + every city through one fitAlbers projection — so dots and land share a coordinate system by construction and a city can never float off the outline. Gated on N≥2 (single-city states like Alaska render nothing — a one-dot map has no context). 5 new unit tests in state-map.test.ts walk every built /state/[slug]/index.html and assert: a map renders on every multi-city state, no map on single-city states, exactly one circle per city, every fill traces to a `var(--color-cat-*)` token (never hardcoded hex), and the two-radius hierarchy holds.
-
Site Preview # State pages now name the in-state leader for each top factor (iter 895)
Until now, /state/[state] told a reader two things: the state-wide average across the 23 factors, and a sorted leaderboard of every city in the state. What it did NOT do was answer the actual navigation question a reader arrives with — "I'm visiting Tennessee, which city has the live music?" — without forcing them to cross-reference the national /best/[pillar] page and mentally filter to TN. The new "Best in {State} for…" block sits between the state-wide pillar bands and the city leaderboard, listing the top-5 positive pillars by state-mean (the same signal the state's "shines on" tagline uses) and naming the single in-state city that leads on each. On /state/tennessee a visitor sees at a glance that Nashville leads on Live Music (87) and Star Power goes to Memphis (80), with the pillar label deep-linking to /best/[pillar] and the city name to /city/[slug]. The block is driven by a new pure helper (lib/state-leaders.ts → stateLeadersByPillar) that gates on cities.length ≥ 2 (single-city states like Alaska show nothing — a "leader" of N=1 says nothing) and skips deferred pillars (there are none today, but the gate keeps the block honest if a future fetcher rotation re-introduces one). 10 new unit tests in state-leaders.test.ts pin the helper end to end: empty/single-city returns [], n=0 honoured, correct leader identification, sort-by-state-mean order, n-cap behaviour, arithmetic mean correctness, missing-pillar cities skipped, and pillars-no-data omitted entirely.
-
Site Preview # Mobile city pages: "Limited data" badge is now a tight chip, not a full-width line (iter 894)
On a 390px-wide phone the new "Limited data" badge from iter 888 was rendering as an unstyled inline link that wrapped to its own 309px-wide line under the pillar stats — instead of the small muted pill the design called for. The cause: the badge is built by the client script after hydration, so Astro's scoped CSS (which is keyed off a data-astro-cid attribute the server stamps on rendered elements) never reached it. The fix moves the .pillar-thin-badge rules into the same <style is:global> block the similar-city cards already live in, prefixed with .city so they cannot leak. A new city-page-global-css.test.ts guard test pins all four client-built classes (.similar-card, .similar-score, .similar-name, .pillar-thin-badge) to the global block so a future "tidy this rule next to its siblings" trips CI before reverting to the silent defect.
-
Site Preview # Every score-displaying circle is now sized for "100" (iter 893)
A site-wide audit extended the iter 891 homepage champion-circle fix to every other score disc on the site. The pillar-leaderboard podium (/best/[pillar]) was the second offender — its 4rem disc paired with the 1.875rem --text-2xl numerals gave a ratio of 2.13, just under the 2.20 rule of thumb we now follow for tabular-nums numerals. A perfect pillar score of 100 on a top-3 podium card had the same overflow shape as Boston's 100 on the homepage. The podium disc is now 4.25rem with inline padding (ratio 2.27); the homepage, state pages, and the two embed badges all already cleared the bar and pin in place. A new score-circle-ratio.test.ts enumerates every score-displaying disc, resolves its width and font-size declarations (CSS vars resolved against tokens.css), and asserts the ratio cannot fall below 2.20 — so a future tidy-up that shrinks a disc or adds a new score circle without first wiring it through the registry trips CI before users notice.
-
Site Preview # Brand lockup no longer squishes the logo-mark into a square (iter 892)
The header (32 × 32) and footer (28 × 28) brand-mark <img> tags declared a square footprint, but the source PNG `/brand/logo-mark-128.png` is 128 × 97 — a ~1.32:1 wide illustration. The browser stretched the natural shape into a square, vertically squishing the mark on every page of the site. Now the dims are 42 × 32 (header) and 37 × 28 (footer) — same heights, but width rescaled to match the source. A new guard test in baselayout-assets.test.ts pins both <img> declarations to a half-pixel-tolerant ratio so a future tidy-up to "round" 32 × 32 numbers trips CI instead of regressing the squish. The font and wordmark are unchanged.
-
Site Preview # Homepage champion-score circle fits the perfect "100" (iter 891)
The #1 city banner on the homepage advertises its Fun Score in a coloured disc next to the city name. The disc was sized 5rem with the 2.75rem --text-3xl numerals inside it — a ratio of 1.82, which works for two digits but lets a perfect "100" overflow past the ring on a tied-for-#1 city like Boston. The disc is now 6.25rem (ratio 2.27, matching the smaller rank cards down the page) with a small inline padding so the three digits sit comfortably inside the ring instead of kissing the border. Caught by a sharp-eyed reader on the live homepage; the smaller .card-score on the same page and on /state/* was already big enough so this is a one-class fix.
-
Site Preview # /faq closes the loop on "Limited data" (iter 890) — the missing-data answer now names the city-page badge and links to its glossary entry
Iter 888 shipped the badge and iter 889 gave it a glossary entry, but the /faq "What happens if a single city is missing data for a single factor?" answer was the obvious place for a reader to land first when they wonder what the affordance means in plain English — and it did not yet name the badge. The answer now explicitly cites the "Limited data" chip and links to /glossary#limited-data so a reader scanning the FAQ can recognise the same disclosure they will see on a city page. The structurally similar deferred-pillar link stays alongside it — Limited data is per-cell, deferred pillars are whole-factor; the paragraph now teaches the distinction by example.
-
Site Preview # /glossary entry for "Limited data" (iter 889) — defines the new affordance and the city-page badge now links to it
Iter 888 shipped the Limited data badge on /city/[slug] but the badge only had a hover tooltip. The new glossary entry gives the term a first-class definition page reachable from anywhere on the site, and the badge upgrades from a tooltip-only span to an actual anchor that lands the reader on /glossary#limited-data. The definition spells out the distinction from a deferred pillar (whole factor on hold across all cities) vs Limited data (per-city, per-pillar) so the two honesty signals don't get conflated. Cross-link points back at the deferred-pillar entry from iter 740.
-
Site Preview # City page: limited-data disclosure (iter 888) — pillars whose published score sits on raw=0 now carry a "Limited data" badge so a reader can tell the city sat at the bottom of the distribution by default rather than from a low measurement; bar opacity nudges down to 0.7 to match; the composite Fun Score is untouched; new lib/thin-data.ts helper is exercised across every published city bundle by a new guard test
Until now, an active pillar whose underlying source returned nothing rendered exactly like a pillar that had been measured at a low value: a real-looking 0-100 score and a national-rank line. The score itself is honest (the percentile path lands a zero-input city at the bottom of the distribution), but the *reason* — "no data" rather than "low data" — was invisible. The new Limited data badge surfaces that distinction without changing the score or composite math. The badge is a small muted pill in the existing stats line; the row bar steps down to opacity 0.7 (deferred pillars are stronger at 0.35, so the visual order reads thin-data < real-data < deferred). The disclosure is driven by lib/thin-data.ts (`isThinDataPillar` / `thinDataPillars`) which a new guard test exercises against every published city bundle, so the affordance turns red the day it stops being warranted — exactly when raw=0 disappears site-wide.
-
Site Preview # /compare "why" line (iter 886) — head-to-head comparisons now ship a plain-language driver sentence under the verdict: "Austin beats Denver by 20 points — biggest lifts are Live Music (90 vs 60), lower Crime (20 vs 45), and Eats (88 vs 71)"; weights pulled from the registry so the heaviest factors surface first, deferred pillars skipped, negative pillars framed with a "lower" prefix so polarity is unambiguous
The existing verdict (shipped iter 225) names the leader and counts how many factors they win, but it doesn't answer the next question every reader asks: *what drove the gap?* Iter 886 adds a `compareWhy()` helper that surfaces the top-3 weighted contributors to the Fun Score delta between exactly two cities, then renders them as a single sentence under the verdict card. Each driver names the pillar and the leader-vs-trailer raw scores (e.g. `Live Music (90 vs 60)`), so the reader sees both the *which* and the *how much* without leaving the page. Negative-polarity pillars (less is better — crime, traffic, going-out cost, …) get a "lower" prefix so the framing reads naturally without making the reader remember polarity. The line is gated on a 3-raw-point minimum on the strongest driver — when a gap is broad and spread thin, the existing "ahead on N of M factors" verdict already says what needs saying, and an opinionated "biggest lifts are …" would mislead. Drivers sort by weight × raw delta, not raw delta alone, so a 10-point edge on crime (weight 0.24) outranks a 10-point edge on bike-share (weight 0.03) — the line tells the *Fun Score* story, not the raw-score story. Eight new unit tests in compare.test.ts pin the helper end to end: polarity flip, deferred-pillar skip, weighted sort, the meaningful-gap floor, orientation when the leader is listed second, singular/plural copy, Oxford-comma joining, and the 3-driver cap.
-
Site Preview # Brand tier refinement (iter 885) — `--color-brand` deepened from saturated `#ff6b35` to `#ea580c` (terracotta), `--color-brand-strong` from `#c2410c` to `#9a3412`; same orange family, less neon, reads as designed brand instead of default startup-orange; embed badges, OG generator and theme-color meta all flipped in lockstep with the tokens
The original brand orange (#ff6b35) was bright and slightly cartoonish — fine for a prototype, weaker for the trust signal we want on a B2B-flippable data product. Iter 885 deepens the whole brand tier one step into a more confident terracotta: `--color-brand` becomes `#ea580c`, `--color-brand-strong` (the AA-text tier) goes from `#c2410c` to `#9a3412`, and `--color-brand-strong-dark` follows to `#7c2d12`. Same orange family — the brand identity doesn't flip categories — but the saturation drops, so the brand reads less neon and more designed. The cream `--color-brand-soft` tint moves from `#fff3eb` to a cooler `#fff7ed`. Every downstream pin moves in lockstep: BaseLayout's `theme-color` meta, both embed badges (hover border + focus outline), the OG-card generator's `.bar` and `.badge`, and the two test fixtures that pin the hex values verbatim (fun-color-story.test.ts, og-image-parity.test.ts). The Fun=green semantic decision (iter 321/359) is untouched — the wordmark "Fun" stays green and the score-band ramp stays as it was.
-
Site Preview # Per-pillar maps on every /best/[pillar] leaderboard (iter 883) — new `<PillarMap>` Astro component reuses the existing Albers-USA composite projection (with AK + HI insets) to plot all 134 cities, coloured by their pillar score band; top-3 dots draw larger; guard test pins every non-deferred board to render the map with one dot per city
Every /best/[pillar] page already led with a podium + a ranked grid + a compact 5-band spread bar — useful, but flat. There was no way to see *where* the high-scoring cities cluster geographically without bouncing to /map and mentally filtering. Iter 883 ships a new reusable `<PillarMap>` component that lives above the podium on every non- deferred board: an SVG US map sized for the leaderboard frame, every city plotted as a dot, dot colour set from the same CATEGORY_BANDS palette the spread bar uses (so the leaderboard's spread story and the map's spread story can never disagree), and the top-3 ranked cities draw at the larger r=9 radius so the podium narrative pops out geographically. The component reuses `fitAlbersUsa` from lib/geo.ts (including the lower-left AK + HI insets from iter 432), so AK and HI cities land inside the frame instead of escaping it. A new guard test (pillar-map.test.ts) walks the dist tree, asserts every /best/[pillar] page renders either a `<figure class="pillar-map">` with one `<circle>` per city OR a `deferred-card` stub (and never both), and pins the dot fills to `var(--color-cat-*)` tokens so a future refactor can't silently flip the map to hardcoded hex. 23 pages benefit from one component.
-
Site Preview # /api page example-city consistency — every "/data/city/{slug}.json" reference now uses nashville-tn, matching the response body example block; new guard test asserts a single canonical slug across the page so a future split (curl uses Austin, response shows Nashville) trips on first CI run
A new visitor to /api hit a small but jarring inconsistency: the bundle response example showed `"slug": "nashville-tn"` and walked through Nashville's full record, while the Quickstart code samples (curl, Python, JS) called `/data/city/austin-tx.json` and the slug-field description quoted "austin-tx" as the example. Two different example cities reads as either a stale doc or a half-finished refresh — the exact trust signal the page exists to build. Iter 882 swaps every `austin-tx` reference on the page to `nashville-tn` so the response example, the curl command, the Python and JS samples and the slug description all walk the developer through the SAME city. A new guard test scans the rendered dist HTML for every `/data/city/{slug}.json` URL and asserts they share one canonical slug — a future engineer who updates one surface but forgets the other trips this test on first CI run instead of leaving the page mismatched in production. No data or algorithm change — just example-city alignment across one page.
-
Site Preview # Methodology page Nashville worked-example numbers (upside 56.77, friction 52.95, raw 35.59, fun 63) now derive from the live nashville-tn.json bundle at SSR time — completes the calibration centralisation; a Nashville recompute updates the prose, the formula steps and the closing word together
The /methodology worked example walked a reader from raw inputs to a published Fun Score using one city's actual numbers — but every arithmetic literal in that walkthrough (the upside 56.77, the friction 52.95, the drag product 21.18, the raw composite 35.59, the lifted intermediate 58.45, the final 63, and the closing English-word "Sixty-three") was hand-typed. The guard test pinned them against the live bundle so a Nashville drift would fail loudly — but the page itself would render the stale numbers until someone made the manual edit. Iter 881 swaps every literal for an SSR-time derivation off public/data/city/nashville-tn.json (`fs.readFileSync` at build time), so the prose, the formula display, the calibration arithmetic and the descriptor word ("Sixty-three", or whatever the live score rounds to next) all auto-update. The English-word descriptor map covers the full Fun band (50-69); a band crossover still requires an editor's eye, which the iter-874 guard test catches. Completes the 5-iter calibration centralisation arc (iters 877-881): every quantity on the methodology page that depends on the live algorithm or live data now derives from its source of truth — scoring.ts for the algorithm, the city bundle for the example. Published numbers are unchanged today; the surface is now self-refreshing.
-
Site Preview # Methodology page polarity counts ("16 positive factors", "7 negative factors") now derive from the pillar registry, and the openapi.json spelled-out variants are pinned by an extended SIZE_WORDS guard — a future re-classification that moves a pillar between polarities updates every surface from one edit
The /methodology page hardcoded three polarity-split counts: the positive section heading ("The 16 factors that lift a score"), the negative section heading ("The 7 factors that weigh a score down"), and the step-4 prose ("The 16 positive factor scores are blended … the 7 negative factor scores into…"). All three now read from POSITIVE_PILLARS.length / NEGATIVE_PILLARS.length via the existing {positiveCount} / {negativeCount} expressions one paragraph up. The iter-672 factor-count-drift sweep catches total-count drift ("18 factors" / "twelve pillars") but NOT polarity-split drift: 16 and 7 are the live polarity counts, not historical totals, so a future re-classification that flipped one pillar from positive to negative would silently leave the prose stating the wrong split. A new polarity-count-drift.test.ts scans every dist/.html file for the pattern `\d+ (positive|negative) factor(s)?` and asserts the number matches the registry — covers the methodology page today, catches any page that picks up the same defect tomorrow. The openapi.json prose spells the numbers out ("All sixteen positive factors…" / "All seven negative factors…"), so an extended SIZE_WORDS map in openapi.test.ts pins those strings against POSITIVE_PILLARS.length / NEGATIVE_PILLARS.length and inversely fails if a stale spelled-out count from a previous split is left behind. Published values are unchanged (16 positive, 7 negative, 23 total, zero deferred) — only the source-of-truth wiring tightened.
-
Site Preview # Methodology page output-range constants (floor = 5, span = 93) now derive from scoring.ts too — every calibration anchor + every formula coefficient on the methodology page is now derived, not hand-typed; the algorithm-version contract auto-refreshes the prose end-to-end
Final iter of the calibration-constant centralisation that began in iter 877. The §6 calibration formula on /methodology — clamp(round(5 + 93 × (raw − lo) / (hi − lo)), 0, 100) — hardcoded "5" (the CALIBRATION_FLOOR) and "93" (the output-range span). scoring.ts has exported these as CALIBRATION_FLOOR + CALIBRATION_SPAN since the Week-4 freeze, but the methodology prose typed them as bare literals. The formula step now reads `{floor} + {span} × (raw − {lo}) / {hi-lo}` with every term derived from a scoring.ts import. Naming nuance: scoring.ts's CALIBRATION_SPAN is the OUTPUT-range span (93, the multiplier), not the input-range span (HI − LO ≈ 51.47) the methodology walkthrough also needs — the page imports CALIBRATION_SPAN as OUTPUT_RANGE_SPAN and derives the input-range span locally to keep the two distinct. The iter-874 guard test imports the same constants and reproduces the calibration algorithm via them, so a future re-tune updates the prose, the symbolic formula, the Nashville arithmetic, AND the test in lockstep. Published values are unchanged (floor = 5, span = 93) — only the source of truth moved.
-
Site Preview # Methodology page drag coefficient (0.4) now derives from scoring.ts at build time too — completes the calibration-constant centralisation; a future drag re-tune updates the prose AND every Nashville formula step from a single edit
Twin of iter 877, scoped to the §5 drag coefficient. NEGATIVE_DRAG_COEFFICIENT was hardcoded as the literal "0.4" in three places on the methodology page: the prose ("a single drag coefficient of 0.4"), the symbolic formula step (upside − 0.4 × friction), and the Nashville arithmetic (56.77 − 0.4 × 52.95 = 56.77 − 21.18). All three now derive from the scoring.ts export — the prose displays NEGATIVE_DRAG_COEFFICIENT, the symbolic formula reads "upside − {drag} × friction", and the Nashville product (21.18) is computed from drag × 52.95 at SSR time. A future drag re-tune (which the algorithm version contract allows on a minor bump) updates the page automatically, and the iter-874 guard test imports the same constant — so the worked example, the prose, the formula and the test all move together. Published values are unchanged (drag = 0.4, product = 21.18) — only the source of truth moved.
-
Site Preview # Methodology page calibration anchors (lo = 3.24, hi = 54.71) now derive from scoring.ts at build time, not from hand-typed prose — a future calibration re-freeze updates the page automatically, and the iter-874 worked-example guard test now imports the same constants instead of duplicating them
The methodology page walks readers through the affine calibration that maps the raw composite onto the 0–100 Fun Score, citing the lo = 3.24 + hi = 54.71 anchors that the Week-4 freeze locked in. Those numbers were hand-typed into the prose AND the formula step (plus their span 51.47), and the iter 874 guard test that pinned the Nashville walkthrough hardcoded its own copies of all three. Three copies of the same constant is two too many: a future calibration re-freeze (which the algorithm version contract allows on a minor bump, with notice) would leave the page citing stale anchors and the test asserting against them — silently invalidating both the worked example and the audit trail that proves the example is reproducible. The methodology page now imports CALIBRATION_LO + CALIBRATION_HI from scoring.ts (the TS mirror of config.py's frozen constants, which the parity test already pins to the Python side), and the worked-example test imports the same exports — so a single edit to scoring.ts updates the prose, the formula, AND the guard test in lockstep. The published values are unchanged (lo = 3.24, hi = 54.71, span = 51.47) — only the source of truth moved.
-
API Preview # Enriched the /api Dataset structured data for Google Dataset Search — variableMeasured now ships a PropertyValue array (Fun Score plus every active pillar, each as its own indexable variable) and measurementTechnique points the crawler at /methodology so the algorithm narrative travels with every dataset listing
Iter 212 first added Dataset JSON-LD to /api, iter 528 wired up temporalCoverage from the live computed_at, and this iter completes the Dataset Search ranking-signal trifecta. variableMeasured had been a single string ("CityFunIndex Fun Score (0–100)") — a Dataset Search crawler reading it could only surface the catalog entry for the Fun Score itself, not for any of the 23 underlying factor variables a researcher might query for ("restaurants per capita by city", "live music density"). It now ships a PropertyValue array: one entry for the Fun Score plus one for every PILLAR in the registry, each with the label + blurb the city page already shows and the documented min/max range of 0–100. The array derives from PILLARS at SSR time, so a registry edit auto-propagates to the Dataset listing instead of requiring a hand-edit. measurementTechnique is new — Google Dataset Search reads it as the documented method behind the variables, and pointing it at /methodology lets the worked example + algorithm prose travel with every dataset surface (Google Dataset Search, Bing, academic catalog aggregators). A new api-dataset-jsonld.test.ts pulls the Dataset node out of the rendered /api HTML, asserts measurementTechnique resolves to /methodology, that variableMeasured is an array (not a string) covering Fun Score + every PILLAR, and that temporalCoverage is preserved alongside the new fields — so a future regression that drops a field or reverts to the single-string form fails the build instead of silently degrading dataset discoverability.
-
API Preview # Refreshed the /api page Nashville example blocks against the live bundle — the cities.json row shows score 63 (was 65), the city-detail block shows the live universal_positive 56.77 + eats raw 0.86 / percentile 48.87, and the new pillars.json example shows the live eats weight 0.09 + crime score 85.52. A new guard test reads the live bundle on every build and pins each example against it
Twin of iter 874's methodology worked-example fix, scoped to the /api page's three quick-reference code blocks ("what /data/cities.json looks like", "what /data/city/{slug}.json looks like", "what /data/pillars.json looks like"). These blocks are the integration reference a developer copies into a test fixture before they wire against the live endpoint. Iter 605 last refreshed them against the v2.0.3-dev Nashville bundle, and five un-defers since (v2.0.5-dev going_out_cost, v2.0.6-dev transit_quality, v2.0.7-dev higher_education, v2.0.8-dev Google Trends, v2.0.9-dev walkability) have shifted Nashville's universal_score from 65 to 63 along with several pillar values. Without the fix, the developer copies the example, fetches live Nashville, and the values diverge — undermining the trust signal /api exists to build. All three blocks now read with the live numbers (universal_score 63, universal_positive 56.77, universal_negative 52.95, raw_composite 35.59, eats raw 0.86 / percentile 48.87, crime score 85.52, eats weight 0.09, similar_cities head [charlotte-nc, indianapolis-in, atlanta-ga]) and api-nashville-example.test.ts reads the live nashville-tn.json bundle on every build and asserts the rendered HTML matches it verbatim — so a future drift fails the test instead of silently leaving stale numbers on the developer-onramp page.
-
Site Preview # Methodology page worked example re-walks against the live Nashville bundle — upside is now 56.77 (was 58.74), raw composite 35.59 (was 37.56), final Fun Score 63 (was 67). A new guard test reads the live bundle on every build, derives the same numbers via the published algorithm constants, and fails loud if the rendered HTML drifts away from them
The /methodology page walks a reader from raw inputs to a published Fun Score using one city's actual numbers — currently Nashville. Every recompute that moves Nashville's factor scores silently invalidates the walkthrough, and the methodology page is one of the top three acquirer-scrutiny pages on the site. Iter 605 first re-anchored it to Nashville after the v2.0.3-dev OSM overrides; five un-defers since (v2.0.5 going_out_cost, v2.0.6 transit_quality, v2.0.7 higher_education, v2.0.8 Google Trends, v2.0.9 walkability) have shifted Nashville from a Fun Score of 67 to 63. The walkthrough now reads with the live numbers: upside = 56.77 (was 58.74), friction = 52.95 (unchanged), raw composite = 56.77 − 0.4 × 52.95 = 35.59, final calibrated Fun Score = 63. The calibration anchors (lo = 3.24, hi = 54.71) are frozen constants set at the Week-4 freeze and stay put. A new methodology-worked-example.test.ts reads the live nashville-tn.json bundle on every build, derives the same six numbers via the published algorithm constants, and pins that the rendered dist HTML carries them verbatim — so a future Nashville drift fails the test instead of silently leaving a wrong worked example on the page.
-
API Preview # Refreshed the published OpenAPI 3.1 spec's example responses so the first numbers a developer copies into a test fixture match the live bundles — the /data/cities.json example now shows Austin at 71 + Nashville at 63 (the previous example, frozen against the v2.0.3-dev recompute, had those swapped at 62 + 69)
The /api/openapi.json example responses serve a specific job: a developer reading the spec copy-pastes the example as a test fixture, then runs an integration test against the live endpoint and asserts the shape matches. If the numbers in the example drift away from what the live endpoint actually returns, the developer gets a passing schema test but a failing values-comparison test with no obvious culprit. Iter 745 last refreshed the examples against the v2.0.3-dev recompute, and four un-defer iterations have shifted scores since (v2.0.5 going_out_cost, v2.0.6 transit_quality, v2.0.7 higher_education, v2.0.8 Google Trends, v2.0.9 walkability). The /data/cities.json `twoCities` example now reads Austin at 71 ("Very Fun") + Nashville at 63 ("Fun"), the actual live values. The /data/city/{slug}.json `austin` example's universal_positive + raw_composite fields are also re-rounded against the live numbers (60.34 + 39.95 vs the prior 60.09 + 39.70). The pillar-level example (eats, city_vibe) and the algorithm_version + computed_at fields were already in sync with the live v2.0.9-dev recompute and stayed put.
-
Site Preview # Press media kit + homepage hero + /business proof grid now lead with data coverage — the "at a glance" facts card shows 23/23 factors live, the hero tagline says so above the fold, and the B2B trust column gets a new "every factor, every city" item that names the placeholder mechanism competitors hide
Follow-on to the methodology + glossary + FAQ + homepage stat-strip sweep earlier today. The /press page is the single-purpose journalist + corp-dev landing surface, and it previously did not name the data-coverage trust signal at all — the "at a glance" facts strip listed cities, states, factor count and refresh cadence, but no reporter could see from the page alone whether any of those factors were being filled with placeholder values. A new "Data coverage" fact card now reads "23/23 — all factors live" with a tooltip naming the §6.5 placeholder it would otherwise have used, and a new quick-answer Q&A — "Are any factors missing data?" — gives reporters a quotable paragraph naming the per-cell gap-fill mechanism (raw=0, percentile=50, score=50) and pointing out that competing indexes do not publish their coverage stats at all. The homepage hero "tagline-trust" paragraph — the second sentence under the H1, the line every first-time visitor reads — now adds "All 23 factors are currently live, with no placeholder fills" between the "measured, not guessed" promise and the methodology CTA, so the milestone surfaces above the fold rather than waiting for the iter-868 at-a-glance sub-marker. The /business B2B funnel — the page corp-dev visitors land on when they want to put the Fun Score on their own product — gets a new "Every factor, every city — 23/23 live" item in its proof grid (alongside the existing "methodology anyone can audit" + "versioned schema" trust items), naming the §6.5 placeholder competitors hide so a partner deck inherits the defensible coverage line out of the box. Every claim branches on DEFERRED_PILLARS.size, so a future fetcher rotation that re-introduces a deferred pillar swaps all four surfaces back to the heads-up framing automatically.
-
Site Preview # Every one of the 23 factors now ships with a real measurement — the methodology page, glossary, and FAQ now say so affirmatively when zero are deferred
Iter 793 cleared the last deferred pillar (walkability) and brought the DEFERRED set to empty for the first time. The methodology page previously hid its "About deferred factors" aside when none qualified, and the glossary + FAQ entries for deferred pillars silently switched to generic prose. That under-sold a real trust signal: most competitor indexes (Walk Score, Niche, AreaVibes) do not disclose whether any of their factors are placeholder-filled at all, let alone tell you when the count drops to zero. The methodology page now carries an affirmative aside reading "Every one of the 23 factors above ships with a real measurement on every scored city. None are deferred…" — with a green accent rather than the original amber heads-up. The glossary "Deferred pillar" entry now links to "How the methodology handles missing data" rather than the misleading "See which factors are deferred" cta, and the FAQ replaces the omitted "why does this factor look constant" Q&A with a new "What happens if a single city is missing data for a single factor?" answer that names per-cell gap-fills as the only remaining mechanism. Every claim is derived at SSR time from DEFERRED_PILLARS.size, so the moment a future fetcher rotation ever re-introduces a deferred factor, all three surfaces swap back automatically.
-
Site Preview # City + state + pillar leaderboard ranks now show tied ranks honestly — T-prefixes and "tied with N other" tails surface multi-way ties wherever a rank is displayed
Companion to the homepage tie-fix earlier today. City pages now render their national rank as "T#1 of 134 in the US · tied with 6 other cities" when the score is shared, and the state-rank pill carries the same T prefix. /state/[state] leaderboards switched from array-index ranks (which mislabel co-leaders as #2, #3) to competition ranks with a T prefix on shared scores. /best/[pillar] pillar leaderboards do the same on both the top-three podium (T-1st / T-2nd / T-3rd when a pillar score is shared) and the 4+ rank grid. Both the SSR markup and the client-side rank rebuild script use the same logic, so a stale-build hydration cannot drift the visible rank off the live JSON. The state-page leader prose now reads "X shares the top of {State} with N other cities" when multiple cities share the in-state max — currently visible on /state/florida, where Key West and Tampa both sit at 74. The methodology page now documents how ties arise (clamp+round at the 98th-percentile calibration anchor) and the glossary carries a new "tied rank" entry that journalists and API clients can cite. The /personalize re-ranker — which lets a visitor re-weight the 23 pillars and watch the city list re-sort live — was the last surface still mis-labelling co-leaders from array index; it now shares the same competition-rank + T-prefix convention so two cities that tie on the user's weights surface as T#1 with a "tied with N other cities" tooltip rather than an arbitrary #1 / #2 split.
-
Site Preview # Homepage now names tied #1 cities honestly — seven cities currently tie at score 100, and the champion spotlight + grid ranks now say so instead of crowning an arbitrary winner
Rank-normalization clips at 100, so multiple cities can sit at the ceiling at once — today seven cities (Boston, Cincinnati, Madison, Pittsburgh, Portland ME, San Francisco, Seattle) share the top score. The homepage champion banner previously read "America's most fun city · #1 of 134 cities" no matter how many cities were tied, and the grid below labelled #2-#7 as if they were strictly below #1. Both now reflect reality: the champion says "One of America's 7 most fun cities · tied for #1 of 134 cities (7-way tie at 100)", and any tied rank in the grid is prefixed with T (T#1, T#8, T#11) the way standings tables do. Anyone landing on the homepage now sees that the top-tier cities are genuinely co-equal rather than a precision the data does not actually support.
-
Site Preview # Homepage SERP meta description now leads with the competitor differentiator — Google snippets for "cityfunindex" or "best US cities" now arrive pre-positioned against Walk Score / AreaVibes / Niche
The SERP snippet that shows on the homepage Google result was previously a factual breakdown ("23 factors, 16 upsides, 7 frictions, every score traceable to its source"). Accurate, but it doesn't help a searcher who arrived from a "city ranking" or "best US cities" query distinguish CityFunIndex from the indexes they already know. Rewrote it to lead with the differentiator: 23 factors, more than Walk Score / AreaVibes / Niche, every weight published, every score sourced. Same ~155-character SERP envelope; competitive positioning now lands before the click.
-
Site Preview # /about — "The question other rankings skip" now names the rankings it's skipping past (Walk Score, Niche, AreaVibes) so corp-dev and journalist readers anchor on real competitors rather than the abstract "other lists"
/about was the only consumer-facing surface still using vague "other rankings" framing without naming who the competitors actually are. Rewrote the opening section to lead with three concrete competitor one-liners (Walk Score = walkability, Niche = K-12 + demographics, AreaVibes = 7 livability categories) before pivoting to what CityFunIndex measures. Also closed the section with the factor-count differentiator (23 factors, more than any comparable US index) so the "what is this" page now lands all three competitive-positioning beats in the first 100 words: who they compete with, what those competitors measure instead, and how CityFunIndex is structurally wider. Threads /about into the same competitor-positioning theme now live on the homepage, /methodology, /business, /pricing, /faq and /press.
-
Site Preview # Homepage now surfaces the AI City Report (Pro plan) — the 23-factor data isn't just a free-to-browse score, it's the substrate for a written, sourced dossier and "Find My City" from a plain-English description
The "Put the Fun Score to work" section previously held two cards (API for developers, license for platforms). A visitor who wanted the data read for them — written for the bulk of "I'm thinking about moving" researchers, not just developers and listing sites — had no signal that capability existed until they reached /pricing. Added a third "Read for you — the AI City Report" card to the same grid (auto-fit, no layout break) that names the three Pro tools (AI City Report, Find My City, relocation briefs), its monthly price, and links straight to the Pro tier on /pricing. The header line now reads "embed it, build on it, license it for your own product, or have it read for you" — same structure, but the LLM-powered tier is now first-class on the homepage instead of buried.
-
Site Preview # Press kit now arms journalists with the competitor differentiator — boilerplate paragraph names Walk Score / AreaVibes / Niche / BestPlaces factor counts so the paste-ready quote already positions CityFunIndex
Journalists writing "best cities for X" stories almost always cite Walk Score, AreaVibes or Niche somewhere in the piece. The /press boilerplate used to ship the 23-factor count standalone — accurate but not contextualized, so a journalist had to do the comparison research themselves to figure out whether 23 was a lot or a little. Enriched the boilerplate paragraph with the comparison inline (Walk Score 1, AreaVibes 7, Niche ~8, BestPlaces.net ~10) and added a new "vs. other city indexes" row to the at-a-glance facts panel that spells out the same numbers as a pasteable stat. Press citations now land with the differentiator pre-formatted, no extra research required from the reporter.
-
Site Preview # FAQ now answers "How does this differ from Walk Score, AreaVibes or Niche?" — the single most-asked first-impression question now has a direct, sourced answer that funnels to the methodology comparison table
Closes a long-standing FAQ gap. Visitors who arrive having heard of Walk Score, Niche or AreaVibes invariably ask this — the answer currently lived only on /methodology and only after they scrolled past the bands table. Added a dedicated Q&A in the "About the Fun Score" section that answers it directly: each index scores a different question, CityFunIndex measures recreational quality across 23 factors (vs 1-10 in competitors), every weight is published, and every score is re-weightable. Links straight to the new comparison table for the full side-by-side. Also lands the answer in the FAQPage JSON-LD that already ships from /faq, so SERP rich-result snippets can surface it directly.
-
Site Preview # Pricing-page lede tightens the why-pay value prop — it now names what the other indexes don't publish (per-factor numbers, re-weightable scores, every city in one CSV) instead of a generic "raw numbers" reference
The /pricing hero lede previously read "the paid plans are for when you need the raw numbers underneath" — accurate, but doesn't answer the actual question a paying buyer asks: "what does this give me that Walk Score / AreaVibes / Niche don't already give me free?" Rewrote it to name the three concrete things behind the paywall: the raw 23-factor scores, the weights the reader can re-shuffle, and the full national bundle as one CSV — the things competitor indexes either don't publish or only sell through enterprise contact-sales.
-
Site Preview # Business page now flexes the 23-factor differentiator — the "One number, N factors" trust card spells out "1 to 10 inputs elsewhere" and the hero adds a direct link to the side-by-side comparison
The /business page already named PILLARS.length, but a corp-dev or BD evaluator who lands there cold needs to see the differentiation against the actual category names, not just the count. Enriched the "One number, N factors" proof card with the concrete claim — "The major livability indexes expose between 1 and 10 inputs; we expose 23, every weight published, and every score re-weightable to your reader's own priorities." Added a hero subline directly under the CTAs that deep-links to /methodology#compare-heading so an evaluator who wants the receipts is one click from the side-by-side table.
-
Site Preview # Homepage funnels visitors to the new competitor-comparison section — the "Fun Score in three steps" foot now offers a second link to "How this differs from Walk Score, AreaVibes & Niche"
Pairs with today's competitor-positioning table on /methodology. The homepage "how" section already linked to the full methodology, but a brand-new reader has no signal that this site has done the work to differentiate itself from the existing players. Added a second link next to "See the full methodology" that deep-links to /methodology#compare-heading so the side-by-side table is one click from the home page. Restyled the foot row as a flex group so the two links sit on the same baseline with a separator dot, wrapping cleanly on mobile.
-
Site Preview # Methodology page now positions CityFunIndex against the four major city indexes — Walk Score, AreaVibes, Niche, BestPlaces — with a side-by-side table of focus, factor count, methodology transparency and re-weightability
The /methodology page had a thorough explanation of how the Fun Score is built but no answer to the obvious question every new reader asks: "how is this different from Walk Score / Niche / AreaVibes?" Added a dedicated "How this differs from other city indexes" section between the bands table and the data sources, with a comparison table of the four established players plus CityFunIndex across four dimensions that a serious reader actually evaluates: what each index measures, how many public factors it exposes, whether its methodology is open, and whether the user can re-weight it. The framing is honest — we are scoring a different question (recreational quality, not livability) and the table makes the comparison explicit instead of leaving readers to guess at the difference. Closes a long-standing gap for B2B evaluators, journalists, and serious consumer readers.
-
Site Preview # Leaderboard JSON-LD now embeds each ranked city as a typed Place — every /best/{pillar} page exposes a Schema.org ItemList of Place objects with stable @id, name and url
The ItemList JSON-LD on every /best/{pillar} leaderboard previously wrapped each ranked city in a bare ListItem with position + url + name at the top level. SERP rich-results parsers (Google, Bing, Yandex, DuckDuckGo) understand the URL but cannot link it back to the broader Place graph for that city on its own /city/{slug} page, so the leaderboard and the city page tell two parallel stories about the same entity. Rewrote ItemListElement so each entry is { @type: ListItem, position, item: { @type: Place, @id: …#place, name, url } }. The @id matches the Place node on the city page, which lets crawlers stitch the 23 leaderboards + 134 city pages into one connected graph. Also added itemListOrder: ItemListOrderDescending so the rank order is machine-readable instead of inferred from position.
-
Site Preview # Embed badges ship clean HTML for partner iframes — the full and compact Fun Score badges drop the multi-line whitespace inside their score, city, band and meta spans
The /embed/{slug} and /embed/compact/{slug} badges are iframe-embedded on partner sites, which means every partner dev who inspects the badge source sees the literal HTML we ship. Both variants had the same chip-whitespace pattern as the city + leaderboard pages: the badge-score span, the badge-band (full) and badge-meta (compact) spans split their content across three indented lines, padding every rendered token with a stray leading + trailing space. Inlined the four affected spans across both files. Visual layout is unchanged; the source HTML a partner copy-pastes or scrapes is now crisp.
-
Site Preview # City-page breakdown chips ship clean HTML — the 23 pillar-label links and the "Cities like X" similar-card spans no longer pad their text with stray whitespace
Closed out the city-page surfaces in the iter-844-846 chip-whitespace sweep. Four remaining surfaces on src/pages/city/[slug].astro: the two pillar-label `<a>` chips (one for the positive group, one for the negative — together they render the 23 per-factor leaderboard links on every city page) and the two similar-card spans (similar-score badge + similar-name) that render the "Cities like X" block. All four split their content across three indented lines, padding the rendered text with leading + trailing whitespace inside the tag. Inlined every instance. Crawlers and screen readers now see the bare token; the visual layout is unchanged.
-
Site Preview # Leaderboard chips ship clean HTML — podium scores, rank-cards and the board-chip pills no longer carry padded whitespace inside their tags
Continued the iter-844 / iter-845 chip-cleanup pass across /best/* leaderboards. Seven inline elements on src/pages/best/[pillar].astro — the top-three podium's pillar-score and city-name spans, the rank-grid's rank-name spans, and the four board-chip pills (two for the "Best for…" rail and two for the "Safest & calmest" rail, current + non-current) — wrapped their text on three indented source lines. That preserves a leading and trailing space inside every chip's rendered HTML; harmless to a sighted user since the browser collapses inter-text whitespace, but visible in crawler snippet previews and in screen-reader output that read the literal text. Inlined all seven. The strengthened iter-845 guard now also covers `<p>{expr}</p>`; broadening the same rule to chip-class <span>/<a> shapes is the next natural step once the remaining inline surfaces (DataFreshness, PillarSlider, embed badges, anchor-link FAQ/glossary) get the same inline treatment.
-
Site Preview # City-page hero chips ship clean HTML — the personality and watch-for lines no longer carry leading/trailing whitespace inside their <p> tags
The two data-derived chips beneath every city's tagline — the "Strongest on …" personality line and the "Watch for elevated …" caution line — were rendered with the JSX expression on its own indented line, which preserved a leading and trailing space inside the <p> element on all 134 city pages. Browsers collapse that whitespace visually, but crawlers reading the raw HTML for snippet previews see " Strongest on … " — a noisier, less crisp signal than the bare sentence. Fixed by inlining the JSX expression on the same line as the opening and closing tag, matching the sibling tagline's form. The iter-720 fix had caught the same defect class on five other surfaces; the iter-721/778 guard test stayed silent here because its regex targets double-space text content, not single-space JSX-whitespace bracketing — that gap is the next worthwhile audit pass.
-
Site Preview # Sitemap lastmod now tracks every page honestly — static pages stamp from their source-file mtime, data pages from the recompute timestamp
Previously, only data-derived URLs (/city/*, /state/*, /best/*) carried a lastmod in sitemap.xml; the sixteen static pages (/, /about, /pricing, /methodology, /faq, …) shipped without one because tagging them with the recompute timestamp would have lied — their copy doesn't move with the scores. The cost: when we did edit one of those pages' copy (the iter 833-834 SERP meta-description sweep, for instance), Google had no signal that anything had changed. The honest fix: stamp each static page with the mtime of its src/pages/<page>.astro source file. So /pricing's lastmod advances when /pricing's copy advances, but not when we rebuild the site or recompute scores; and /city/* still reflects the pillars.json computed_at. A new vitest pins the source-path tuple so a typo can't silently downgrade the page to "no lastmod."
-
Site Preview # SERP meta-description sweep round 2 — homepage, /business, /pricing and /about descriptions tightened to fit under Google's ~155 char truncation
Curl-audited every top-level page's rendered <meta name="description"> and found four longer than the ~155-character window Google shows before truncating to an ellipsis. Homepage (178 → 152), /business (220 → 154), /pricing (180 → 141) and /about (170 → 143) were rewritten — same factual claim, denser phrasing. Every other top-level page (/, /city/*, /state/*, /best/*, /api, /faq, /glossary, /changelog, /press, /methodology, /compare, /personalize) was already inside the window after iters 810-811's city + state sweep. Pairs with the existing data-derived description generators for city and state pages: every URL the crawler reaches now ships a complete, on-brand snippet in search results.
-
Site Preview # Closed the CSS-variable drift sweep — six more pages cleaned, two missing tokens added, guard test installed so the bug class cannot return silently
Round two of the design-token sweep started by the /faq, /glossary, /changelog and /press fix. Audited every var(--…) reference across every page, layout and component and diffed against the canonical tokens.css registry. Eight more drift sites were found and rewritten — /methodology, /compare, /personalize, /city/*, /best (index) and /404 had been pointing at phantom --color-surface-2, --color-surface-soft and --color-brand-soft names with hex fallbacks that approximated but did not match the real palette. tokens.css gains two genuinely-missing entries: --color-brand-soft (the soft peach used by the /404 recovery strip) and --space-7 (the 2.5rem gap that /pricing already wanted between --space-6 and --space-8). A new vitest, css-var-drift.test.ts, pins the invariant: every var(--name) in src/pages, src/layouts or src/components must be either defined in tokens.css, set inline (CSS decl or JS setProperty), or have a chained var(...) fallback. Phantom-name references with hex fallbacks now fail the test.
-
Site Preview # Fixed CSS-variable drift on /faq, /glossary, /changelog and /press — page-local styles now read real design tokens instead of phantom names with mismatched hex fallbacks
Four pages had been authored against CSS custom-property names that never existed in tokens.css (--surface-2, --border, --muted, --surface, --brand, --ink). The browser fell through to the hex fallback baked into each var() call, which were near-misses of the real palette (#f6f7f9 vs the real #f6f6f9 canvas, #525866 vs the real #6b6b7d muted, and so on). Result: those four pages rendered with subtly off-brand backgrounds, borders and label colours compared to the rest of the site — visible side-by-side, invisible on a single tab. Twenty-six occurrences across the four files were rewritten to use the canonical tokens (--color-canvas, --color-line, --color-muted, --color-surface, --color-brand, --color-ink). No visual change for /pricing, /, /city/*, /state/*, /best/* or /api, which were already on the right tokens.
-
Site Preview # City + /api pages now advertise their JSON twins via <link rel="alternate"> — integrators auto-discover the raw endpoint
A consumer browsing /city/austin-tx now also fetches a tiny <link rel="alternate" type="application/json"> in the HTML head that points at /data/city/austin-tx.json — Austin's raw bundle. The /api page mirrors the pattern, pointing at /api/openapi.json. Integrators, journalist crawlers, HTTP-snippet tools, and content-negotiation-aware clients can now discover each page's machine-readable twin without parsing the page body or reading the docs — the same affordance the JSON Feed exposes for /changelog already, now applied to per-page data. BaseLayout gains a new jsonDataHref + jsonDataTitle prop pair; three vitest pins assert that city + /api render the link with the right URL and per-page title, and that pages without a JSON twin (e.g. /about) do not emit a dead alternate link that would 404 for tools that try to follow it.
-
Site Preview # Sitemap now stamps every city/state/leaderboard URL with a lastmod — Google re-crawls when scores move
sitemap.xml used to ship as a flat URL list — every entry was just <loc>. Crawlers had no signal that a city page had been recomputed, so a moved score could sit stale in the SERP for weeks before the next routine re-crawl picked it up. The handler now reads pillars.json's computed_at field and stamps it as the lastmod on every URL whose content actually moves with the recompute: 134 city pages, 51 state pages, and every /best/<pillar> leaderboard. Static pages like /about and /privacy deliberately omit lastmod — their copy is independent of the data refresh, and a build-time stamp on them would lie. Timestamps are truncated to whole seconds because several historic crawlers silently drop a lastmod that carries milliseconds. Three new vitest pins assert the rule: city URLs carry the computed_at, /about does not, and every lastmod matches the whole-second ISO-8601 shape.
-
Site Preview # Homepage now publishes a top-cities ItemList — Google can render a ranked rich result
The homepage already carries WebSite + Organization JSON-LD so Google knows what the site is and who publishes it. What it did not carry was the ranked-list signal that lets Google show a top-N rich snippet under the SERP entry — the same shape every /best leaderboard already publishes. Today the homepage @graph gains an ItemList node listing the thirteen featured cities by rank (the champion + the twelve-card grid) with their @type ListItem position, url, and name. itemListOrder is ItemListOrderDescending so search engines read the list as a true ranking. No visible UI change; this is purely a search-engine-facing enrichment so the homepage finally matches the SEO maturity of /best/eats, /state/california, and the /best leaderboard index.
-
API Preview # OpenAPI spec info.version is now pinned to the live algorithm — silent six-version drift caught
The /api/openapi.json spec carries an `info.version` field that every SDK generator (openapi-generator, Speakeasy, Kiota, et al.) bakes into the package version of the SDK it scaffolds. We had let this drift to v2.0.3-dev while the live data shipped at v2.0.9-dev — six algorithm versions out of date. Today the value snaps back to v2.0.9-dev, and a new vitest guard reads pillars.json at test time and asserts info.version === bundle.algorithm_version, so any future algorithm bump that forgets the openapi.json refresh fails the test suite immediately rather than shipping a stale spec to downstream SDK consumers. Same pass also refreshed the openapi example city (Austin) score numbers + computed_at timestamps to current live values.
-
Site Preview # Homepage now carries the data-freshness chip — same provenance signal as every other surface
Every other consumer surface — city pages, leaderboards, state pages, /map, /compare, /personalize — already showed a "Rankings computed [date], algorithm vX.Y.Z" chip at the bottom so a reader could verify how recent the data is. The homepage was the last page missing it. Today the same DataFreshness component lands at the foot of the homepage with the live computed_at and the live algorithm_version baked into the published HTML. No new component, no design churn — just closing the provenance-signal gap on the highest-traffic page.
-
Site Preview # Homepage now opens with "the index in one sentence" — top, middle, and bottom city named
The homepage stat strip already named the totals (cities scored, states covered, factors per city, top Fun Score). Today we add a derived sentence below that strip that names three cities at once: the top of the index, the city sitting near the median, and the city anchoring the bottom — with their scores. A reader landing cold gets the full shape of the field in one line ("Across 134 cities, Boston (MA) leads at 100, Sacramento (CA) sits near the middle at 55, and Stockton (CA) anchors the calm end at 0.") before they scroll into the champion spotlight or the per-band cards. Three named cities + their scores in initial HTML for Google — and a narrative hook for skimming visitors. Fifth SSR sweep this week: same compute-then-render shape as the city hero, the "Cities like X" block, the state personality line, and the leaderboard openers.
-
Site Preview # The /best leaderboard index now surfaces the closest race in the hero
The /best page hero already lists every leaderboard with its #1 city. Today we add a derived line below the lede that names the single most contested factor across the whole index — the leaderboard where the leader and runner-up are closest: "The closest race is Eats, where Gatlinburg (93) edges out Stowe (92) by a single point." The line is computed at build time from the same pillars bundle that drives every individual board, picks the tightest gap among non-deferred boards, and reads with the right phrasing for a tie, a one-point edge, or a multi-point lead. It gives a reader a reason to click into a specific board on a page that previously only listed cards.
-
Site Preview # Leaderboard openers now name the top three and the tail in one sentence
Every /best/[pillar] leaderboard page already led with a single sentence naming the #1 city ("Nashville tops every city in the index for live music, scoring 99."). Today we enrich the same sentence to name the runner-up, third place, and the bottom of the leaderboard with their scores: "Nashville (99) leads, ahead of Austin (94) and New Orleans (92). Wichita anchors the bottom at 31 — a 68-point gap." Same hero spot, no new layout — three more named cities per page across 23 positive and 7 negative leaderboards (90 fresh named-entity references in initial HTML for Google to index). A skimming reader gets the full shape of the field — leader, contenders, and how wide the spread is — before they even reach the ranked table below. Fourth SSR sweep this week: same compute-then-render philosophy as the city hero, the "Cities like X" block, and the state-page personality line.
-
Site Preview # City breakdown openings now name the strongest and weakest factors in plain prose
Every city page's Fun-breakdown section already led with a short lede ("Every factor is scored 0–100 against the other cities in the index.") and then a chart of all 23 bars. Today we add a one-line summary in between: "Austin's strongest factors are Higher Education, Live Music and City Vibe. Its heaviest drawbacks are Air Pollution, Traffic and Weather Extremes." Three positives and three negatives, joined as "A, B and C" prose, derived at build time from the same pillars bundle that drives the chart. The line lands in initial HTML for Google instead of after JS execution — a denser data-derived summary on every city URL — and a reader who only reads the first two sentences of the section now leaves with the city's shape, not just its overall score. The previous client-side rendering path stays as a graceful-degradation fallback (skips automatically when SSR already populated). Third SSR sweep this week — same compute-then-render philosophy as the hero line and the "Cities like X" block.
-
Site Preview # State pages now open with what the state is actually strong on
Every multi-city state page already led with the top city and a stat strip — but nothing said what the *state* itself was strong on. Today we ship a per-state personality line directly under the leader note: California "shines on Climate, Walkability and Transit Quality." Tennessee "shines on Culture, Drinks and Events." Texas "shines on Higher Education, City Vibe and Climate." Each line averages the 16 active positive pillars across every city in the state and names the top three, so a reader gets the state's character in one glance before scrolling into the city list. Single-city states (Wyoming, DC) stay silent — the line would just repeat the one city's own personality line. Same compute-then-render philosophy as the city hero from last week. Each state URL now has unique data-derived prose Google can index, on top of the per-state meta description that shipped earlier today.
-
Site Preview # The "Cities like X" block on every city page is now crawlable
Every city page closes with a "Cities like {City}" block — three to five outbound links to the cities most similar to this one by pillar profile, computed by the Python similarity layer and shipped in each city's detail JSON. Until today that block was rendered client-side after the JS fetch, so Google's crawler skipped past an empty section and the outbound link equity went unclaimed. The same cards now server-render at build time directly from the per-city detail JSON, so 5 internal links per page × 134 cities = 670 fresh crawlable edges in the site graph for free. The client-side renderer stays in place as a graceful-degradation fallback (skips automatically when SSR already populated). Lower bounce on the consumer side, denser internal linking for SEO — same data, two clean wins.
-
Site Preview # City pages now ship a "Watch for…" line for the cities with real weaknesses
The city hero already opens with "Strongest on A, B and C." — the three highest-scoring positive pillars per city. Today we ship a complementary "Watch for elevated X." (or "X and Y.") line for the cities where one or two negative pillars score 70/100 or higher (the rough top-quartile of badness). Calm cities with no genuinely-bad negatives stay silent — the line is informative, not reflexively defensive. So Memphis opens with "Watch for elevated Crime and Economic Strain." Detroit with "Watch for elevated Economic Strain and Crime." Austin with "Watch for elevated Air Pollution." while a low-friction city like Jackson WY shows nothing at all. The directional noun is overridden for two pillars whose canonical label reads neutrally ("Air Quality" → "Air Pollution"; "Going-Out Cost" → "Going-Out Costs") so the prose stays grammatical. The data feeds the honesty signal: we don't just sell each city's strengths.
-
Site Preview # State pages now ship per-state meta descriptions — and a grammar bug is fixed
Every /state/{state} page used to advertise the same templated tail — "every California cities in the index, scored 0–100 on the same 23 factors." That clause carried a grammar bug too: `every` requires a singular noun, but the pluralised cityWord was substituted on both sides ("every California **cities**"). The description now reads "All 18 California cities on the CityFunIndex Fun Score, led by San Francisco at 100/100 — average 43/100." for multi-city states and "Wyoming's Jackson on the CityFunIndex Fun Score: 95/100." for the rare single-city states. Each state now has a unique data-derived snippet that names the leader and the state-wide average, so Google sees genuinely differentiated copy per URL and a click-bait number (the leader's score) appears before the line break.
-
Site Preview # City pages now ship per-city meta descriptions for richer Google snippets
Every /city/{slug} page used to advertise the same templated meta description tail — "See the full breakdown across N fun factors and N that wear a city down." — which meant 134 cities competed in Google with 134 near-identical snippets. The meta description now reads "{City}, {State} scores {N}/100 on the CityFunIndex Fun Score. Strongest on {top three positive pillars}." with the same line mirrored into og:description for Slack / Discord / X previews. So Austin's SERP snippet now leads with "Strongest on Bike & Micromobility, Live Music and Drinks." while Charleston's reads "Strongest on Sports, Culture and Drinks." — Google sees a unique snippet per URL, the snippet matches the kind of long-tail search queries we want ("best cities for live music"), and a social-card preview now hooks the click instead of repeating the generic blurb. Falls back cleanly to a band-only line when the build-time pillar bundle is unavailable.
-
Site Preview # Every city page now opens with a data-derived "Strongest on…" line
The hero on every city page used to lead with one of five generic taglines keyed to the category band — every "Fun" city read the same line, every "Quiet" city the same other one. With all 23 factors now scoring from real data, we can do better: a new "Strongest on X, Y and Z." line below the band tagline names the three factors on which the city scores highest. So Austin opens with "Strongest on Bike & Micromobility, Live Music and Drinks." DC with "Strongest on Transit Quality, Live Music and Bike & Micromobility." Charleston SC with "Strongest on Sports, Culture and Drinks." Each line is derived at build time from the city's own positive-pillar scores, so a reader lands on the page and immediately gets a specific read on *what* makes the city fun, not just *how* fun. The band tagline above stays put as the macro framing.
-
Site Preview # Coverage strip now reads "50 states + DC" — DC is a federal district, not a state
The homepage at-a-glance stat strip, the /press at-a-glance facts card, the /press paste-ready boilerplate, and the /api "API at a glance" hero row were all summing the cities.json `state` field as a single count, which produced "51 states" — wrong, because the District of Columbia is a federal district, not the 51st state. Every surface now reads "50 states" with a discreet "+ DC" sub-label when the index includes Washington, derived through a new jurisdictionLabel() helper in lib/states.ts so the four surfaces can never disagree again. The press boilerplate now reads "134 US cities across 50 states + DC", which is paste-ready and factually correct in print. The data.test.ts cardinality guard was reframed in the same shape so a future seed-set change still fails CI if the 50/1 split shifts.
-
Site Preview # Zero-deferred copy cleanup across /faq, /glossary, /api
With walkability shipped, DEFERRED_PILLARS is empty for the first time — every one of the 23 factors scores from real data. Three public pages still held copy that read fine when even one pillar was deferred but rendered awkwardly the moment the set went empty. The /faq "Why do some factors show the same score" Q is now dropped entirely when nothing is deferred, and the sibling "Is the data real" answer no longer teases a "handful still being measured" caveat that does not apply. The /glossary "Deferred pillar" entry now leads with "Today none of the 23 factors are deferred — all 23 carry real measurements" instead of the literal-template "0 of the 23 factors" that the old code path would have rendered. The /api data-dictionary aside swaps its "N of the 23 factors are still being measured — {list}" paragraph for "All 23 factors carry real measurements" when the deferred set is empty, with the explanatory links to /glossary and /methodology preserved. Three new vitest guards pin each of these contracts so a future regression that drops the empty-set conditional will fail the build instead of shipping the credibility-hit phrasing live.
-
Data Preview # Walkability shipped — every one of the 23 pillars now scores from real data (v2.0.9-dev)
Walkability was the last pillar still falling back to the cohort median gap-fill. The EPA National Walkability Index (NWI) — a 1-20 score per Census block group, derived from intersection density, transit proximity, employment + housing mix, and dwelling-unit density — was already the planned source, but its 220,000-block-group geodatabase was too large to fetch at runtime. The build now stages the SLD V3 against the 2020 Census Block Assignment Files offline, computing the population-weighted NWI across each city’s Census place once and shipping the per-city result as a small JSON cache inside the ingestion image. 133 of 134 seed cities resolve cleanly (99.3%, well above the §6.5 80% coverage floor); the one miss — billings-mt, whose place FIPS is absent from the Montana BAF — falls back to the cohort median per §6.5. Walkability is removed from DEFERRED_PILLARS in both the Python and the TypeScript registries; the 23-of-23 milestone now holds across the entire algorithm.
-
Data Preview # Google Trends search interest joins City Vibe — coverage layer for the long-tail expansion (v2.0.8-dev)
City Vibe rode on the GDELT news-tone signal (the Reddit subscriber + post-sentiment feeds are wired but stay deferred — Reddit’s free Data API is licensed for non-commercial use only). GDELT covers ~87% of the current 134-city seed today, but its coverage degrades for smaller cities — a planned expansion past 350 cities would drop GDELT below the §6.5 80% coverage floor and put the pillar at risk of being deferred again. Google Search indexes every city by name, including the ones that never appear in GDELT’s news corpus, so it is an orthogonal coverage layer rather than a stylistic substitute. The new google_trends source pulls each city’s 12-month normalized search interest in batches of four (anchored to a fixed reference keyword for cross-batch comparability), caps tail outliers at 5× the reference, and feeds the value into _measure_city_vibe alongside the GDELT news-tone signal. Any rate-limit or library failure (the pytrends client’s upstream repo was archived) degrades cleanly to a no-op — the city simply falls back to the GDELT signal. Algorithm version v2.0.8-dev. No algorithm change for the cities GDELT already covers; net coverage rises on the long tail.
-
Data Preview # Daily refresh schedule + recompute skip-when-unchanged gate
Two compute-cost tightenings now that every source is at a 7-day or longer cadence: the refresh job runs once a day at 06:00 UTC instead of every six hours (a 4× drop in scheduler invocations, every one of which used to land mostly on TTL no-ops), and the daily 09:00 UTC recompute now starts with a small probe — if no raw observation has landed since the last publish on this algorithm version, the recompute exits cleanly without running the resolve + score pipeline. On a steady-state day this saves the BigQuery slot-minutes of the full recompute; on a day where any source did refresh, the gate falls through and the recompute runs normally.
-
Data Preview # Slower refresh cadence — a quality-of-life index should not churn daily
Air-quality and weather sources (AirNow, OpenAQ, NOAA NWS) used to re-fetch every 6 hours, and the news + social-sentiment signal (GDELT) every 24 hours. That made the index nominally fresher but produced a noisy published score — a city should not move because of one Tuesday AQI reading. Those feeds now refresh quarterly (air/weather) and monthly (news/social), aligned with how long the underlying signals actually take to change in a way that matters. Crime, climate, and the rest were already monthly or annual. The FAQ has been updated to describe this honestly.
-
Data Preview # Refresh job flushes incrementally — long batches survive timeouts
Long per-city sources like OSM Overpass (134 cities × ~10 s/city ≈ 20-90 min) used to lose every row if the Cloud Run job-level timeout fired mid-batch, because the per-batch flush only ran after the last city completed. The refresh job now flushes accumulated rows to BigQuery raw_observations every 10 cities (and once more at end of batch) via a writer callback, so a timeout loses at most the in-flight chunk instead of the whole batch. raw_observations is append-only history and resolve_observations.sql picks the latest, so chunked writes are safe; the next run derives ledger state from the persisted rows and skips the cities that did land. No algorithm change.
-
Data Preview # BLS unemployment coverage closes — county-level LAUS fallback wired
Small tourism towns and a handful of mid-sized cities (Aspen, Telluride, Park City, Jackson, Stowe, Gatlinburg, Sedona, Honolulu, Louisville, Nashville, Billings) sit outside the BLS LAUS City/Town series and were missing an unemployment_rate reading — 11 of 134 cities. The bulk BLS fetcher now also builds the matching County-level LAUS series id (LAUCN<state><county>0000000003) for every city, prefers the sharper CT reading when present, and falls back to the CN series otherwise. Result: 100% unemployment coverage on the next BLS refresh cycle, no algorithm change.
-
Data Preview # Airport reach + day-trip catchment counts land in raw observations
Two diagnostic catchment counts now ship alongside every refresh: airport_count_iata (IATA-coded commercial airports within 80 km of the city centroid) and day_trip_count (OSM tourism POIs with a Wikidata link within 120 km — roughly a 90-minute drive). Both ride the existing OSM Overpass fetcher pattern, so coverage is global from day one with no new API key. The values land in raw_observations as top-level fields today; a future calibration shift promotes them to scored pillars (airport_reach, day_trips) once a city-level distribution is in hand. No algorithm change today — composite Fun Scores are byte-identical to the previous build.
-
Data Preview # Going-Out Cost un-deferred — 19-factor Fun Score with BLS regional CPI (v2.0.5-dev)
Going-Out Cost moved from the deferred set to a live, weighted negative factor in the composite Fun Score. The BLS regional CPI fetcher (food away from home, recreation services, transportation services) now has 100% coverage across all 134 cities — every city in a known Census region (Northeast / Midwest / South / West) gets its regional CPI reading, so the §4.1 coverage floor is cleared by a wide margin. The pillar reads as a four-tier signal today because the regional series covers all 134 cities uniformly within each region; metro-CBSA series exist for ~23 metros and can be wired later as a per-city bls_cpi_area_code override for finer granularity. Algorithm version v2.0.5-dev. Three factors still deferred (higher_education, transit_quality, walkability) — their fetchers are wired but coverage hasn't cleared the §4.1 floor yet.
-
Data Preview # Bike & micromobility un-deferred — first 18-factor Fun Score (v2.0.4-dev)
Bike & micromobility moved from the deferred set to a live, weighted factor in the composite Fun Score. With the expanded OSM cycleway tag set and GBFS multi-operator count from this morning, the §6.5 publish gate cleared cleanly: every city now ranks somewhere along a smooth distribution from 92.6 (the best bike-infrastructure city in the set) down to a single-digit floor for car-dependent cities with no cycleway tagging and no bikeshare operator. The 0.03 weight that this pillar carries comes from the §4 default vector — small, but enough to nudge comparisons between otherwise-tied cities. Algorithm version v2.0.4-dev.
-
Data Preview # Bike & micromobility: 8 new OSM tag matches + GBFS multi-operator resolution
Expanded the OSM cycleway tag set the pillar reads from 4 entries to 12 — added cycleway:left, cycleway:right, cycleway:both (each in lane and track variants), cycleway=shared_lane (sharrows), and bicycle_road=yes. Many US cities tag bike infrastructure as cycleway:left=lane on a parent highway way rather than highway=cycleway on its own way, so the prior 4-tag set undercounted real infrastructure in those cities. Also switched the GBFS bikeshare half from a binary has-a-system flag to the COUNT of active operators, so a city with three systems (Lime + Lyft + a regional non-profit) outranks a city with one. These two changes broke the previously-degenerate distribution that was keeping bike_micromobility deferred at score=50 across all 134 cities.
-
API Preview # OpenAPI Category description now matches the live band thresholds
The /api/openapi.json Category schema claimed Fun started at 55, Some Fun at 40, and Quiet under 40 — left over from before iter 321 rebalanced both cutoffs (Fun now starts at 50, Some Fun at 30, Quiet under 30). Every SDK generated against the spec, plus the Swagger UI rendering at /api, taught consumers the wrong thresholds. The description is now updated to match the live CATEGORY_BANDS, and a new vitest derives the expected band names and lower-bound numbers from CATEGORY_BANDS itself so this can never silently diverge again. The Nashville value in the cities.json example was also refreshed to its current score (69, up from the stale 65 captured pre-recompute).
-
Site Preview # Deferred-pillar prose now reads "Will be measured from…" instead of "Measured from…"
A small honesty fix on every city page. The five factors still under construction (Bike & Micromobility, Going-Out Cost, Higher Education, Transit Quality, Walkability) carry a dash and a "Data coming soon" label in the breakdown — but the prose under each factor used to read "Measured from <source list>" as if the data were already flowing. That contradicted the dash. Deferred factors now read "Will be measured from <source list>" so the source attribution stays put for readers who want to know what feed is coming, but the tense matches reality. The methodology Sources section also drops a stale claim that the Going-Out Cost factor publishes off the BLS regional CPI today; BLS is live for unemployment (Economic Strain) only, with the CPI subindexes wired but deferred alongside the rest of that factor.
-
Site Preview # Unique-hangouts source expansion + source-attribution fixes
The OSM Overpass and Google Places venue-type lists for the Unique Hangouts factor were widened to cover karaoke boxes, escape rooms, amusement arcades, hackerspaces, miniature golf, trampoline parks, video arcades and comedy clubs alongside the existing bowling / amusement-center / internet-cafe terms. Methodology + glossary copy now reflects the actual pipeline — the City Vibe sources line was corrected to attribute the factor solely to the GDELT news-tone signal it actually reads from.
-
Data Preview # city_vibe pillar back online — 100% coverage across all 134 cities
The city_vibe pillar (GDELT news-tone signal) now publishes a real score for every one of the 134 cities, up from a partial-coverage state that left it deferred for the new-state expansion. The fix was a curated gdelt_aliases override per city (e.g. "City Of Birmingham", "City Of Manchester") that matches the way GDELT V2Locations actually records municipal entities. Score distribution: 22 cities ≥80, 56 in 50–79, 38 in 20–49, 18 below 20. Napa, Scottsdale, and Lincoln top the list; lower-tier cities are mostly mid-size metros whose news tone genuinely runs cooler than coastal/destination cities.
-
Data Preview # State coverage now spans all 50 + DC, and city + state pages name the major-league teams behind the sports pillar
Four visible changes shipped together. First, the seed set grew by 16 cities to close 13 state-coverage gaps (AL, AR, CT, DE, IA, ME, MS, MT, ND, NH, RI, SD, WV) — Birmingham + Montgomery, Little Rock, Bridgeport + Hartford, Wilmington, Des Moines, Portland ME, Jackson MS, Billings, Manchester NH, Fargo + Bismarck, Providence, Sioux Falls and Charleston WV all now have identity rows and will appear in the index on the next recompute. Second, every city page now carries an explicit "Pro sports in {city}" section listing its NFL, NBA, MLB, NHL and MLS teams by name (the sports pillar previously captured this only indirectly via event flow). Third, each /state/{state} page now shows a "Pro teams" stat in its summary strip — "11 in 5 cities" for Texas, hidden cleanly for small-market states without any franchise. Fourth, the JSON API now carries a pro_sports_teams field on every /data/city/{slug}.json bundle — an array of {league, team} objects, empty for cities without a major franchise. Documented in openapi.json + the /api data dictionary so the embedded consumer SDK and the website see the same shape. All four are curated from one CSV pinned to the web mirror by a parity test so the CSV, JSON bundles, and UI cannot drift.
-
Data Preview # Source-side wiring landed for four deferred pillars — going-out cost, transit quality, higher education and bike & micromobility
A source-extension batch wired real fetchers behind four of the five previously deferred pillars. Going-Out Cost now joins each city to its Census-region BLS regional CPI series (food away from home, recreation services, transportation services) — coarse at the regional level but real BLS data, with a CBSA-metro override path documented in refresh_job._city_cpi_area for future metros. Transit Quality now reads ACS B08301 transit-commute share as a one-call addition to the bulk ACS fetch — every city covered, no Transit.land dependency. Higher Education now reads ACS B01001 18-24 age share the same way; the IPEDS College Scorecard fetcher (Title-IV enrollment per county) remains optional and now correctly preserves None when DATA_GOV_API_KEY is absent rather than crashing on float(None). Bike and Micromobility now reads an OSM Overpass cycleway count appended to the existing per-pillar venue-count query — no new HTTP calls, the percentile-rank makes the count-vs-km units irrelevant. Pillar deferred state stays at five today because the published bundles still carry the gap-fill placeholder for those pillars — the next recompute will land real measurements and the four un-defer flips ship in the same release as the bundle refresh.
-
Data Preview # Methodology page and pillar sources now name the real data feeds — OpenStreetMap is the venue spine, not Google Places
A second-pass blurb-vs-reality audit caught seven more factor source lines that misattributed where their numbers came from. Eats, Drinks, Coffee, Culture and Unique Hangouts all listed Google Places as the count source — but Google Places only contributes a rating-quality multiplier; the actual venue counts come from OpenStreetMap. Outdoors claimed "parks and trails" while the OSM query reads parks, nature reserves, playgrounds and beaches (no trail tag). Live Music credited Bandsintown as a feed, but Bandsintown sits unwired in the ingestion pipeline — the only event sources for that pillar are Ticketmaster and SeatGeek. Sports omitted SeatGeek (which also supplies sports events) and named only "stadiums" when the OSM query also counts sports centres and fitness centres. Unique Hangouts cited "climbing gyms and mini-golf" that the OSM query does not match — the real tags are escape rooms, arcades, trampoline parks and karaoke venues. The methodology page data-sources block was similarly stale: it dropped the Bandsintown mention, recharacterised Google Places as a quality-rating signal, and fixed the OpenStreetMap line to reference beaches instead of trails. Source attribution should now match what each `_measure_<pillar>` function actually reads from the observations dict.
-
Data Preview # Pillar descriptions now match what the algorithm actually reads (air quality, city vibe, star power)
A blurb-vs-reality audit caught three factor descriptions that overpromised or misnamed their data sources. Air Quality previously read "days of unhealthy air" — but the pipeline averages EPA AirNow and OpenAQ AQI across a 168-hour trailing window, not a days-count, which means a city with strong seasonal pollution patterns (California Central Valley winter inversions, Phoenix-area summer ozone) can read better than its long-term reputation on a clean week. City Vibe claimed OpenStreetMap street density alongside the news + social-sentiment signal — but the OSM street-density signal was never wired in; the score is the GDELT news + social-sentiment aggregate alone. Star Power cited "Ticketmaster headline tours" — but the touring-act signal is SeatGeek headliner popularity, blended with Wikidata notable residents. All three blurbs and source lines on the city breakdown and the methodology data-sources block now describe the real measurement.
-
Data Preview # Albuquerque NM venue counts repaired — was scoring zero on every density factor
Albuquerque had no usable OpenStreetMap admin-boundary relation: the city itself appears only as a point node, and the only "Albuquerque" polygon at admin_level=9 is a district in Brazil that incorrectly matched the global name search. With the area lookup returning empty, every OSM venue-density query (eats, coffee, drinks, unique_hangouts, culture, outdoors) came back as zero, ranking Albuquerque at the percentile floor and dragging its Fun Score down to 0. Shipped an osm_name override pointing Albuquerque at the surrounding Bernalillo County boundary at admin_level=6 (which contains the city plus a few small suburbs) — the next refresh will report 559 restaurants, 180 cafes, and 88 bars/pubs in the metro, putting Albuquerque back into a real percentile rank for every venue-density factor.
-
Data Preview # City Vibe pillar now live — every city shows a real news-tone score
The city_vibe pillar (1 of the 23 factors) was previously deferred and ship-as-50 across every city because GDELT V2Locations coverage only reached ~78% of the seed-city set, below the 80% §4.1 floor. The iter-741 alias pass brought GDELT into 102/118 cities; lowering the per-city min doc-count from 10 to 5 (still enough articles for a percentile-rank to mean something, and the per-pillar rank treatment further smooths small-sample noise) brings usable coverage to 97/118 = 82.2%, clearing the publish gate. The pillar is no longer in the deferred set: every leaderboard, personalize slider, and city page now shows a real-data city_vibe score — Washington DC at 9 (very negative national news tone), Charleston at 100, the median city around 50. The composite Fun Score weights have been redistributed: every positive pillar's effective weight rebalances proportionally now that there are 4 deferred positives instead of 5.
-
Data Preview # GDELT coverage doubled for 21 cities + §6.5 cap now env-tunable
GDELT V2Locations tags US cities in non-canonical forms — Washington DC as "Washington, Washington" (its largest US-city block at 80k weekly mentions), Kansas City mostly as "Kansas City, Kansas", and ~14 cities including New York, Austin, and Buffalo with a "City Of X" prefix. Without the alias, every per-city pattern in gdelt.build_location_pattern matched zero rows and city_vibe gap-filled to the §6.5 fallback. The identity loader's gdelt_aliases column now covers all 21 affected cities, including a new comma-state-suffix override so DC's tag can co-exist with its true "District of Columbia" state. Coverage climbed from 81/118 to 102/118 cities — DC alone went from 0 to 366k weekly docs. Companion change: MAX_PILLAR_DELTA is now env-overridable on the cityfunindex-recompute Cloud Run job, so a legitimate one-off calibration shift (a city's AQI moving past the 5-point cap, a new GDELT signal coming online) can be absorbed without halting the publish or permanently widening the day-over-day anomaly guard.
-
Data Preview # Deferred-pillar fetcher modules + identity overrides wired into Job A
Five new source modules now plug into refresh_job.py — gbfs (MobilityData GBFS catalog, bike_micromobility), ipeds (Department of Education College Scorecard, higher_education), transit_land (Transit.land v2 stops, transit_quality), epa_walkability (EPA National Walkability Index, walkability), and ntd (FTA National Transit Database, transit_quality). gbfs hits a public GitHub-hosted CSV with no auth and is live today; ipeds and transit_land are wired and start emitting the moment DATA_GOV_API_KEY and TRANSIT_LAND_API_KEY (both free, registration-only) land in Secret Manager; epa_walkability and ntd remain stubs that raise DryRunBlocked until their annual CSV caches + Census-UA / place-BG crosswalks are staged. Two new identity columns — wikidata_qid (manual override for cities Wikidata search misses, e.g. Norfolk Q49233) and gdelt_aliases (pipe-separated alt names like "Saint Louis" for St. Louis) — flow through fetch_context into the per-city context. Same I/O-boundary contract every other fetcher follows: DRY_RUN-guarded, optional API keys via secret_store, no live data without explicit configuration.
-
Site Preview # /about page coverage claim now reads live from cities.json
The /about page "Where things stand" section said "The full index will cover the largest US cities alongside a curated set of smaller places" — future tense, even though the index already covers 118 cities live. Now reads "The index covers {cityCount} US cities today" with cityCount derived from loadCitiesIndex().length at SSR. Same registry-derive pattern the consumer prose sweep established for pillar counts: when cities.json grows, every consumer surface updates automatically.
-
Site Preview # Final 22 hardcoded factor counts on homepage + B2B funnel now derive from the registry
The previous iters cleaned up library code and a handful of pages. This sweep closes the seven remaining consumer-facing pages that still carried hardcoded "23 factor" / "sixteen" / "seven" prose: the homepage (meta description, "Measure 23 factors" headline, "The 23 factors combine", JSON download blurb), /business (meta description, "Feed all 23 factors", "One number, twenty-three factors" proof title, "identical 23 factors" claim, license-card prose, feature bullet), /glossary (Fun Score definition, Factor entry "23 factors total: 16 positive and 7 negative", Positive/Negative pillar entries, Pillar entry "23 pillar scores"), /methodology (meta description, "same 23 factors" claim, "Sixteen factors describe what makes a city fun; seven describe what wears it down"), /pricing (Service description JSON-LD), /api (pillar data dictionary, Dataset JSON-LD description, city endpoint blurb). All 22 surfaces now read PILLARS.length / POSITIVE_PILLARS.length / NEGATIVE_PILLARS.length at SSR — adding or removing a pillar updates every consumer surface with zero string edits anywhere.
-
Site Preview # /api pillars endpoint now derives city count from cities.json
The last hardcoded "118 cities" in active page prose was on /api inside the /data/pillars.json endpoint description ("re-rank all 118 cities without another request"). Now derives from cityCount at SSR time, mirroring the page hero stat that already used the same derivation. The "N cities" drift sweep in city-count-drift.test.ts continues to police any future drift.
-
Site Preview # Organization JSON-LD + billing tier features now derive from the registry
The final two lib/ modules that quoted hardcoded factor counts now derive from PILLARS.length: structured-data.ts (Organization description quoted on every page as JSON-LD: "0–100 Fun Score built from 23 factors") and billing.ts (four tier feature lines: "All 23 factor scores", "23 category leaderboards", and two "Score history and trend lines for all 23 factors" mentions across Free, Data, and API plans). With this iteration the registry-derive sweep is complete across every customer-facing surface — page prose, JSON-LD, paid-tier feature lists, OpenAPI schema disclosure, and freshness counts all read from one source of truth.
-
Site Preview # About, API, FAQ, state + leaderboard counts now derive from the registry
Final sweep of hardcoded factor counts across consumer pages. Updated 13 prose surfaces on /about (rating built from "23 factors — sixteen that make a city fun and seven that wear it down"), /api (CSV download name, page lede, endpoint aside, pillars-endpoint description, weight-vector field doc), /faq (four answers spanning algorithm, personalize, compare, and data-use), /state/[state] (description + single-city lede), and /best (leaderboard index hub copy). Every surface now reads PILLARS.length, POSITIVE_PILLARS.length, or NEGATIVE_PILLARS.length at SSR time — combined with the previous iters this closes the "free-text factor count" defect class across every consumer-facing page.
-
Site Preview # Personalize + compare factor counts now derive from the registry
Three remaining prose surfaces still quoted a hardcoded "23 factors" count: the /personalize meta description and lede ("slide the 23 CityFunIndex pillars" / "Slide the 23 factors to your own taste") and the /compare meta description ("all 23 factors, head to head"). All three now derive at SSR time from POSITIVE_PILLARS.length + NEGATIVE_PILLARS.length on /personalize and PILLARS.length on /compare. With this change, every consumer page that mentions a factor count reads it from the same registry — the prose can no longer drift away from what the algorithm actually computes.
-
Site Preview # City page factor counts now derive from the pillar registry
Six prose surfaces on the per-city page carried hardcoded factor counts: the meta description ("16 fun factors and 7 that wear a city down"), the hero lede ("16 things … and 7 that wear it down"), the "How the Fun Score adds up" section lede ("sixteen things … minus seven"), the upside composite sub-label ("16 things that lift the score"), the math-note total ("Those 23 contributions"), and the explore-card on /personalize ("Slide the 23 factors"). All six now derive at SSR time from POSITIVE_PILLARS.length, NEGATIVE_PILLARS.length, and their sum — so the prose stays in lock step with the registry as factors come online or retire. Word-form "sixteen"/"seven" was switched to digit form for consistency with the other four surfaces.
-
Site Preview # Methodology + FAQ factor counts now derive from the pillar registry
The /methodology page carried three hardcoded "23 factors" / "16 positive factors" / "7 negative factors" prose claims in the "How a score is built" stages and the worked-example lede; the /faq "why do some factors show the same score" answer hardcoded "23" alongside its already-dynamic deferred-count derivation. All four counts now resolve at SSR time from POSITIVE_PILLARS.length, NEGATIVE_PILLARS.length and PILLARS.length, so adding or retiring a factor flows the new total into the prose automatically. The factor-count-drift sweep (iter 364) catches the digit-form regression separately, but the source files are now drift-free at the source.
-
API Preview # OpenAPI deferred-set check derives from DEFERRED_PILLARS, with inverse + count guards
The openapi.test.ts guard pinning that PillarScore.description names each deferred pillar key used to walk a hardcoded array of six identifiers — meaning an un-deferral that removes a key from DEFERRED_PILLARS would leave the test still requiring it, forcing a three-file dance (pillars.ts + openapi.json + this test) instead of two. The check now iterates DEFERRED_PILLARS directly. Two new guards were added in the same test: an inverse check that fails if a no-longer-deferred pillar key appears in the description (catches the forgot-to-strip case), and a numeric-word check that pins the leading "Six of the 23 factors" phrase against DEFERRED_PILLARS.size and PILLARS.length (catches off-by-one drift on either side).
-
Site Preview # Pillar leaderboards now derive city + deferred counts from the registry
The /best/[pillar] leaderboard pages carried four hardcoded "one of six factors still being measured" strings (in lede + metaDescription for both positive and negative deferred boards) plus a hardcoded "A ranking would be 118 cities tied at 50" in the deferred-card body. Both counts now derive at build time — the deferred count from DEFERRED_PILLARS.size, the city count from the actual ranked array length — so the prose stays current automatically when a feed comes online or the sample set grows. The city-count drift sweep test (iter 388) was extended to also walk best/[pillar].astro so the next developer who reintroduces a hardcoded literal trips the regression guard on first CI run, not on first reader complaint.
-
API Preview # API page example payloads now derive algorithm_version + computed_at from live bundle
The /api documentation showed two example JSON payloads (the city detail and the pillars bundle) with hardcoded algorithm_version: "v2.0.3-dev" and computed_at: "2026-05-24T15:08:37Z" literals. They matched live data today but would silently drift the moment the next recompute bumped the version — a developer reading the docs would see a stale version that doesn't appear in any actual API response. Both fields now derive from loadPillarsBundle() at SSR time, so the example payloads stay synchronised with the data they describe. The Dataset temporalCoverage field (iter 562) already used this pattern; this iteration extends it to the example bodies as well.
-
API Preview # API deferred-pillar disclosure now reflects the partial-data reality
The /api data dictionary and the openapi.json PillarScore schema both used to claim that deferred pillars ship "raw: 0, score: 50 for every record" — an over-strong statement. In practice, city_vibe ships real measurements for the subset of cities that have GDELT coverage (Charleston SC, for one, carries raw: 42.5, score: 29.06), even though the pillar as a whole is still deferred because coverage is below the §4.1 publish gate. A developer reading the old disclosure and hitting Charleston's city_vibe = 29.06 would have flagged the API as inconsistent. The revised wording explains that the gap-fill placeholder applies where the fetcher has no data, that a subset of cities may carry real measurements for the same deferred factor, and that the factor is excluded from public leaderboards regardless. Three surfaces updated in lockstep: /api dictionary, openapi.json PillarScore description, and openapi.json PillarsBundleCity positive/negative descriptions.
-
Site Preview # Glossary "Deferred pillar" entry now enumerates the six factors by name
The /glossary entry for "Deferred pillar" used to say "6 of the 23 factors are deferred" without naming which six — so a first-time visitor who clicked through the deferred-pillar deep-link from /best, /faq, /methodology, /compare, /personalize or any city page got a precise count but no list. The definition now enumerates the deferred set inline ("(bike & micromobility, city vibe, going-out cost, higher education, transit quality and walkability)") right after the count, mirroring the iter-751 /faq treatment. List is derived from DEFERRED_PILLARS at build time, so it shrinks automatically as each feed comes online, and a new guard test in deferred-ui.test.ts fails CI if a regression strips a label from either page.
-
Site Preview # Guard test pins /personalize per-slider deferred treatment to DEFERRED_PILLARS
PillarSlider.astro emits two affordances on deferred sliders that an unsuspecting refactor could quietly drop: a data-deferred="true" attribute on the wrapper (drives the dimmed visual treatment) and a pz-slider-deferred hint span (renders the "Data coming soon" micro-label). Both already shipped, but nothing pinned the contract against the canonical DEFERRED_PILLARS set — a future refactor that inlined the slider or moved the deferred branch elsewhere could silently un-dim some sliders and remove the hint without breaking any build. A new test in deferred-ui.test.ts now counts both attribute and hint occurrences on the built /personalize page and requires each to equal DEFERRED_PILLARS.size, so the per-slider treatment can never drift from the deferred set again.
-
Site Preview # FAQ deferred-pillar answer now enumerates the six factors by name
The /faq answer about "Why do some factors show the same score for every city?" used to say "Six of the 23 factors are still being measured" without naming which six. A reader who got curious had to cross-reference /glossary or hunt through the pillar grid to figure out which factors were affected. The answer now enumerates the deferred set inline — "(bike & micromobility, city vibe, going-out cost, higher education, transit quality and walkability)" — so the gap is visible in the same paragraph that names it. The list is derived from DEFERRED_PILLARS at build time, so it shrinks automatically as each feed comes online and a new guard test in deferred-ui.test.ts fails CI if a regression strips a label.
-
Site Preview # Best-of leaderboard index footnote explains the "Data coming soon" cards
A reader landing on /best and scanning a grid of 23 cards used to see six tiles that read "Data coming soon" in muted italics, with no in-page explanation of why those particular factors had no leader, whether they were broken, or when they would arrive. A small footnote now sits between the negative-board section and the foot nav whenever any pillars are deferred. It names the canonical term, cross-links to /glossary#deferred-pillar, and explains that the underlying leaderboards would tie every city at the §6.5 placeholder until each feed comes online. Same disclosure pattern as the iter-743 methodology aside, iter-745 compare footnote, iter-746 city footnote and iter-748 personalize footnote — every public surface that exposes a deferred-pillar artifact now names the term and links the definition.
-
Site Preview # Personalize page footnote names the deferred sliders
The /personalize sliders had a per-slider hint that said "Data coming soon — this slider doesn't reorder cities yet, only shifts every score by the same amount" beneath each deferred pillar. That was accurate but unmoored: a reader had no canonical name for the category, no link out to a definition, and no page-level reassurance that the personalized ranking they were watching scroll was still sound. A new page-level footnote now sits below the results region whenever any pillars are deferred. It names the canonical term, cross-links to /glossary#deferred-pillar, and explains that deferred sliders apply a uniform weight to every city (so they shift every score by the same amount and don't reorder anyone) rather than silently doing nothing. Same disclosure pattern as the iter-743 methodology aside, iter-745 compare footnote and iter-746 city footnote — every surface that exposes a deferred-pillar artifact now names the term and links the definition.
-
Site Preview # City page now explains its dashed pillar rows
A reader landing on /city/washington-dc and seeing dashes in the walkability, transit, higher-education, bike, going-out cost and city-vibe rows used to have no in-page explanation of what the dashes meant — the runtime had already swapped the dashes in and labelled the stats row "Data coming soon," but a first-time visitor had no idea why those rows looked different from the others, or whether the composite Fun Score at the top of the page was reliable when six of the input pillars were missing values. A small footnote now sits below the breakdown groups whenever any pillars are deferred. It names the canonical term, links the term to /glossary#deferred-pillar, and reassures the reader that the headline Fun Score above the breakdown renormalises around the deferred set and is not biased by the dashes below. Same disclosure pattern as the iter-743 methodology aside and iter-745 compare footnote — the city page was the last surface where a deferred dash had no in-page answer.
-
Site Preview # Compare page footnote names the deferred-pillar dashes
A reader putting four cities head-to-head on /compare and seeing four em-dashes in the walkability row used to have no in-page explanation — the row was correctly muted and labelled "Data coming soon," but a visitor scanning the table for the first time had no way to know why those rows were dashes instead of numbers, or whether the comparison itself was reliable. A static footnote now sits beneath the comparison table whenever any pillars are still deferred. It names the canonical term "deferred pillars," links to /glossary#deferred-pillar, and reassures the reader that the composite Fun Score (the top row of the table) renormalises around the deferred set and is not biased by the dashes below. The footnote is gated on DEFERRED_PILLARS.size > 0 so it disappears automatically once every fetcher is live.
-
Site Preview # FAQ now answers "why does my city show the same score for some factors?"
A reader scanning a city page who sees walkability=50 next to walkability=50 next to walkability=50 would reasonably ask whether the index is broken. The /faq did not answer that question — the closest entry ("Is the data real right now?") said "every published score is a real measurement" and stopped. The new entry, in the data-and-the-api group, explains that six factors are still being measured and ship with the §6.5 placeholder, links the term "deferred pillars" to the canonical /glossary#deferred-pillar entry, and clarifies that the composite Fun Score is not biased because the placeholder cancels across the ranking. The "Is the data real right now?" answer now ends with a one-line pointer into the new entry so a reader can follow the chain. The FAQPage JSON-LD picks up the new Q/A automatically, so the disclosure is machine-readable too.
-
Site Preview # Methodology and "Data coming soon" stubs link to the deferred-pillar glossary entry
A reader on /methodology who sees the "· coming soon" suffix next to a factor used to have no in-page answer to "what does that mean?" — they had to click through to a /best/<slug> stub to find out, and that stub itself never named the canonical term. Both surfaces now cross-link to /glossary#deferred-pillar. The /methodology page renders a derived footnote below the factor lists ("6 factors above are marked coming soon — those are deferred pillars") and the /best/<deferred-slug> stub opens with "This factor is a deferred pillar." Both link the term to the canonical definition. The footnote count derives from PILLARS so it shrinks as fetchers come online — the prose never lies about how much is deferred.
-
Site Preview # Pricing FAQ no longer over-promises "23 leaderboards"
The /pricing "Is CityFunIndex really free?" answer claimed "all 23 leaderboards" were free, but six of the 23 factors are still deferred and ship as "Data coming soon" stub pages on /best/<slug> rather than as real ranked leaderboards. The wording is now "every leaderboard," which stays accurate as the deferred set shrinks and the leaderboard count grows. Same defect class as the /api PillarScore and /glossary "Deferred pillar" disclosures shipped today — every public surface that mentions a count should derive it from the registry, not pin a number that drifts as the pipeline matures.
-
Site Preview # Glossary defines "Deferred pillar" so the term has a canonical meaning
The term "deferred" surfaces in five places across the site — the /methodology factor table, the /best/<slug> stub pages, the city-page pillar breakdown, the /api PillarScore docs and the openapi.json PillarScore schema — but until today /glossary had no canonical entry a reader could resolve the term against. The new entry, alphabetized between Calibration and Factor, defines a deferred pillar as a factor whose data fetcher is wired but not yet running across every city, explains the §6.5 gap-fill placeholder mechanic and notes the composite Fun Score is unaffected. The deferred and effective counts are derived from DEFERRED_PILLARS / EFFECTIVE_PILLARS so the prose stays in sync as fetchers come online and the deferred set shrinks. The /api disclosure aside now has a canonical glossary URL to point at: /glossary#deferred-pillar.
-
API Preview # OpenAPI spec discloses the six deferred-pillar placeholder values
PillarScore in /api/openapi.json was documented as a non-null object with raw/percentile/score fields, but six pillars currently ship raw=0 and score=50 because their fetchers are wired but not live across every city. An SDK generated from that spec would lead a consumer to expect real measurements and the developer would hit walkability=50 across every city with no way to tell from the schema alone. The PillarScore schema description now names the six deferred pillar keys (bike_micromobility, city_vibe, going_out_cost, higher_education, transit_quality, walkability) and explains the §6.5 gap-fill. The positive{} and negative{} polarity descriptions in PillarsBundleCity carry the same disclosure for the raw-number bundle. An openapi.test.ts assertion now pins the disclosure so a future schema regen cannot silently drop the wording.
-
API Preview # /api now names the six deferred pillars so developers know what is real
A developer fetching /data/city/<slug>.json sees a PillarScore for every one of the 23 factors, but six of those scores are the §6.5 gap-fill placeholder (raw=0, score=50) — their fetchers are wired but not yet running across every city. Until today the /api page said "23 scored factors, each value is a PillarScore" without flagging the gap, so a developer hitting walkability=50 across every city would reasonably wonder if the API was broken. The PillarScore section now carries a disclosure aside that names the six deferred factors (Bike & Micromobility, City Vibe, Going-Out Cost, Higher Education, Transit Quality, Walkability), explains the placeholder, and notes the composite Fun Score is unaffected because a constant offset across every city cancels out of every ranking. The list is derived from DEFERRED_PILLARS via the registry, so the disclosure shrinks automatically as fetchers come online.
-
Site Preview # Guard test pins the deferred-pillar UI contract against built HTML
Added a vitest that reads dist/best/<slug>/index.html for every DEFERRED_PILLAR and asserts the page renders the "Data coming soon" stub AND drops the schema.org ItemList JSON-LD — the same surfaces a just-shipped fix had to repair after a refactor left the negative-board branch hard-coding deferred:false. A symmetric check reads dist/best/<active-slug> and asserts the opposite (no stub copy, ItemList present) so a future regression that wrongly routes every board through the deferred branch also fails CI. The /methodology factor table contract is pinned by counting data-deferred="true" rows + factor-deferred spans + "coming soon" suffixes — each expected to equal the size of DEFERRED_PILLARS, so the count adapts automatically when a pillar fetcher comes online. No product change; the test simply makes the two recent UX repairs un-regressable.
-
Site Preview # Deferred-pillar stub now covers the negative "cheapest night out" board
The /best/cheapest-night-out leaderboard — the public face of the going_out_cost factor, which is still under construction — was previously serving a full 118-city ranking with podium, spread bar and ItemList JSON-LD, even though every city carried the §6.5 gap-fill placeholder of 50. The deferred-pillar guard that was shipped earlier this week covered every positive factor in the registry but skipped the seven negative boards because the negative branch in /best/[pillar].astro hard-coded deferred:false. The branch now reads from isDeferred(), so cheapest-night-out renders the same "Data coming soon" stub the other five deferred boards already do, and its schema.org ItemList is suppressed so Google Search is no longer told there is a real ranking on the page. A stale changelog reference to the non-existent /best/going-out-cost slug was fixed at the same time.
-
Site Preview # Fix double-space rendering between text and bold/link siblings
Several "Led by <city>" lines on the homepage and /best leaderboard index, plus prose runs on /methodology, /pricing and the deferred /best/[pillar] stub, were rendering with a stray double space — the `text{' '}\n<a>` JSX pattern produced "Led by Charleston, SC" with two spaces because Astro's JSX whitespace rule kept both the explicit space expression and the newline-collapsed whitespace between sibling nodes. Each of the five sites was rewritten to put the text and the inline element on the same source line, which is the only form Astro guarantees collapses to a single space. The defect was cosmetic, not a content change.
-
Site Preview # Personalize sliders + compare rows flag deferred factors
/personalize sliders for the six deferred factors are dimmed and carry a "Data coming soon — this slider doesn't reorder cities yet, only shifts every score by the same amount." note, so a reader who cranks Walkability to 5x weight can see why the ranking doesn't change. /compare renders the same six rows with an italic "Data coming soon" subtitle on the row label and "—" in every city cell, and bestPerPillar skips them so no city gets a spurious "Best" badge from the §6.5 gap-fill tie.
-
Site Preview # Deferred-pillar leaderboards now render a "coming soon" stub
The six leaderboards for factors still under construction — /best/walkability, /best/transit-quality, /best/bike-micromobility, /best/cheapest-night-out, /best/higher-education and /best/city-vibe — previously ranked every city by the §6.5 gap-fill placeholder, producing 118 cities tied at 50 in arbitrary slug order. Each of those boards now renders an explanatory "Data coming soon" card instead of the meaningless ranking, with chip nav still letting readers jump to the live leaderboards. The /best leaderboard index flags the same six cards as "Data coming soon" instead of citing a spurious leader, and the city-page row for each deferred factor drops the leaderboard link so a click cannot land on a hollow board. The homepage "Find your kind of fun" grid gets the same treatment — each deferred card now reads "Data coming soon" in place of the old "Led by <random tied city>" line — and the "Or somewhere calm and easy" chip row marks the "Cheapest night out" link with a "· coming soon" suffix so the deferred negative pillar is flagged before a click. The schema.org ItemList JSON-LD was also stripped from the deferred pages so Google Search isn't told there's a ranking that the page no longer surfaces.
-
Site Preview # City pages now label deferred pillars as "Data coming soon"
Six factors are still under construction — bike & micromobility, city vibe, going-out cost, higher education, transit quality and walkability. The city-page breakdown previously showed each of them as a real 50/100 score (the §6.5 renormalisation placeholder), which read like a real measurement. Each row is now visually dimmed and reads "Data coming soon" beneath the factor name, so a visitor can tell at a glance which factors are scored from data and which are placeholders. The composite Fun Score itself is unaffected — §6.5 already renormalises around deferred pillars.
-
Site Preview # Methodology worked example now uses live Nashville numbers and the frozen calibration anchors
The "A worked example, end-to-end" walkthrough on the Methodology page was carrying numbers from a pre-calibration draft — it followed Albuquerque through a calibration of lo=12 / hi=58, neither of which is true anymore: the live calibration anchors were re-frozen from the actual raw-composite distribution to lo=3.24 / hi=54.71, and Albuquerque's density pillars are currently unscored (OSM boundary edge case). The walkthrough now follows Nashville, TN end-to-end with numbers pulled straight from the live /data/city/nashville-tn.json bundle: raw eats density 0.85/k → percentile 47.01, upside 58.04, friction 54.23, raw composite 36.35, final Fun Score 65 (Fun band) — every step independently verifiable against the API.
-
Data Preview # Fixed Wikidata resolver for Washington, DC and New York City
The Wikidata city-resolver SPARQL was silently returning no QID for two of the most-notable-people cities in the country, so their star_power pillar gap-filled to a flat 50. Two independent root causes ship a two-line fix: (1) the resolver constrained the city's container to instance-of U.S. state (Q35657), but Washington, DC is contained by a federal district (Q11754) — the state-type filter now uses a VALUES clause accepting both; (2) the resolver matched the seed name against rdfs:label only, but Wikidata's primary English label for Q60 is "New York City" — "New York" appears only as a skos:altLabel — so the city name now matches against (rdfs:label|skos:altLabel). Regression tests pin both QIDs and the disjunction. On-site DC and NYC star_power values will follow on the next refresh + recompute cycle.
-
Data Preview # Fixed empty venue counts for 17 consolidated-city metros (NYC, SF, LA, DC, Baltimore, St. Louis…)
A spot-audit of the v2.0.1-dev publish caught New York scoring 15/100 because its eats / drinks / coffee / culture raw values were all 0 — impossible for the densest city in the country. Root cause: OSM stores NYC as admin_level=5 (the 5-borough city), not the admin_level=8 used by the typical US municipal boundary, so the venue-count Overpass query never found a relation to count. The same shape applied to every consolidated city-county and Virginia / Missouri independent city — 17 metros total ended up at zero venues across every density pillar. Two fixes ship together: (1) city_identity.csv now carries osm_admin_level overrides for the 12 metros whose correct level was verified directly against the Overpass API (NYC=5, SF/Baltimore/Denver/Anchorage/St. Louis/Norfolk/Richmond/Chesapeake/Virginia Beach=6, Buffalo/Wichita=7); (2) osm_overpass.fetch_all_counts now walks an admin-level fallback chain (6→7→5→8→4) when the primary level returns all zeros OR raises CityAreaUnresolved (Overpass occasionally serves back fewer count blocks than pillars at certain admin levels — NYC at level 5 returned 7-of-8, LA at level 8 returned 4-of-8 — and the arity miss must fall through instead of aborting the city's whole density-pillar fetch); (3) the per-Overpass-call server timeout was raised from 30s → 90s (and HTTP timeout 60s → 120s) after confirming LA needs ~45s to evaluate the 8 pillar unions inside the admin_level=8 boundary; the tighter cap was clipping the response mid-stream. The long-tail edge cases (DC, Honolulu, Albuquerque, St. Louis, St. Petersburg) needed a second mechanism: city_identity.csv now also carries an osm_name override for cities whose OSM relation name differs from the seed display name (St. Louis vs Saint Louis, Honolulu vs Honolulu County, Washington vs District of Columbia) plus admin-level overrides for the consolidated forms (Honolulu County at 6, DC at 4; Albuquerque was attempted at 9 but its OSM relation lacks the boundary=administrative tag the venue query requires, so it falls back to the unscored state until a follow-up source iteration relaxes that filter). algorithm_version was first bumped to v2.0.2-dev to land the 14-city batch, then v2.0.3-dev to ship the second batch with DC and Honolulu — each version bump skips the §4.2 cross-version delta cap, which would otherwise halt the publish for cities whose pillar scores legitimately moved >5pt after the OSM fix. After the second refresh + recompute, DC and Honolulu now carry real venue counts and the score distribution re-balances with the big metros where they belong.
-
Data Preview # Calibration anchors re-frozen from the live 100-city distribution; site now publishes real Fun Scores (v2.0.1-dev)
First real-data publish exposed that the §4.7 placeholder calibration anchors (CALIBRATION_LO=12.0, CALIBRATION_HI=58.0) did not fit the actual raw-composite range. Ten cities clamped to 0 (New York included) and two to 100, which is not a real ranking. The Week-4 calibration runbook (calibration.py) was run against the live scores.raw_composite distribution: 100 cities, range [-2.58, 59.76]. The 2nd / 98th percentile lands at LO=3.24 and HI=54.71. Anchors are now frozen there; algorithm_version bumped to v2.0.1-dev so the new scores never compare against the v2.0.0-dev baseline. The recomputed distribution: 8 Exceptional / 20 Very Fun / 19 Fun / 29 Some Fun / 24 Quiet — only 2 cities at 0, 2 at 100. The mirror in web/src/lib/scoring.ts now matches; arithmetic-parity tests on both sides explicitly pass (12.0, 58.0) so they remain stable across future re-freezes, and the universal-score reproduction tests gate themselves on bundle/scoring.ts agreement so they skip cleanly during a calibration-deploy window.
-
Data Preview # Ingestion fully refreshed in 29 minutes; coverage gate held the line on 6 skeleton pillars
Two fixes shipped on the ingestion side. (1) osm_overpass now issues ONE Overpass query per city for all eight density pillars instead of eight separate queries — the previous shape (944 queries per refresh) exceeded the 60-minute Cloud Run job limit and hung the 2026-05-23 run. (2) Every city now carries its BLS LAUS area code (CT{geoid}000000), so the unemployment-rate series resolves and the economic_strain pillar gets a real signal for all 118 cities instead of zero. The full refresh now completes in 28m55s (down from a 60-min hang) and lands real data for 17 of 23 pillars. The §6.5 publish gate still halted on six pillars with no real source attached yet (bike_micromobility, city_vibe, going_out_cost, higher_education, transit_quality, walkability), so the site continues to show sample data while those sources are wired. The gate behaved exactly as designed: no partial-coverage scores are ever published.
-
Site Preview # JSON Feed deep links now land on the actual changelog entry
Every item in /changelog.json carries a `url` of the form `https://cityfunindex.com/changelog#<date-slug>` so a feed reader (NetNewsWire, Feedbin, Inoreader) can deep-link to the exact entry. The /changelog HTML, however, rendered each entry as a bare <li> with no `id` — so the fragment had nothing to scroll to and every reader landed at the top of the page instead. The page now stamps an `id` on each entry derived from the same `entryAnchor()` helper the feed uses, plus a hover-revealed `#` permalink and a soft `:target` highlight so the linked-to entry is visually obvious. A feeds.test.ts pin walks dist/changelog/index.html and asserts every feed-item fragment resolves to a real anchor, so a future refactor that drops the id fails CI.
-
Site Preview # Pricing page schema now declines to quote a $0 price for Enterprise
The Service+OfferCatalog JSON-LD on /pricing surfaces four API plans to Google. The Enterprise plan is custom-quoted (priceNote "From $2,500/mo"), but an earlier version of the page emitted `price: "0"` for it — Google reads that as a free offer, the exact opposite of the on-page copy. The Offer now omits `price` entirely when the plan has no fixed monthly figure, leaving priceSpecification.description to carry "From $2,500/mo". A jsonld-coverage.test.ts pin catches a refactor that re-introduces the $0 fallback.
-
Data Preview # Publish gate held — first live recompute refused to ship partial scores
The first end-to-end live data attempt landed eleven sources (acs, airnow, census_population, fbi_cde, fema_nri, gdelt, google_places, hud_fmr, noaa_ncei, noaa_storm_events, nps; 2,755 raw observations) into the warehouse. Recompute then derived scores for all 118 cities in the 0–100 range, ran the §6.5 publish gate, and HALT-ed: sixteen pillars were below the coverage floor because their primary sources — osm_overpass for the venue-density pillars, wikidata for star power, and others — had not yet landed data. The gate did exactly what it was built for: refused to overwrite the live publish with a degraded snapshot. The public bundles are unchanged; the site keeps its sample-data disclosures. Once osm_overpass and the remaining feeds land, a re-run will clear the floor and replace the sample bundle.
-
Site Preview # Organization JSON-LD now carries a public contact email
Every page's Organization @graph node now includes a ContactPoint with api@cityfunindex.com — the same inbox already published in /press and /.well-known/security.txt. Google's Knowledge Graph surfaces this email in the SERP card, and integrators who ingest the JSON-LD into research notebooks find a contact channel without scraping the page. The triple placement (structured-data, press, security.txt) is pinned by structured-data.test.ts so a future inbox rename hits all three at once instead of one surface silently drifting.
-
Data Preview # Refresh pipeline now persists every completed source incrementally
The first live refresh run hung inside an unidentified source past the 60-minute Cloud Run job timeout, and every completed batch — eleven sources, several thousand observations — was discarded because writes were batched until after the whole plan finished. refresh_job.run() now flushes each batch to raw_observations the moment it completes, so the next hung source forfeits only its own batch and the run preserves everything fetched before it. raw_observations is append-only by contract (§6.1), so a successor run of the same batch simply adds new history rows and the resolver picks the latest.
-
Site Preview # Sample-data disclosures will flip to live the moment the pipeline writes
Six surfaces previously hardcoded the "illustrative sample data while the live pipeline is being finalised" caveat — the homepage hero, the pillar leaderboards, /methodology, /faq, /about, /press, /terms, /pricing and /api. Each one is now gated on publishedDataIsSample(), so the moment the live recompute writes its first real bundle every sample-data disclosure across the site disappears in one rebuild and is replaced with a pointer to the per-page freshness chip — no manual prose edits, no risk of one surface still claiming the data is sample while another shows real scores. A new vitest pins the pattern so any future page that adds the prose must also wire in the gate.
-
Site Preview # Alaska and Hawaii now appear on the city map
The /map page was previously plotted on a 49-feature lower-48 outline, which meant Anchorage and Honolulu had no land to sit on — they either projected off-frame or, if their states had been included, would have shrunk the lower-48 to a thumbnail. /map now uses a composite albersUsa projection: the lower-48 still fills the frame, and Alaska + Hawaii ride along in lower-left insets via their own sub-projections (parallels 55/65 for AK, 8/18 for HI). City dots and state outlines share the same composite so dots always land on land, and the new fitAlbersUsa helper is pinned by seven vitest cases.
-
Site Preview # Every city page now states the right factor counts
Mobile QA caught a class of stale prose that the existing drift sweep had missed: phrases like "12 things that make a city fun and 6 that wear it down" and "Those 18 contributions add back up" on every city page, plus "a dozen things" on the Best-of index and "Twelve pillars are positive" in the glossary. All seven sites updated to the live split (16 positive + 7 negative = 23 pillars). The CI sweep now also catches "thing(s)" / "contribution(s)" and word-form counts ("dozen", "twelve", "eighteen", "nineteen") so future drifts of this shape fail the build instead of slipping to production.
-
Site Preview # Category bands now break at 50: half the scale is green, half is red
The five category bands have been rebalanced so the green/red dividing line sits exactly at 50 — the natural midpoint of the 0-100 scale. Fun now starts at 50 (was 55) and Some Fun starts at 30 (was 40), giving each band a clean 20-point range either side of the midpoint. Exceptional (85+) and Very Fun (70+) are unchanged. The category colours and the CityFunIndex "Fun" wordmark also shifted to read as a single green-good / red-bad story: Fun is a light sage, Some Fun a gentle peach, and the wordmark Fun mark is now green.
-
Data Preview # Four new positive factors shipped: 23 total, 16 positive + 7 negative
The Fun Score now also covers Transit Quality, Walkability, Higher Education and Bike & Micromobility — the four remaining pillars from the expansion proposal, landed as one coordinated change so the algorithm settles into its final shape: 16 positive factors lifting a city, 7 negative factors that wear it down. Transit blends GTFS stop density, NTD service intensity and ACS commute mode share. Walkability uses the EPA National Walkability Index. Higher Education pairs IPEDS enrollment density with the ACS 18–24 share. Bike & Micromobility blends OSM cycleway-km with the MobilityData GBFS catalog. All 12 prior positive weights rebalance to make room. The methodology page, /best leaderboards, CSV header, and published sample bundles all reflect the new 23-factor shape.
-
Data Preview # Going-Out Cost pillar shipped: 19 factors, 7 negative
The Fun Score now includes a Going-Out Cost factor — what a typical night out actually costs in this metro, measured from three Bureau of Labor Statistics regional Consumer Price Index subindexes (food away from home, recreation services, transportation services) averaged into a single percentile rank. The negative side rebalances to seven factors; the positive side is unchanged. The methodology page, the /best leaderboards (a new "Cheapest night out" board), the CSV header and the published sample bundles all reflect the new factor.
-
Site Preview # Trust signal added: every score traces to real data
The homepage hero, the FAQ, and the methodology lede now lead with the empirical-data commitment — every Fun Score traces back to federal, state and city open datasets, public APIs and a small set of licensed commercial feeds. Nothing is editorial. A new FAQ entry "Do you make these numbers up?" gives the direct answer for skeptical readers.
-
Site Preview # Cleaner brand-mark transparency + halo guard test
A second pass on the transparent brand variants replaced the original fuzz-threshold cutout (which left a visible cream halo around the artwork on dark backgrounds) with a per-pixel alpha mapped to the distance from the source cream colour. All 13 brand variants under /brand/ regenerated; OG cards rebuilt; a new brand-assets.test.ts guards corner alpha plus a border-ring halo check across every transparent variant (38 tests).
-
Site Preview # New brand mark across the site and social cards
The CityFunIndex wordmark is now joined by a dedicated brand mark — a speedometer-and-skyline icon shipped in transparent, mark-only and wordmark-only variants under /brand/. The favicon, the Apple touch icon, the homepage hero, the site-wide social card and every per-city and per-state Open Graph card now carry the new mark; the press kit lists every downloadable variant. White-on-dark wordmark variants ship for use on coloured surfaces.
-
Data Preview # Pillar-expansion proposal published
A research proposal at PILLAR_EXPANSION_PROPOSAL.md (in the repo) sets out five new factors under consideration for the next methodology pass: Transit Quality (GTFS via Transit.land + NTD + ACS), Walkability (EPA National Walkability Index), Higher Education (IPEDS enrollment density), Bike & Micromobility (OSM cycleways + GBFS bikeshare) and Going-Out Cost (BLS regional CPI). Methodology is not yet locked — the proposal includes rebalanced weights, the real free data sources, and the order they would land in.
-
Site Preview # Paste-ready citation on every city page
Each /city/{slug} page now carries a "Cite this page" block with a one-line credit string that includes the Fun Score, the algorithm version that produced it and the canonical URL — copy-button included, so a reporter can quote a city without leaving the page.
-
Site Preview # Installable as a browser search engine
CityFunIndex now ships an OpenSearch 1.1 description at /opensearch.xml, so Chrome, Firefox and Safari can offer it as a first-class search engine — typing a city name in the address bar jumps straight to its score page.
-
API Preview # Flat CSV download for the whole dataset
The full 118-city dataset is now downloadable as a single CSV at /sample.csv — one row per city with every Fun Score and all 19 factor scores, ready for Excel, R, pandas or anything that reads HTTP. The algorithm version is embedded in the Content-Disposition filename and listed as a third DataDownload in the schema.org Dataset on the API page.
-
API Preview # Changelog now publishes a JSON Feed
Feed-readers and integrators can subscribe to /changelog.json — a JSON Feed 1.1 export with deep-link IDs back to each entry. The consumer page advertises it via <link rel="alternate"> for autodiscovery.
-
Site Preview # City pages link to raw JSON and CSV downloads
Every /city/{slug} page now carries a "Use the data" section with pill links to that city’s full JSON record and the flat CSV covering every city — surfacing the API right where a reporter or analyst lands.
-
Site Preview # Data-freshness chip on every leaderboard and state page
The /best, /best/{pillar} and /state/{state} pages now footer-stamp when the rankings were computed and what algorithm version produced them — the same trust signal a reporter expects from a data product.
-
Site Preview # Glossary published
Every term used on the site, in the API and in the methodology is now defined on the /glossary page — Fun Score, calibration, raw composite, pillar, percentile rank and twelve more. Each entry has a stable anchor id and ships as a DefinedTerm in structured data.
-
Site Preview # Media kit published
The new /press page collects paste-ready boilerplate, at-a-glance stats, brand assets and a single contact for press, partnership and research enquiries.
-
Site Preview # FAQ regrouped with deep-linkable anchors
The /faq page is now grouped by theme — About the Fun Score, Using the index, Data & the API — and every question has a stable anchor id so individual answers can be linked to directly.
-
Site Preview # About page rewritten for partners and journalists
The /about page now reflects the mixed open + licensed data picture, names the audiences the project serves, and spells out the three commitments that keep the score independent.
-
API Preview # OpenAPI 3.1 spec published
The API is now described by a machine-readable spec at /api/openapi.json, so any OpenAPI-compatible code generator can produce a client. The /api page links it directly.
-
API Preview # API quickstart in four languages
The /api page now includes copy-paste quickstarts for curl, browser JavaScript, Python and Node, plus published versioning, errors and field-stability sections.
-
Site Preview # Methodology gains a fully worked example
The /methodology page now walks Albuquerque end-to-end — every pillar value, the upside and friction blends, and the calibration arithmetic — so the formula can be reproduced by hand.
-
API Preview # City pages expose schema.org Place + Fun Score
Every /city/{slug} page now ships JSON-LD that names the Place, its address and coordinates, and the Fun Score as a PropertyValue with a 0–100 range — readable by search engines and AI assistants.
-
API Preview # Pricing model: flat tiers by deployment scope
The API pricing is now a four-tier ladder — Free, Startup, Business, Enterprise — priced by the scope of your deployment rather than per-request metering. The /pricing and /api pages explain the model and the FAQ block beneath the cards has the common questions.
-
Data Preview # Sample data v2 + browser-verified consumer site
The published sample bundles now cover 118 cities across 38 states on the v2 algorithm, with 849 tests green and every consumer page browser-verified. The first live data refresh lands once the remaining ingestion keys are provisioned.