diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e224006 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python (lint + test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync + + - name: Ruff check + run: uv run ruff check . + + - name: Deptry (unused dependencies) + run: uv run deptry . + + - name: Tests + run: | + uv run pytest pipeline/utils/test_haversine.py + uv run pytest pipeline/utils/test_poi_counts.py + uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py + + frontend: + name: Frontend (lint + typecheck) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: ESLint + run: npm run lint + + - name: Prettier check + run: npm run format:check + + - name: TypeScript typecheck + run: npm run typecheck + + rust: + name: Rust (lint + test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: server-rs + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: server-rs + + - name: Clippy + run: cargo clippy -- -D warnings + + - name: Format check + run: cargo fmt --check + + - name: Install cargo-machete + run: cargo install cargo-machete + + - name: Unused dependencies check + run: cargo machete + + - name: Tests + run: cargo test diff --git a/CLAUDE.md b/CLAUDE.md index 7b9d626..1b4fe25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Serves `frontend/dist/` as static fallback in production **only** when `--dist` - All features (numeric and enum): row-major flat `Vec`, NaN = null - Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap>` mapping feature index → string values - String fields (address, postcode): interned/packed for memory efficiency -- All CLI args are required (no hidden defaults). Optional services use `Option`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `ollama_model` and `public_url` must be explicitly provided via env or CLI. +- All CLI args are required (no hidden defaults). Optional services use `Option`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `gemini_model` and `public_url` must be explicitly provided via env or CLI. ### Frontend (`frontend/`) diff --git a/Taskfile.yml b/Taskfile.yml index 834e34e..25b403d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -24,10 +24,14 @@ tasks: test:python: cmds: - - uv run -m pipeline.utils.test_fuzzy_join - uv run pytest pipeline/utils/test_haversine.py - uv run pytest pipeline/utils/test_poi_counts.py - uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py + + test:python:fuzzy-join: + desc: Run fuzzy join test (requires data files in data/) + cmds: + - uv run -m pipeline.utils.test_fuzzy_join test:server: desc: Run Rust backend tests @@ -107,6 +111,19 @@ tasks: cmds: - cargo fmt --all + ci: + desc: Run CI checks locally (lint + typecheck + test, no builds) + cmds: + - task: lint + - task: typecheck + - task: test + + typecheck: + desc: TypeScript typecheck only + dir: frontend + cmds: + - npm run typecheck + check: desc: Run all checks (lint, typecheck, build) cmds: diff --git a/analyses/price_model_evaluation.ipynb b/analyses/price_model_evaluation.ipynb index b535cc2..d8cf9cc 100644 --- a/analyses/price_model_evaluation.ipynb +++ b/analyses/price_model_evaluation.ipynb @@ -20,7 +20,6 @@ "import numpy as np\n", "import plotly.express as px\n", "import plotly.graph_objects as go\n", - "from plotly.subplots import make_subplots\n", "\n", "pl.Config.set_tbl_rows(20)\n", "pl.Config.set_fmt_str_lengths(50)\n", diff --git a/analyses/rightmove_buy.ipynb b/analyses/rightmove_buy.ipynb index ac24a69..cf806cf 100644 --- a/analyses/rightmove_buy.ipynb +++ b/analyses/rightmove_buy.ipynb @@ -48,7 +48,6 @@ "import polars as pl\n", "import plotly.express as px\n", "import plotly.graph_objects as go\n", - "from plotly.subplots import make_subplots\n", "\n", "pl.Config.set_tbl_rows(20)\n", "pl.Config.set_fmt_str_lengths(80)\n", @@ -265,7 +264,7 @@ ], "source": [ "price = clean[\"price\"]\n", - "print(f\"Price stats:\")\n", + "print(\"Price stats:\")\n", "print(f\" Min: £{price.min():,}\")\n", "print(f\" P5: £{price.quantile(0.05):,.0f}\")\n", "print(f\" P25: £{price.quantile(0.25):,.0f}\")\n", @@ -590891,7 +590890,7 @@ ")\n", "\n", "age = with_age[\"days_on_market\"].drop_nulls()\n", - "print(f\"Days on market stats:\")\n", + "print(\"Days on market stats:\")\n", "print(f\" Median: {age.median():.0f} days\")\n", "print(f\" Mean: {age.mean():.0f} days\")\n", "print(f\" P25: {age.quantile(0.25):.0f} days\")\n", @@ -594786,20 +594785,20 @@ } ], "source": [ - "print(f\"=== Rightmove Buy Listings Summary ===\")\n", + "print(\"=== Rightmove Buy Listings Summary ===\")\n", "print(f\"Total listings: {len(clean):,}\")\n", "print(f\"Outcodes covered: {clean['outcode'].n_unique():,}\")\n", - "print(f\"\")\n", + "print(\"\")\n", "print(f\"Price: median £{clean['price'].median():,.0f}, mean £{clean['price'].mean():,.0f}\")\n", "print(f\"Bedrooms: median {clean['bedrooms'].median():.0f}, mean {clean['bedrooms'].mean():.1f}\")\n", - "print(f\"\")\n", + "print(\"\")\n", "print(f\"Tenure known: {(len(clean) - clean['tenure'].null_count())/len(clean)*100:.1f}%\")\n", "print(f\" Freehold: {len(clean.filter(pl.col('tenure') == 'Freehold')):,}\")\n", "print(f\" Leasehold: {len(clean.filter(pl.col('tenure') == 'Leasehold')):,}\")\n", - "print(f\"\")\n", + "print(\"\")\n", "print(f\"Floorspace available: {clean['floorspace_sqm'].drop_nulls().len()/len(clean)*100:.1f}%\")\n", - "print(f\"\")\n", - "print(f\"Property types:\")\n", + "print(\"\")\n", + "print(\"Property types:\")\n", "for row in clean['property_type'].value_counts().sort('count', descending=True).iter_rows():\n", " print(f\" {row[0]}: {row[1]:,} ({row[1]/len(clean)*100:.1f}%)\")" ] diff --git a/analyses/source_overlap.ipynb b/analyses/source_overlap.ipynb index 66901d8..399ab3a 100644 --- a/analyses/source_overlap.ipynb +++ b/analyses/source_overlap.ipynb @@ -128,7 +128,7 @@ "CROSS_DEDUP_BUY = 2_220\n", "hk_buy_total = len(buy.filter(pl.col(\"source\") == \"Home.co.uk\")) + CROSS_DEDUP_BUY\n", "hk_buy_unique = len(buy.filter(pl.col(\"source\") == \"Home.co.uk\"))\n", - "print(f\"\\n--- BUY overlap analysis ---\")\n", + "print(\"\\n--- BUY overlap analysis ---\")\n", "print(f\"Home.co.uk scraped (before dedup): {hk_buy_total:,}\")\n", "print(f\"Home.co.uk unique (after dedup): {hk_buy_unique:,}\")\n", "print(f\"Cross-source duplicates removed: {CROSS_DEDUP_BUY:,}\")\n", @@ -1114,7 +1114,7 @@ "\n", "print(f\"Outcodes with home.co.uk listings: {len(oc_comparison)}\")\n", "print(f\"Total outcodes in dataset: {buy_oc['outcode'].drop_nulls().n_unique()}\")\n", - "print(f\"\\nHome.co.uk coverage by outcode:\")\n", + "print(\"\\nHome.co.uk coverage by outcode:\")\n", "oc_comparison" ] }, @@ -7315,7 +7315,7 @@ "print(f\"Covered outcodes: {covered_count}\")\n", "print(f\"Total outcodes: {total_outcodes}\")\n", "print()\n", - "print(f\"In covered outcodes:\")\n", + "print(\"In covered outcodes:\")\n", "print(f\" Rightmove: {rm_in_covered:,} listings\")\n", "print(f\" Home.co.uk: {hk_buy_unique:,} unique listings\")\n", "print(f\" HK/RM ratio: {ratio_in_covered:.2f}\")\n", @@ -7326,13 +7326,13 @@ "projected_dedup = int(projected_hk * CROSS_DEDUP_BUY / hk_buy_total)\n", "projected_unique = projected_hk - projected_dedup\n", "\n", - "print(f\"--- Projected full-coverage estimates ---\")\n", + "print(\"--- Projected full-coverage estimates ---\")\n", "print(f\" Projected home.co.uk total: ~{projected_hk:,}\")\n", "print(f\" Projected cross-dedup: ~{projected_dedup:,}\")\n", "print(f\" Projected unique additions: ~{projected_unique:,}\")\n", "print(f\" Projected merged dataset: ~{rm_buy + projected_unique:,} ({projected_unique/rm_buy*100:.1f}% increase)\")\n", "print()\n", - "print(f\"⚠️ These are rough estimates — the covered outcodes may not be representative\")" + "print(\"⚠️ These are rough estimates — the covered outcodes may not be representative\")" ] }, { diff --git a/finder/transform.py b/finder/transform.py index a0b98d1..3f847fc 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -94,9 +94,6 @@ def transform_property(prop: dict, outcode: str, pc_index: PostcodeSpatialIndex) key_features = [kf.get("description", "") for kf in prop.get("keyFeatures", []) if kf.get("description")] - listing_update = prop.get("listingUpdate", {}) - update_date = listing_update.get("listingUpdateDate", "") - postcode = pc_index.nearest(lat, lng) if not postcode: log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64b71d0..3b7dbbc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -178,15 +178,18 @@ export default function App() { return () => controller.abort(); }, []); - const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { - if (infoFeature) { - window.history.replaceState({ ...window.history.state, infoFeature }, ''); - } - const path = pageToPath(page, inviteCode ?? undefined); - const url = hash ? `${path}#${hash}` : path; - window.history.pushState({ page }, '', url); - setActivePage(page); - }, [inviteCode]); + const navigateTo = useCallback( + (page: Page, hash?: string, infoFeature?: string) => { + if (infoFeature) { + window.history.replaceState({ ...window.history.state, infoFeature }, ''); + } + const path = pageToPath(page, inviteCode ?? undefined); + const url = hash ? `${path}#${hash}` : path; + window.history.pushState({ page }, '', url); + setActivePage(page); + }, + [inviteCode] + ); useEffect(() => { if (!window.history.state?.page) { @@ -225,7 +228,8 @@ export default function App() { } }, [activePage, fetchSearches, fetchSavedProperties, user]); - const isAuthRequiredPage = activePage === 'account' || activePage === 'saved' || activePage === 'invites'; + const isAuthRequiredPage = + activePage === 'account' || activePage === 'saved' || activePage === 'invites'; useEffect(() => { if (authLoading) return; if (isAuthRequiredPage && !user) { @@ -266,8 +270,8 @@ export default function App() { initialLoading={initialLoading} theme={theme} pendingInfoFeature={null} - onClearPendingInfoFeature={() => { }} - onNavigateTo={() => { }} + onClearPendingInfoFeature={() => {}} + onNavigateTo={() => {}} screenshotMode ogMode={isOgMode} initialTravelTime={urlState.travelTime} @@ -306,7 +310,13 @@ export default function App() { /> )} {activePage === 'home' ? ( - navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} hidePricing={user?.subscription === 'licensed' || user?.isAdmin} /> + navigateTo('dashboard')} + onOpenPricing={() => navigateTo('pricing')} + theme={theme} + features={features} + hidePricing={user?.subscription === 'licensed' || user?.isAdmin} + /> ) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? ( navigateTo('dashboard')} @@ -412,13 +422,21 @@ export default function App() { setShowSaveModal(false)} onSave={savedSearches.saveSearch} - onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); }} + onViewSearches={() => { + setShowSaveModal(false); + navigateTo('saved'); + }} saving={savedSearches.saving} error={savedSearches.error} /> )} {showLicenseSuccess && ( - { setShowLicenseSuccess(false); navigateTo('dashboard'); }} /> + { + setShowLicenseSuccess(false); + navigateTo('dashboard'); + }} + /> )} ); diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 757c35a..e9fa7fe 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -18,9 +18,7 @@ function PageLayout({ children }: { children: React.ReactNode }) { return (
-
- {children} -
+
{children}
); @@ -38,10 +36,7 @@ function DeleteDialog({ onConfirm: () => void; }) { return ( -
+
{ - setSharingId(id); - try { - const shortUrl = await shortenUrl(params); - doCopy(shortUrl, id); - } catch { - doCopy(`${window.location.origin}/?${params}`, id); - } finally { - setSharingId(null); - } - }, [doCopy]); + const handleShare = useCallback( + async (params: string, id: string) => { + setSharingId(id); + try { + const shortUrl = await shortenUrl(params); + doCopy(shortUrl, id); + } catch { + doCopy(`${window.location.origin}/?${params}`, id); + } finally { + setSharingId(null); + } + }, + [doCopy] + ); if (loading) { return ( @@ -201,7 +199,11 @@ function SavedSearchesTab({ > {sharingId === search.id ? ( - ) : copiedId === search.id ? 'Copied!' : 'Share'} + ) : copiedId === search.id ? ( + 'Copied!' + ) : ( + 'Share' + )} )} {user.verified ? 'Verified' : 'Unverified'} @@ -732,7 +739,9 @@ export default function AccountPage({

Subscription

- + {user.subscription === 'licensed' ? 'Licensed' : 'Free'}
diff --git a/frontend/src/components/home/HexCanvas.tsx b/frontend/src/components/home/HexCanvas.tsx index cbed932..69ba104 100644 --- a/frontend/src/components/home/HexCanvas.tsx +++ b/frontend/src/components/home/HexCanvas.tsx @@ -47,14 +47,16 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) { >
))} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 7294b60..53abc75 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -11,10 +11,10 @@ import type { FeatureMeta } from '../../types'; export default function HomePage({ onOpenDashboard, - onOpenPricing, + onOpenPricing: _onOpenPricing, theme = 'light', features = [], - hidePricing, + hidePricing: _hidePricing, }: { onOpenDashboard: () => void; onOpenPricing: () => void; @@ -79,8 +79,8 @@ export default function HomePage({ House hunting? Make your biggest investment your smartest move.

- So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that - fit. + So many options - choosing the right one can feel overwhelming. Our interactive map + makes it simple: select your must-haves and instantly see the areas that fit.

@@ -217,66 +222,75 @@ export default function HomePage({ {/* Right: Comparison table */}

- Others vs{' '}Perfect Postcode + Others vs{' '} + + Perfect Postcode{' '} + +

-
- - - - - - - - - - - {FEATURE_ROWS.map((row, i) => ( - - - {[row.listings, row.postcode, row.guides].map((has, j) => ( - - ))} - +
+
- - Listing portals - - {'\u201CCheck my postcode\u201D'} - - Area guides - - Perfect Postcode -
- {row.feature} - {row.subtitle && ( -
{row.subtitle}
- )} -
- {has ? '\u2713' : '\u2717'} - - ✓ -
+ + + + + + - ))} - -
+ + Listing portals + + {'\u201CCheck my postcode\u201D'} + + Area guides + + Perfect Postcode +
-
+ + + {FEATURE_ROWS.map((row, i) => ( + + + {row.feature} + {row.subtitle && ( +
+ {row.subtitle} +
+ )} + + {[row.listings, row.postcode, row.guides].map((has, j) => ( + + {has ? '\u2713' : '\u2717'} + + ))} + + ✓ + + + ))} + + +
{/* Scrollytelling: Problem + Solution + Demo map */} -

+

See It in Action

@@ -311,27 +325,48 @@ export default function HomePage({ const FEATURE_ROWS = [ // listings postcode guides - { feature: 'Search without choosing an area first', subtitle: '(start with needs, not a location)', listings: false, postcode: false, guides: false }, - { feature: 'Area data', subtitle: '(crime, schools, noise, broadband)', listings: false, postcode: true, guides: true }, - { feature: 'Property-specific data', subtitle: '(price, EPC, floor area)', listings: true, postcode: false, guides: false }, - { feature: '56 combinable filters in one place', subtitle: '(all insights, one interactive map)', listings: false, postcode: false, guides: false }, + { + feature: 'Search without choosing an area first', + subtitle: '(start with needs, not a location)', + listings: false, + postcode: false, + guides: false, + }, + { + feature: 'Area data', + subtitle: '(crime, schools, noise, broadband)', + listings: false, + postcode: true, + guides: true, + }, + { + feature: 'Property-specific data', + subtitle: '(price, EPC, floor area)', + listings: true, + postcode: false, + guides: false, + }, + { + feature: '56 combinable filters in one place', + subtitle: '(all insights, one interactive map)', + listings: false, + postcode: false, + guides: false, + }, ]; const HOW_STEPS = [ { title: 'Set your must-haves', - description: - 'Budget, commute, schools \u2014 the map shows only what qualifies.', + description: 'Budget, commute, schools \u2014 the map shows only what qualifies.', }, { title: 'Explore areas and discover hidden gems', - description: - 'Zoom in, dig into details and nice to haves.', + description: 'Zoom in, dig into details and nice to haves.', }, { title: 'Drill into postcodes', - description: - 'See individual properties, sale prices, floor area, and compare.', + description: 'See individual properties, sale prices, floor area, and compare.', }, { title: 'Shortlist with confidence', diff --git a/frontend/src/components/home/ScrollStory.tsx b/frontend/src/components/home/ScrollStory.tsx index 2cba49b..56e5471 100644 --- a/frontend/src/components/home/ScrollStory.tsx +++ b/frontend/src/components/home/ScrollStory.tsx @@ -14,7 +14,7 @@ const DEMO_FEATURE_NAMES = [ 'Good+ primary schools within 5km', 'Number of restaurants within 2km', ]; -const noop = () => { }; +const noop = () => {}; // Filter fractions per stage: featureName -> [minFrac, maxFrac] // 0 = feature.min, 1 = feature.max @@ -75,10 +75,7 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [

You're about to spend{' '} - - up to £500k - {' '} - on a home. + up to £500k on a home.

), @@ -91,7 +88,9 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
1
-

Set your must-haves

+

+ Set your must-haves +

Say you want a home{' '} @@ -127,8 +126,8 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [ body: (

…all within{' '} - 45 minutes of Manchester{' '} - by public transport. + 45 minutes of Manchester by + public transport.

), }, @@ -137,11 +136,13 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [ body: ( <>

- No area chosen. No listings browsed. Yet you already know exactly where your needs are met. + No area chosen. No listings browsed. Yet you already know exactly where your needs are + met.

- That's just 4 filters. We've built 56 — - covering commute times, crime, broadband, noise, schools, amenities, and more. + That's just 4 filters. We've built{' '} + 56 — covering commute + times, crime, broadband, noise, schools, amenities, and more.

), @@ -337,9 +338,13 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) { ); })} {/* Travel time indicator */} -
+
- + Commute to Manchester {STAGES[stage].travel && ( @@ -369,7 +374,11 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
Fewer diff --git a/frontend/src/components/invite/InvitePage.tsx b/frontend/src/components/invite/InvitePage.tsx index 51561f9..2bf0e73 100644 --- a/frontend/src/components/invite/InvitePage.tsx +++ b/frontend/src/components/invite/InvitePage.tsx @@ -171,7 +171,7 @@ export default function InvitePage({

{isValid ? isAdminInvite - ? "You\u2019re invited!" + ? 'You\u2019re invited!' : 'Special offer!' : 'Perfect Postcode'}

@@ -256,12 +256,8 @@ export default function InvitePage({
-

- License activated! -

-

- You now have full access to Perfect Postcode. -

+

License activated!

+

You now have full access to Perfect Postcode.

); @@ -306,8 +302,12 @@ export default function InvitePage({
-

You already have a license

-

Your account already has full access.

+

+ You already have a license +

+

+ Your account already has full access. +

) : user ? (
-
- -
- - ); - })} + ); + })} )} {grouped.map((group) => { @@ -203,8 +214,15 @@ export default function FeatureBrowser({ > Upgrade to full map - - + + diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 308f7ec..fee9c8f 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -49,16 +49,10 @@ function SliderLabels({ const labels = displayValues || value; return (
- + {isAtMin ? 'min' : formatFilterValue(labels[0], raw)} - + {isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
@@ -175,7 +169,8 @@ export default memo(function Filters({ }, [filters]); const availableFeatures = useMemo( - () => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)), + () => + features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)), [features, enabledFeatures, activeListingType, isAllowed] ); const enabledFeatureList = useMemo( @@ -271,7 +266,10 @@ export default memo(function Filters({ const badgeCount = enabledFeatureList.length + activeEntryCount; return ( -
+
@@ -287,7 +285,16 @@ export default memo(function Filters({
- +
{(['historical', 'buy', 'rent'] as const).map((type) => { @@ -332,19 +339,21 @@ export default memo(function Filters({
{travelTimeEntries.map((entry, index) => (
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - /> + onTogglePin(travelFieldKey(entry))} + onSetDestination={(slug, label) => + onTravelTimeSetDestination(index, slug, label) + } + onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onToggleBest={() => onTravelTimeToggleBest(index)} + onRemove={() => onTravelTimeRemoveEntry(index)} + />
))}
@@ -385,7 +394,11 @@ export default memo(function Filters({ className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`} >
- +
- + Math.round(v / step) * step; onDragChange([ - pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)), - pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)), + pMin <= 0 + ? (hist?.min ?? feature.min!) + : snap(scale.toValue(pMin)), + pMax >= 100 + ? (hist?.max ?? feature.max!) + : snap(scale.toValue(pMax)), ]); } - : ([min, max]) => onDragChange([ - min <= feature.min! ? (hist?.min ?? feature.min!) : min, - max >= feature.max! ? (hist?.max ?? feature.max!) : max, - ]) + : ([min, max]) => + onDragChange([ + min <= feature.min! ? (hist?.min ?? feature.min!) : min, + max >= feature.max! ? (hist?.max ?? feature.max!) : max, + ]) } onPointerDown={() => onDragStart(feature.name)} onPointerUp={() => onDragEnd()} @@ -521,8 +549,8 @@ export default memo(function Filters({ setShowPhilosophy(false)}>

- Start with your must-haves, then layer on nice-to-haves. - The map narrows down as you add filters — the areas that survive are your best matches. + Start with your must-haves, then layer on nice-to-haves. The map narrows down as you + add filters — the areas that survive are your best matches.

@@ -530,9 +558,9 @@ export default memo(function Filters({ 1. Budget & property basics

- Set your price range, minimum floor area, and property type. - If you need a lease over freehold (or vice versa), filter for that too. - This eliminates most of the map immediately. + Set your price range, minimum floor area, and property type. If you need a lease + over freehold (or vice versa), filter for that too. This eliminates most of the map + immediately.

@@ -541,9 +569,9 @@ export default memo(function Filters({ 2. Commute & transport

- Add a travel time filter to your workplace — choose public transport or cycling - and set your maximum tolerable commute. You can also filter by - how many stations are within walking distance. + Add a travel time filter to your workplace — choose public transport or + cycling and set your maximum tolerable commute. You can also filter by how many + stations are within walking distance.

@@ -552,9 +580,9 @@ export default memo(function Filters({ 3. Safety & environment

- Use the crime filters to cap serious or minor crime rates. - Check road noise levels if you're a light sleeper, and - environmental risk filters for ground stability concerns. + Use the crime filters to cap serious or minor crime rates. Check road noise levels + if you're a light sleeper, and environmental risk filters for ground stability + concerns.

@@ -563,9 +591,9 @@ export default memo(function Filters({ 4. Schools & education

- Filter by the number of Ofsted-rated Good or Outstanding primary and - secondary schools nearby. The education deprivation score captures - broader area-level attainment. + Filter by the number of Ofsted-rated Good or Outstanding primary and secondary + schools nearby. The education deprivation score captures broader area-level + attainment.

@@ -574,9 +602,8 @@ export default memo(function Filters({ 5. Lifestyle & amenities

- Want restaurants, parks, or grocery shops within walking distance? - Filter by nearby amenity counts. Broadband speed filters help if - you work from home. + Want restaurants, parks, or grocery shops within walking distance? Filter by nearby + amenity counts. Broadband speed filters help if you work from home.

@@ -585,16 +612,15 @@ export default memo(function Filters({ 6. Energy & running costs

- EPC ratings from A to G indicate energy efficiency. - Filter for better ratings to find homes with lower bills and - fewer upgrade headaches. + EPC ratings from A to G indicate energy efficiency. Filter for better ratings to + find homes with lower bills and fewer upgrade headaches.

- Tip: if nothing survives your filters, relax one constraint at a time - to see which compromise unlocks the most options. + Tip: if nothing survives your filters, relax one constraint at a time to see which + compromise unlocks the most options.

diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index aa1411f..669a73a 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -17,13 +17,18 @@ interface HoverCardProps { features: FeatureMeta[]; } -export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) { +export default memo(function HoverCard({ + x, + y, + id, + isPostcode, + data, + filters, + features, +}: HoverCardProps) { const activeFilterNames = Object.keys(filters); - const featureMap = useMemo( - () => new Map(features.map((f) => [f.name, f])), - [features] - ); + const featureMap = useMemo(() => new Map(features.map((f) => [f.name, f])), [features]); // Get key stats to show from local data (min_ values) const getDisplayStats = () => { diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx index d111bf3..a884aa6 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -116,9 +116,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) { className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5" style={{ backgroundColor: color }} /> - {!isLast && ( -
- )} + {!isLast &&
}
@@ -135,7 +133,11 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) { ); } -export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) { +export default function JourneyInstructions({ + postcode, + entries, + label, +}: JourneyInstructionsProps) { const [journeys, setJourneys] = useState([]); // Only transit entries with a destination set @@ -192,9 +194,7 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe ) .catch((err) => { logNonAbortError('journey', err); - setJourneys((prev) => - prev.map((j, i) => (i === idx ? { ...j, loading: false } : j)) - ); + setJourneys((prev) => prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))); }); }); diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx index 024bd22..216fef1 100644 --- a/frontend/src/components/map/LocationSearch.tsx +++ b/frontend/src/components/map/LocationSearch.tsx @@ -78,10 +78,7 @@ export default function LocationSearch({ setLoading(true); search.close(); try { - const res = await fetch( - `/api/postcode/${encodeURIComponent(result.label)}`, - authHeaders(), - ); + const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders()); if (!res.ok) { setError('Postcode not found'); return; @@ -102,7 +99,7 @@ export default function LocationSearch({ setLoading(false); } }, - [onFlyTo, onLocationSearched, isMobile, search], + [onFlyTo, onLocationSearched, isMobile, search] ); // Mobile collapsed state: just a search icon button @@ -120,7 +117,12 @@ export default function LocationSearch({ } return ( -
+
({ width: 0, height: 0 }); // In screenshot mode, use the prop directly for instant updates (no async lag) - const viewState = - screenshotMode && initialViewState ? initialViewState : internalViewState; + const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState; useEffect(() => { const container = containerRef.current; @@ -245,10 +250,7 @@ export default memo(function Map({ Transport
- + perfect-postcode.co.uk
@@ -256,7 +258,11 @@ export default memo(function Map({ ) : null ) : ( <> - + {!hideLegend && (viewFeature && colorRange ? ( viewFeature.startsWith('tt_') ? ( @@ -280,7 +286,9 @@ export default memo(function Map({ showCancel={viewSource === 'eye'} onCancel={onCancelPin} mode="feature" - enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined} + enumValues={ + colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined + } theme={theme} raw={colorFeatureMeta.raw} /> diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx index 79df00e..45d34ab 100644 --- a/frontend/src/components/map/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -1,5 +1,10 @@ import { formatValue } from '../../lib/format'; -import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, ENUM_PALETTE } from '../../lib/consts'; +import { + FEATURE_GRADIENT, + DENSITY_GRADIENT, + DENSITY_GRADIENT_DARK, + ENUM_PALETTE, +} from '../../lib/consts'; import { gradientToCss } from '../../lib/utils'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { TickerValue } from '../ui/TickerValue'; @@ -34,7 +39,9 @@ function InlineEnumSwatches({ values }: { values: string[] }) { className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }} /> - {label} + + {label} +
); })} @@ -106,7 +113,10 @@ export default function MapLegend({ ) : (
{rangeMin} -
+
{rangeMax}
)} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 0e3b93a..0034aec 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -1,5 +1,12 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types'; +import type { + FeatureMeta, + FeatureFilters, + POICategoryGroup, + ViewState, + PostcodeGeometry, + Property, +} from '../../types'; import type { SearchedLocation } from './LocationSearch'; import type { Page } from '../ui/Header'; import Map from './Map'; @@ -103,9 +110,7 @@ export default function MapPage({ const [poiPaneOpen, setPoiPaneOpen] = useState(false); const [showBookmarkToast, setShowBookmarkToast] = useState(false); - const bookmarkToastDismissed = useRef( - localStorage.getItem('bookmark_toast_dismissed') === '1' - ); + const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1'); const handleSavePropertyWithToast = useCallback( (property: Property) => { @@ -158,8 +163,7 @@ export default function MapPage({ max: entry.timeRange?.[1], })), }; - const hasContext = - Object.keys(context.filters).length > 0 || context.travelTime.length > 0; + const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0; const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined); if (!result) return; @@ -174,7 +178,13 @@ export default function MapPage({ })); travelTime.handleSetEntries(newEntries); }, - [aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries, travelTime.activeEntries, filters] + [ + aiFilters.fetchAiFilters, + handleSetFilters, + travelTime.handleSetEntries, + travelTime.activeEntries, + filters, + ] ); const handleTravelTimeSetDestination = useCallback( @@ -246,7 +256,14 @@ export default function MapPage({ const pois = usePOIData(mapData.bounds, selectedPOICategories); - useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries); + useUrlSync( + mapData.currentView, + filters, + features, + selectedPOICategories, + selection.rightPaneTab, + travelTime.entries + ); useEffect(() => { mapData.setInitialView(initialViewState); @@ -268,11 +285,18 @@ export default function MapPage({ if (!res.ok) throw new Error('Postcode not found'); return res.json(); }) - .then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => { - mapFlyToRef.current?.(data.latitude, data.longitude, 16); - selection.handleLocationSearch(data.postcode, data.geometry); - if (isMobile) setMobileDrawerOpen(true); - }) + .then( + (data: { + postcode: string; + latitude: number; + longitude: number; + geometry: PostcodeGeometry; + }) => { + mapFlyToRef.current?.(data.latitude, data.longitude, 16); + selection.handleLocationSearch(data.postcode, data.geometry); + if (isMobile) setMobileDrawerOpen(true); + } + ) .catch(() => { // Silently fail — postcode might not exist }); @@ -397,7 +421,13 @@ export default function MapPage({ window.__screenshot_ready = true; } } - }, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]); + }, [ + screenshotMode, + mapData.loading, + mapData.data.length, + mapData.postcodeData.length, + mapData.usePostcodeView, + ]); const bookmarkToast = showBookmarkToast && (
@@ -580,7 +610,9 @@ export default function MapPage({
- Loading... + + Loading... +
)} @@ -641,9 +673,7 @@ export default function MapPage({ inline /> )} -
- {renderFilters()} -
+
{renderFilters()}
{mobileDrawerOpen && selection.selectedHexagon && ( @@ -746,7 +776,9 @@ export default function MapPage({
- Loading... + + Loading... +
)} @@ -794,9 +826,7 @@ export default function MapPage({
- {selection.rightPaneTab === 'properties' - ? renderPropertiesPane() - : renderAreaPane()} + {selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index fa5507a..801ab97 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -17,7 +17,6 @@ export default function MobileDrawer({ tab, onTabChange, }: MobileDrawerProps) { - // Close on Escape useEffect(() => { const handler = (e: KeyboardEvent) => { diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index ba3af89..af9b588 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -21,7 +21,7 @@ export default function POIPane({ groups, selectedCategories, onCategoriesChange, - poiCount, + poiCount: _poiCount, onNavigateToSource, }: POIPaneProps) { const [searchTerm, setSearchTerm] = useState(''); @@ -136,7 +136,6 @@ export default function POIPane({

)} -
diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index 005157e..6c632fc 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -234,7 +234,9 @@ function PropertyCard({ )} {price !== undefined && ( -
+
{askingPrice !== undefined || askingRent !== undefined ? ( Last sold: £{formatNumber(price)} @@ -265,9 +267,7 @@ function PropertyCard({ £{formatNumber(estimatedPrice)} - {estPricePerSqm !== undefined && ( - (£{formatNumber(estPricePerSqm)}/m²) - )} + {estPricePerSqm !== undefined && (£{formatNumber(estPricePerSqm)}/m²)}
)} diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 66b3385..45e7d46 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -58,7 +58,7 @@ export function TravelTimeCard({ (selectedSlug: string, selectedLabel: string) => { onSetDestination(selectedSlug, selectedLabel); }, - [onSetDestination], + [onSetDestination] ); const sliderMin = 0; @@ -68,7 +68,9 @@ export function TravelTimeCard({ const ModeIcon = MODE_ICONS[mode]; return ( -
+
{/* Header */}
@@ -86,7 +88,11 @@ export function TravelTimeCard({
{slug && ( - + )} @@ -126,8 +132,8 @@ export function TravelTimeCard({ ? ' by car, based on typical road speeds and the road network.' : mode === 'bicycle' ? ' by bicycle, using cycle-friendly routes.' - : ' on foot, using pedestrian paths and pavements.'} - {' '}Use the slider to filter areas within your preferred commute time. + : ' on foot, using pedestrian paths and pavements.'}{' '} + Use the slider to filter areas within your preferred commute time.

)} @@ -135,8 +141,8 @@ export function TravelTimeCard({ {showBestInfo && ( setShowBestInfo(false)}>

- Uses the 5th percentile travel time - the fastest realistic journey - if you time your departure to catch optimal connections. The default uses the{' '} + Uses the 5th percentile travel time - the fastest realistic journey if + you time your departure to catch optimal connections. The default uses the{' '} median, representing a typical journey regardless of when you leave.

@@ -156,12 +162,8 @@ export function TravelTimeCard({ onValueChange={([min, max]) => onTimeRangeChange([min, max])} />
- - {formatFilterValue(displayRange[0])} min - - - {formatFilterValue(displayRange[1])} min - + {formatFilterValue(displayRange[0])} min + {formatFilterValue(displayRange[1])} min
)} diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 4054991..41b3732 100644 --- a/frontend/src/components/pricing/PricingPage.tsx +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -42,7 +42,7 @@ function tierLabel(tier: PricingTier, index: number): string { export default function PricingPage({ onOpenDashboard, user, - onLoginClick, + onLoginClick: _onLoginClick, onRegisterClick, }: { onOpenDashboard: () => void; @@ -87,8 +87,7 @@ export default function PricingPage({ const tier = pricing.tiers[i]; if (tier.up_to === null || pricing.licensed_count < tier.up_to) { currentTierIndex = i; - spotsRemaining = - tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count; + spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count; break; } } @@ -99,7 +98,8 @@ export default function PricingPage({ if (currentTierIndex === 0) return; const container = scrollRef.current; const card = activeCardRef.current; - const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; + const scrollLeft = + card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; container.scrollLeft = Math.max(0, scrollLeft); setScrolledLeft(container.scrollLeft > 0); }, [pricing, currentTierIndex]); @@ -117,9 +117,7 @@ export default function PricingPage({ disabled={license.checkingOut} className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2" > - {license.checkingOut && ( - - )} + {license.checkingOut && } {license.checkingOut ? 'Redirecting...' : isFree @@ -143,7 +141,8 @@ export default function PricingPage({
@@ -151,7 +150,8 @@ export default function PricingPage({
@@ -159,7 +159,8 @@ export default function PricingPage({
@@ -167,7 +168,8 @@ export default function PricingPage({
@@ -175,7 +177,8 @@ export default function PricingPage({
@@ -183,16 +186,15 @@ export default function PricingPage({
-

- Early access pricing -

+

Early access pricing

Pay once, access forever. The earlier you join, the less you pay.

@@ -200,9 +202,9 @@ export default function PricingPage({

- Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, - £500 for a survey. Get the wrong area and you're stuck with a long - commute, bad schools, or a road you didn't know about. + Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 + for a survey. Get the wrong area and you're stuck with a long commute, bad schools, + or a road you didn't know about.

Less than your survey costs. Vastly more useful. @@ -216,145 +218,151 @@ export default function PricingPage({

) : pricing ? ( -
- {scrolledLeft &&
} -
-
- {pricing.tiers.map((tier, i) => { - const isCurrent = i === currentTierIndex; - const isFilled = - tier.up_to !== null && - pricing.licensed_count >= tier.up_to; - const filledInTier = isCurrent - ? pricing.licensed_count - - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0) - : 0; - const tierSlots = tier.slots; - const fillPercent = isFilled - ? 100 - : isCurrent && tierSlots > 0 - ? (filledInTier / tierSlots) * 100 +
+ {scrolledLeft && ( +
+ )} +
+
+ {pricing.tiers.map((tier, i) => { + const isCurrent = i === currentTierIndex; + const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to; + const filledInTier = isCurrent + ? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0) : 0; + const tierSlots = tier.slots; + const fillPercent = isFilled + ? 100 + : isCurrent && tierSlots > 0 + ? (filledInTier / tierSlots) * 100 + : 0; - return ( -
- {isCurrent && ( -
- Current tier -
- )} - + return (
-

+ Current tier +

+ )} + +
- {tierLabel(tier, i)} -

-
- - {formatPrice(tier.price_pence)} - - {tier.price_pence > 0 && ( + {tierLabel(tier, i)} +

+
- /lifetime + {formatPrice(tier.price_pence)} + {tier.price_pence > 0 && ( + + /lifetime + + )} +
+ + {isCurrent && spotsRemaining > 0 && ( +

+ {spotsRemaining} spot + {spotsRemaining !== 1 ? 's' : ''} remaining +

+ )} + {isFilled && ( +

+ Filled +

)}
- {isCurrent && spotsRemaining > 0 && ( -

- {spotsRemaining} spot - {spotsRemaining !== 1 ? 's' : ''} remaining -

+ {/* Progress bar for current tier */} + {isCurrent && tierSlots > 0 && ( +
+
+
)} - {isFilled && ( -

- Filled -

- )} -
- {/* Progress bar for current tier */} - {isCurrent && tierSlots > 0 && ( -
-
-
- )} +
+
    + {FEATURES.map((feature) => ( +
  • + + {feature} +
  • + ))} +
-
-
    - {FEATURES.map((feature) => ( -
  • - - - {feature} - -
  • - ))} -
- - {isCurrent ? ( - <> - {ctaButton} - {license.error && ( -

- {license.error} + {isCurrent ? ( + <> + {ctaButton} + {license.error && ( +

+ {license.error} +

+ )} +

+ {isFree ? 'No credit card required' : '30-day money-back guarantee'}

- )} -

- {isFree - ? 'No credit card required' - : '30-day money-back guarantee'} -

- - ) : isFilled ? ( -
- Sold out -
- ) : ( -
- Upcoming -
- )} + + ) : isFilled ? ( +
+ Sold out +
+ ) : ( +
+ Upcoming +
+ )} +
-
- ); - })} + ); + })}
) : ( @@ -363,7 +371,6 @@ export default function PricingPage({

)}
-
); } diff --git a/frontend/src/components/ui/CollapsibleGroupHeader.tsx b/frontend/src/components/ui/CollapsibleGroupHeader.tsx index 196f72a..70cf0b7 100644 --- a/frontend/src/components/ui/CollapsibleGroupHeader.tsx +++ b/frontend/src/components/ui/CollapsibleGroupHeader.tsx @@ -16,7 +16,10 @@ export function CollapsibleGroupHeader({ children, }: CollapsibleGroupHeaderProps) { return ( - @@ -185,7 +172,9 @@ export function DestinationDropdown({ return (
-
+
diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 0930c1d..b042bc5 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -14,7 +14,15 @@ import { SpinnerIcon } from './icons/SpinnerIcon'; import UserMenu from './UserMenu'; import MobileMenu from './MobileMenu'; -export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite'; +export type Page = + | 'home' + | 'dashboard' + | 'learn' + | 'pricing' + | 'account' + | 'saved' + | 'invites' + | 'invite'; export const PAGE_PATHS: Record = { home: '/', @@ -128,27 +136,51 @@ export default function Header({ {/* Desktop nav */} {!isMobile && (

{isFree diff --git a/frontend/src/components/ui/UserMenu.tsx b/frontend/src/components/ui/UserMenu.tsx index 04f3f68..7d7a8db 100644 --- a/frontend/src/components/ui/UserMenu.tsx +++ b/frontend/src/components/ui/UserMenu.tsx @@ -1,13 +1,7 @@ import { useState, useRef, useEffect } from 'react'; import type { AuthUser } from '../../hooks/useAuth'; -export default function UserMenu({ - user, - onLogout, -}: { - user: AuthUser; - onLogout: () => void; -}) { +export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) { const [open, setOpen] = useState(false); const menuRef = useRef(null); diff --git a/frontend/src/components/ui/icons/LogoIcon.tsx b/frontend/src/components/ui/icons/LogoIcon.tsx index 2f767dc..5d6f0b6 100644 --- a/frontend/src/components/ui/icons/LogoIcon.tsx +++ b/frontend/src/components/ui/icons/LogoIcon.tsx @@ -11,11 +11,7 @@ export function LogoIcon({ className = 'w-4 h-4' }: IconProps) { stroke="currentColor" strokeWidth={2} > - + ); diff --git a/frontend/src/components/ui/icons/SparklesIcon.tsx b/frontend/src/components/ui/icons/SparklesIcon.tsx index 1e7c0e9..8f6f9b4 100644 --- a/frontend/src/components/ui/icons/SparklesIcon.tsx +++ b/frontend/src/components/ui/icons/SparklesIcon.tsx @@ -6,8 +6,14 @@ export function SparklesIcon({ className = 'w-4 h-4' }: IconProps) { return ( - - + + ); } diff --git a/frontend/src/hooks/useAiFilters.ts b/frontend/src/hooks/useAiFilters.ts index b436b30..40440b9 100644 --- a/frontend/src/hooks/useAiFilters.ts +++ b/frontend/src/hooks/useAiFilters.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useRef } from 'react'; import type { FeatureFilters } from '../types'; -import type { TransportMode, TravelTimeEntry } from './useTravelTime'; +import type { TransportMode } from './useTravelTime'; import { apiUrl, authHeaders, logNonAbortError } from '../lib/api'; export interface AiTravelTimeFilter { @@ -37,10 +37,7 @@ interface UseAiFiltersResult { } /** Build a human-readable summary of the AI result. */ -function buildSummary( - filters: FeatureFilters, - travelTimeFilters: AiTravelTimeFilter[] -): string { +function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFilter[]): string { const parts: string[] = []; for (const [name, value] of Object.entries(filters)) { diff --git a/frontend/src/hooks/useCollapsibleGroups.ts b/frontend/src/hooks/useCollapsibleGroups.ts index 4ef6e6a..022359e 100644 --- a/frontend/src/hooks/useCollapsibleGroups.ts +++ b/frontend/src/hooks/useCollapsibleGroups.ts @@ -1,6 +1,10 @@ import { useState, useCallback } from 'react'; -export function useCollapsibleGroups(): [Set, (name: string) => void, (name: string) => void] { +export function useCollapsibleGroups(): [ + Set, + (name: string) => void, + (name: string) => void, +] { const [collapsed, setCollapsed] = useState>(new Set()); const toggle = useCallback((name: string) => { diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index 9cf38c0..ef2f75b 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -24,13 +24,9 @@ import { POI_CLUSTER_MAX_ZOOM, } from '../lib/consts'; import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils'; -import { - type TravelTimeEntry, - travelFieldKey, -} from './useTravelTime'; +import { type TravelTimeEntry, travelFieldKey } from './useTravelTime'; import { MarchingAntsExtension } from '../lib/MarchingAntsExtension'; - interface UseDeckLayersProps { data: HexagonData[]; postcodeData: PostcodeFeature[]; @@ -314,8 +310,17 @@ export function useDeckLayers({ if (!entry.timeRange || !entry.slug) continue; const fk = travelFieldKey(entry); const modeVal = d[`avg_${fk}`]; - if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) { - return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number]; + if ( + modeVal == null || + (modeVal as number) < entry.timeRange[0] || + (modeVal as number) > entry.timeRange[1] + ) { + return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [ + number, + number, + number, + number, + ]; } } @@ -329,7 +334,12 @@ export function useDeckLayers({ if (vf.startsWith('tt_')) { const ttVal = d[`avg_${vf}`]; if (ttVal == null) { - return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number]; + return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [ + number, + number, + number, + number, + ]; } return getFeatureFillColor( ttVal as number, @@ -423,8 +433,17 @@ export function useDeckLayers({ if (!entry.timeRange || !entry.slug) continue; const fk = travelFieldKey(entry); const modeVal = d[`avg_${fk}`]; - if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) { - return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number]; + if ( + modeVal == null || + (modeVal as number) < entry.timeRange[0] || + (modeVal as number) > entry.timeRange[1] + ) { + return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [ + number, + number, + number, + number, + ]; } } @@ -438,7 +457,12 @@ export function useDeckLayers({ if (vf.startsWith('tt_')) { const ttVal = d[`avg_${vf}`]; if (ttVal == null) { - return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number]; + return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [ + number, + number, + number, + number, + ]; } return getFeatureFillColor( ttVal as number, @@ -673,8 +697,7 @@ export function useDeckLayers({ id: 'poi-cluster-text', data: clusters, getPosition: (d) => [d.lng, d.lat], - getText: (d) => - d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count), + getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)), getSize: 12, getColor: [255, 255, 255, 255], fontWeight: 700, diff --git a/frontend/src/hooks/useDropdownPosition.ts b/frontend/src/hooks/useDropdownPosition.ts index baa95c2..43ec163 100644 --- a/frontend/src/hooks/useDropdownPosition.ts +++ b/frontend/src/hooks/useDropdownPosition.ts @@ -1,10 +1,7 @@ import { useCallback, useLayoutEffect, useState } from 'react'; import type React from 'react'; -export function useDropdownPosition( - anchorRef: React.RefObject, - open: boolean, -) { +export function useDropdownPosition(anchorRef: React.RefObject, open: boolean) { const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); const update = useCallback(() => { diff --git a/frontend/src/hooks/useHexagonSelection.ts b/frontend/src/hooks/useHexagonSelection.ts index ee52752..5a36932 100644 --- a/frontend/src/hooks/useHexagonSelection.ts +++ b/frontend/src/hooks/useHexagonSelection.ts @@ -29,7 +29,12 @@ interface UseHexagonSelectionOptions { journeyDest?: JourneyDest | null; } -export function useHexagonSelection({ filters, features, resolution, journeyDest }: UseHexagonSelectionOptions) { +export function useHexagonSelection({ + filters, + features, + resolution, + journeyDest, +}: UseHexagonSelectionOptions) { const [selectedHexagon, setSelectedHexagon] = useState(null); const [properties, setProperties] = useState([]); const [propertiesTotal, setPropertiesTotal] = useState(0); @@ -39,8 +44,9 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest const [loadingAreaStats, setLoadingAreaStats] = useState(false); const [hoveredHexagon, setHoveredHexagon] = useState(null); const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area'); - const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = - useState(null); + const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState( + null + ); const fetchHexagonStats = useCallback( async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => { @@ -204,7 +210,13 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); } } - }, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties]); + }, [ + selectedHexagon, + properties.length, + loadingProperties, + fetchHexagonProperties, + fetchPostcodeProperties, + ]); const handleLoadMoreProperties = useCallback(() => { if (!selectedHexagon) return; diff --git a/frontend/src/hooks/useLocationSearch.ts b/frontend/src/hooks/useLocationSearch.ts index ed58f0d..b9c5a83 100644 --- a/frontend/src/hooks/useLocationSearch.ts +++ b/frontend/src/hooks/useLocationSearch.ts @@ -19,7 +19,15 @@ function normalizePostcode(s: string): string { export type SearchResult = | { type: 'postcode'; label: string } - | { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string }; + | { + type: 'place'; + name: string; + slug: string; + place_type: string; + lat: number; + lon: number; + city?: string; + }; export function useLocationSearch(mode?: string) { const [query, setQuery] = useState(''); @@ -29,60 +37,63 @@ export function useLocationSearch(mode?: string) { const abortRef = useRef(null); const debounceRef = useRef>(); - const handleInputChange = useCallback((value: string) => { - setQuery(value); - setActiveIndex(-1); + const handleInputChange = useCallback( + (value: string) => { + setQuery(value); + setActiveIndex(-1); - abortRef.current?.abort(); - if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current?.abort(); + if (debounceRef.current) clearTimeout(debounceRef.current); - const trimmed = value.trim(); - if (!trimmed) { - setResults([]); - setOpen(false); - return; - } - - if (!mode && looksLikePostcode(trimmed)) { - setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]); - setOpen(true); - return; - } - - if (trimmed.length < 2) { - setResults([]); - setOpen(false); - return; - } - - debounceRef.current = setTimeout(async () => { - const controller = new AbortController(); - abortRef.current = controller; - try { - const params = new URLSearchParams({ q: trimmed, limit: '7' }); - if (mode) params.set('mode', mode); - const res = await fetch( - `/api/places?${params}`, - authHeaders({ signal: controller.signal }), - ); - if (!res.ok) return; - const json: { places: PlaceResult[] } = await res.json(); - const placeResults: SearchResult[] = json.places.map((p) => ({ - type: 'place' as const, - name: p.name, - slug: p.slug, - place_type: p.place_type, - lat: p.lat, - lon: p.lon, - city: p.city, - })); - setResults(placeResults); - setOpen(placeResults.length > 0); - } catch (err) { - logNonAbortError('places search', err); + const trimmed = value.trim(); + if (!trimmed) { + setResults([]); + setOpen(false); + return; } - }, 200); - }, [mode]); + + if (!mode && looksLikePostcode(trimmed)) { + setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]); + setOpen(true); + return; + } + + if (trimmed.length < 2) { + setResults([]); + setOpen(false); + return; + } + + debounceRef.current = setTimeout(async () => { + const controller = new AbortController(); + abortRef.current = controller; + try { + const params = new URLSearchParams({ q: trimmed, limit: '7' }); + if (mode) params.set('mode', mode); + const res = await fetch( + `/api/places?${params}`, + authHeaders({ signal: controller.signal }) + ); + if (!res.ok) return; + const json: { places: PlaceResult[] } = await res.json(); + const placeResults: SearchResult[] = json.places.map((p) => ({ + type: 'place' as const, + name: p.name, + slug: p.slug, + place_type: p.place_type, + lat: p.lat, + lon: p.lon, + city: p.city, + })); + setResults(placeResults); + setOpen(placeResults.length > 0); + } catch (err) { + logNonAbortError('places search', err); + } + }, 200); + }, + [mode] + ); const close = useCallback(() => setOpen(false), []); @@ -112,7 +123,7 @@ export function useLocationSearch(mode?: string) { setOpen(false); } }, - [results, activeIndex, query], + [results, activeIndex, query] ); // Cleanup on unmount diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index d6eafbc..661e7a1 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -8,7 +8,14 @@ import type { ViewChangeParams, ApiResponse, } from '../types'; -import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api'; +import { + buildFilterString, + apiUrl, + assertOk, + logNonAbortError, + authHeaders, + isAbortError, +} from '../lib/api'; import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts'; import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts'; import { type TravelTimeEntry } from './useTravelTime'; @@ -243,8 +250,11 @@ export function useMapData({ }, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]); // Use drag data when it matches the current view feature, otherwise fall back to rawData - const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData; - const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData; + const data = + (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData; + const effectivePostcodeData = + (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? + postcodeData; // Compute p5/p95 from committed data for the viewed feature. // Always uses rawData/postcodeData (not drag preview data) so the color diff --git a/frontend/src/hooks/useSavedProperties.ts b/frontend/src/hooks/useSavedProperties.ts index 9137ae4..7d7f4ce 100644 --- a/frontend/src/hooks/useSavedProperties.ts +++ b/frontend/src/hooks/useSavedProperties.ts @@ -46,7 +46,10 @@ export function useSavedProperties(userId: string | null) { const raw = r as Record; let data: SavedPropertyData = {}; try { - data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {}; + data = + typeof raw.data === 'string' + ? JSON.parse(raw.data) + : (raw.data as SavedPropertyData) || {}; } catch { // Invalid JSON — use empty data } diff --git a/frontend/src/hooks/useTravelDestinations.ts b/frontend/src/hooks/useTravelDestinations.ts index 7068222..de54f6f 100644 --- a/frontend/src/hooks/useTravelDestinations.ts +++ b/frontend/src/hooks/useTravelDestinations.ts @@ -24,10 +24,7 @@ export function useTravelDestinations(mode: TransportMode) { const controller = new AbortController(); setLoading(true); - fetch( - `/api/travel-destinations?mode=${mode}`, - authHeaders({ signal: controller.signal }), - ) + fetch(`/api/travel-destinations?mode=${mode}`, authHeaders({ signal: controller.signal })) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); diff --git a/frontend/src/hooks/useTravelModes.ts b/frontend/src/hooks/useTravelModes.ts index 5bdcb0b..dd8b726 100644 --- a/frontend/src/hooks/useTravelModes.ts +++ b/frontend/src/hooks/useTravelModes.ts @@ -21,7 +21,7 @@ export function useTravelModes() { }) .then((data: { modes: TravelModeInfo[] }) => { const modes = new Set( - data.modes.filter((m) => m.destinations > 0).map((m) => m.mode), + data.modes.filter((m) => m.destinations > 0).map((m) => m.mode) ); setAvailableModes(modes); }) diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index 4897bdb..c475e3d 100644 --- a/frontend/src/hooks/useTravelTime.ts +++ b/frontend/src/hooks/useTravelTime.ts @@ -40,58 +40,39 @@ export function useTravelTime(initial?: TravelTimeInitial) { const [entries, setEntries] = useState(initial?.entries ?? []); const handleAddEntry = useCallback((mode: TransportMode) => { - setEntries((prev) => [ - ...prev, - { mode, slug: '', label: '', timeRange: null, useBest: false }, - ]); + setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]); }, []); const handleRemoveEntry = useCallback((index: number) => { setEntries((prev) => prev.filter((_, i) => i !== index)); }, []); - const handleSetDestination = useCallback( - (index: number, slug: string, label: string) => { - setEntries((prev) => - prev.map((entry, i) => - i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry - ) - ); - }, - [] - ); + const handleSetDestination = useCallback((index: number, slug: string, label: string) => { + setEntries((prev) => + prev.map((entry, i) => + i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry + ) + ); + }, []); - const handleTimeRangeChange = useCallback( - (index: number, range: [number, number]) => { - setEntries((prev) => - prev.map((entry, i) => - i === index ? { ...entry, timeRange: range } : entry - ) - ); - }, - [] - ); + const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => { + setEntries((prev) => + prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry)) + ); + }, []); - const handleToggleBest = useCallback( - (index: number) => { - setEntries((prev) => - prev.map((entry, i) => - i === index ? { ...entry, useBest: !entry.useBest } : entry - ) - ); - }, - [] - ); + const handleToggleBest = useCallback((index: number) => { + setEntries((prev) => + prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry)) + ); + }, []); const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => { setEntries(newEntries); }, []); /** Entries that have a destination selected (slug is set) */ - const activeEntries = useMemo( - () => entries.filter((e) => e.slug !== ''), - [entries] - ); + const activeEntries = useMemo(() => entries.filter((e) => e.slug !== ''), [entries]); return { entries, diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts index 73f49a7..5fc4bf6 100644 --- a/frontend/src/hooks/useTutorial.ts +++ b/frontend/src/hooks/useTutorial.ts @@ -32,8 +32,7 @@ const STEPS: Step[] = [ { target: '[data-tutorial="search"]', title: 'Search Locations', - content: - 'Search for a place name or postcode to jump directly to that area on the map.', + content: 'Search for a place name or postcode to jump directly to that area on the map.', placement: 'bottom', disableBeacon: true, }, diff --git a/frontend/src/index.css b/frontend/src/index.css index df412ad..c4b3b10 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -47,13 +47,22 @@ h3 { /* Hexagon background animations */ @keyframes hex-drift { - from { transform: translateX(-5vw); } - to { transform: translateX(105vw); } + from { + transform: translateX(-5vw); + } + to { + transform: translateX(105vw); + } } @keyframes hex-bob { - 0%, 100% { transform: translateY(var(--bob)); } - 50% { transform: translateY(calc(var(--bob) * -1)); } + 0%, + 100% { + transform: translateY(var(--bob)); + } + 50% { + transform: translateY(calc(var(--bob) * -1)); + } } /* Fade-in animation for homepage sections */ @@ -131,32 +140,65 @@ h3 { /* Aurora gradient animation for pricing hero */ @keyframes aurora-1 { - 0%, 100% { transform: translate(0, 0) scale(1); } - 33% { transform: translate(30px, -20px) scale(1.1); } - 66% { transform: translate(-20px, 15px) scale(0.9); } + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + 33% { + transform: translate(30px, -20px) scale(1.1); + } + 66% { + transform: translate(-20px, 15px) scale(0.9); + } } @keyframes aurora-2 { - 0%, 100% { transform: translate(0, 0) scale(1); } - 33% { transform: translate(-40px, 20px) scale(1.15); } - 66% { transform: translate(25px, -30px) scale(0.95); } + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + 33% { + transform: translate(-40px, 20px) scale(1.15); + } + 66% { + transform: translate(25px, -30px) scale(0.95); + } } @keyframes aurora-3 { - 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } - 50% { transform: translate(20px, 25px) scale(1.1) rotate(3deg); } + 0%, + 100% { + transform: translate(0, 0) scale(1) rotate(0deg); + } + 50% { + transform: translate(20px, 25px) scale(1.1) rotate(3deg); + } } @keyframes aurora-4 { - 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } - 40% { transform: translate(-35px, -15px) scale(1.2) rotate(-2deg); } - 70% { transform: translate(15px, 20px) scale(0.9) rotate(1deg); } + 0%, + 100% { + transform: translate(0, 0) scale(1) rotate(0deg); + } + 40% { + transform: translate(-35px, -15px) scale(1.2) rotate(-2deg); + } + 70% { + transform: translate(15px, 20px) scale(0.9) rotate(1deg); + } } @keyframes aurora-5 { - 0%, 100% { transform: translate(0, 0) scale(1); } - 30% { transform: translate(25px, 30px) scale(1.15); } - 60% { transform: translate(-30px, -10px) scale(0.95); } + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + 30% { + transform: translate(25px, 30px) scale(1.15); + } + 60% { + transform: translate(-30px, -10px) scale(0.95); + } } /* Hide scrollbar for pill groups on mobile */ @@ -168,4 +210,3 @@ h3 { .scrollbar-hide::-webkit-scrollbar { display: none; } - diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9edbb09..3b28daa 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -70,7 +70,11 @@ export async function shortenUrl(params: string): Promise { return `${window.location.origin}${data.url}`; } -export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[], exclude?: string): string { +export function buildFilterString( + filters: FeatureFilters, + features: FeatureMeta[], + exclude?: string +): string { const entries = Object.entries(filters); if (entries.length === 0) return ''; return entries diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index 75385c7..059cead 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -87,12 +87,7 @@ export const POI_GROUP_COLORS: Record = { export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128]; /** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */ -export const MINOR_POI_CATEGORIES = new Set([ - 'Bus stop', - 'Taxi rank', - 'EV Charging', - 'Playground', -]); +export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']); /** Zoom level below which minor POI categories are hidden */ export const MINOR_POI_ZOOM_THRESHOLD = 14; @@ -217,16 +212,16 @@ export const STACKED_ENUM_GROUPS: Record< * 10 colors chosen for perceptual distinctness in both light and dark modes. */ export const ENUM_PALETTE: [number, number, number][] = [ - [59, 130, 246], // blue-500 - [249, 115, 22], // orange-500 - [139, 92, 246], // violet-500 - [34, 197, 94], // green-500 - [239, 68, 68], // red-500 - [6, 182, 212], // cyan-500 - [236, 72, 153], // pink-500 - [245, 158, 11], // amber-500 - [20, 184, 166], // teal-500 - [107, 114, 128], // gray-500 + [59, 130, 246], // blue-500 + [249, 115, 22], // orange-500 + [139, 92, 246], // violet-500 + [34, 197, 94], // green-500 + [239, 68, 68], // red-500 + [6, 182, 212], // cyan-500 + [236, 72, 153], // pink-500 + [245, 158, 11], // amber-500 + [20, 184, 166], // teal-500 + [107, 114, 128], // gray-500 ]; /** Colors for stacked bar segments */ diff --git a/frontend/src/lib/external-search.ts b/frontend/src/lib/external-search.ts index bddead6..09056f2 100644 --- a/frontend/src/lib/external-search.ts +++ b/frontend/src/lib/external-search.ts @@ -42,11 +42,11 @@ const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30]; // Rightmove only accepts these specific price values const RIGHTMOVE_PRICES = [ - 50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, - 160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 260000, - 270000, 280000, 290000, 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, - 550000, 600000, 650000, 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000, - 2500000, 3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000, + 50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000, + 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 260000, 270000, + 280000, 290000, 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, + 600000, 650000, 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000, 2500000, + 3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000, ]; function nearestRadius(target: number, allowed: number[]): number { @@ -99,15 +99,23 @@ export function buildPropertySearchUrls({ const bedroomFilter = filters['Bedrooms']; const minBedrooms = - Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number' ? bedroomFilter[0] : undefined; + Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number' + ? bedroomFilter[0] + : undefined; const maxBedrooms = - Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number' ? bedroomFilter[1] : undefined; + Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number' + ? bedroomFilter[1] + : undefined; const bathroomFilter = filters['Bathrooms']; const minBathrooms = - Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number' ? bathroomFilter[0] : undefined; + Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number' + ? bathroomFilter[0] + : undefined; const maxBathrooms = - Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' ? bathroomFilter[1] : undefined; + Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' + ? bathroomFilter[1] + : undefined; const tenureFilter = filters['Leasehold/Freehold']; const selectedTenures = @@ -123,8 +131,10 @@ export function buildPropertySearchUrls({ rmParams.set('useLocationIdentifier', 'true'); rmParams.set('locationIdentifier', rightmoveLocationId); rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))); - if (minPrice !== undefined) rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor'))); - if (maxPrice !== undefined) rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil'))); + if (minPrice !== undefined) + rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor'))); + if (maxPrice !== undefined) + rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil'))); if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms))); if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms))); if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms))); diff --git a/frontend/src/lib/feature-icons.tsx b/frontend/src/lib/feature-icons.tsx index 4cad3c7..04001f1 100644 --- a/frontend/src/lib/feature-icons.tsx +++ b/frontend/src/lib/feature-icons.tsx @@ -494,10 +494,7 @@ const FEATURE_ICON_PATHS: Record = { /** * Returns a complete SVG icon element for a given feature name, or null if unmapped. */ -export function getFeatureIcon( - featureName: string, - className: string, -): ReactElement | null { +export function getFeatureIcon(featureName: string, className: string): ReactElement | null { const paths = FEATURE_ICON_PATHS[featureName]; if (!paths) return null; return ( diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 00f81a1..1562f1c 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -30,8 +30,18 @@ export function formatDuration(d: string): string { } const MONTH_NAMES = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', ]; export function formatTransactionDate(fractionalYear: number): string { diff --git a/frontend/src/lib/group-icons.ts b/frontend/src/lib/group-icons.ts index 2ecf0ba..050d7b3 100644 --- a/frontend/src/lib/group-icons.ts +++ b/frontend/src/lib/group-icons.ts @@ -24,8 +24,6 @@ const GROUP_ICONS: Record> = { Property: TagIcon, }; -export function getGroupIcon( - group: string, -): ComponentType<{ className?: string }> | null { +export function getGroupIcon(group: string): ComponentType<{ className?: string }> | null { return GROUP_ICONS[group] ?? null; } diff --git a/pipeline/download/places.py b/pipeline/download/places.py index a441c1e..91ad494 100644 --- a/pipeline/download/places.py +++ b/pipeline/download/places.py @@ -13,13 +13,13 @@ import polars as pl from shapely.geometry import Point from tqdm import tqdm -from pipeline.download.pois import ( +from pipeline.utils.england_geometry import ( ENGLAND_BBOX_EAST, ENGLAND_BBOX_NORTH, ENGLAND_BBOX_SOUTH, ENGLAND_BBOX_WEST, + load_england_polygon, ) -from pipeline.utils.england_geometry import load_england_polygon PLACE_TYPES = {"city"} diff --git a/pipeline/download/rental_prices.py b/pipeline/download/rental_prices.py index 1a253de..9b2a1b4 100644 --- a/pipeline/download/rental_prices.py +++ b/pipeline/download/rental_prices.py @@ -21,6 +21,42 @@ BEDROOM_SHEETS = { # Local authority district codes in England, https://en.wikipedia.org/wiki/ONS_coding_system LA_PREFIXES = ("E06", "E07", "E08", "E09") +# April 2021 + April 2023 LA reorganizations: old district codes → new unitary authority codes. +# The ONS rental data (Oct 2022 – Sep 2023) uses the old codes; IoD 2025 uses the new ones. +# We remap old → new and average the medians so the join in merge.py works. +LA_CONSOLIDATION = { + # North Northamptonshire (April 2021) + "E07000150": "E06000061", # Corby + "E07000152": "E06000061", # East Northamptonshire + "E07000153": "E06000061", # Kettering + "E07000156": "E06000061", # Wellingborough + # West Northamptonshire (April 2021) + "E07000151": "E06000062", # Daventry + "E07000154": "E06000062", # Northampton + "E07000155": "E06000062", # South Northamptonshire + # Cumberland (April 2023) + "E07000026": "E06000063", # Allerdale + "E07000028": "E06000063", # Carlisle + "E07000029": "E06000063", # Copeland + # Westmorland and Furness (April 2023) + "E07000027": "E06000064", # Barrow-in-Furness + "E07000030": "E06000064", # Eden + "E07000031": "E06000064", # South Lakeland + # North Yorkshire (April 2023) + "E07000163": "E06000065", # Craven + "E07000164": "E06000065", # Hambleton + "E07000165": "E06000065", # Harrogate + "E07000166": "E06000065", # Richmondshire + "E07000167": "E06000065", # Ryedale + "E07000168": "E06000065", # Scarborough + "E07000169": "E06000065", # Selby + # Somerset (April 2023) + "E07000187": "E06000066", # Mendip + "E07000188": "E06000066", # Sedgemoor + "E07000189": "E06000066", # South Somerset + "E07000246": "E06000066", # Somerset West and Taunton +} + def _read_sheet(xls_path: Path, sheet_id: int, bedrooms: int) -> pl.DataFrame: """Read one bedroom category sheet, extract LA-level median rents.""" @@ -61,6 +97,14 @@ def convert_to_parquet(xls_path: Path, parquet_path: Path) -> None: frames.append(df) combined = pl.concat(frames) + + # Remap old LA codes to new unitary authority codes and average medians + combined = combined.with_columns( + pl.col("area_code").replace(LA_CONSOLIDATION), + ).group_by("area_code", "bedrooms").agg( + pl.col("median_monthly_rent").mean(), + ) + print(f"Combined: {combined.shape}") print(f"Non-null medians: {combined['median_monthly_rent'].drop_nulls().len()}") print(combined.head(10)) diff --git a/pipeline/utils/england_geometry.py b/pipeline/utils/england_geometry.py index b545363..762de3b 100644 --- a/pipeline/utils/england_geometry.py +++ b/pipeline/utils/england_geometry.py @@ -1,4 +1,4 @@ -"""England boundary polygon for accurate point-in-country filtering. +"""England boundary polygon and bounding box for geographic filtering. Uses shapely prepared geometry for fast single-point checks (osmium handlers) and vectorized shapely.contains for batch checks (Polars DataFrames). @@ -12,6 +12,12 @@ import shapely from shapely.geometry import shape from shapely.prepared import PreparedGeometry, prep +# Bounding box for fast pre-filtering before the precise polygon check +ENGLAND_BBOX_WEST = -6.45 +ENGLAND_BBOX_SOUTH = 49.85 +ENGLAND_BBOX_EAST = 1.77 +ENGLAND_BBOX_NORTH = 55.82 + def load_england_polygon(geojson_path: Path) -> PreparedGeometry: """Load England boundary as a prepared shapely polygon for fast contains checks.""" diff --git a/pyproject.toml b/pyproject.toml index af37206..1e06209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,9 +42,14 @@ dev = [ "ruff>=0.8.0", ] +[tool.deptry] +# finder/ has its own pyproject.toml; analyses/ and scripts/ use transitive deps +exclude = ["\\.venv", "finder", "analyses", "scripts"] + [tool.deptry.per_rule_ignores] # pyarrow/fastexcel: runtime backends for polars parquet/Excel I/O -# jupyter/ipywidgets/pandas: needed to run analysis notebooks -DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas"] +# jupyter/ipywidgets/pandas/plotly/folium: needed for analysis notebooks (excluded from scan) +# flask/fake-useragent: used in finder/ (excluded, has own pyproject.toml) +DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas", "plotly", "folium", "flask", "fake-useragent"] # pytest is a dev dependency, not a missing one DEP004 = ["pytest"] diff --git a/screenshot/src/cache.ts b/screenshot/src/cache.ts index 1403586..8b6118c 100644 --- a/screenshot/src/cache.ts +++ b/screenshot/src/cache.ts @@ -60,7 +60,7 @@ export class ScreenshotCache { } getPath(key: string): string { - return join(this.dir, `${key}.png`); + return join(this.dir, `${key}.jpg`); } /** diff --git a/screenshot/src/network-cache.ts b/screenshot/src/network-cache.ts new file mode 100644 index 0000000..eea25ea --- /dev/null +++ b/screenshot/src/network-cache.ts @@ -0,0 +1,58 @@ +interface CacheEntry { + body: Buffer; + headers: Record; + status: number; +} + +const MAX_ENTRIES = 2000; + +/** + * In-memory cache for network responses (tiles, JS/CSS bundles, font glyphs). + * + * This is the single biggest performance win for non-GPU environments: + * cached tiles and assets are served in microseconds instead of making + * HTTP roundtrips, saving 1-3+ seconds per screenshot after warm-up. + */ +export class NetworkCache { + private cache = new Map(); + hits = 0; + misses = 0; + + shouldCache(url: string): boolean { + // Vector map tiles (protobuf) + if (url.includes('/api/tiles/')) return true; + // Static assets by extension + if (/\.(js|css|woff2?|ttf|png|jpe?g|svg|ico|pbf|json)(\?|$)/i.test(url)) return true; + // Font glyphs and emoji sprites under /assets/ + if (url.includes('/assets/')) return true; + return false; + } + + get(url: string): CacheEntry | undefined { + const entry = this.cache.get(url); + if (entry) { + this.hits++; + return entry; + } + this.misses++; + return undefined; + } + + set(url: string, entry: CacheEntry): void { + if (this.cache.size >= MAX_ENTRIES) { + const firstKey = this.cache.keys().next().value; + if (firstKey) this.cache.delete(firstKey); + } + this.cache.set(url, entry); + } + + get size(): number { + return this.cache.size; + } + + stats(): string { + const total = this.hits + this.misses; + const rate = total > 0 ? ((this.hits / total) * 100).toFixed(0) : '0'; + return `${this.size} entries, ${rate}% hit rate (${this.hits}/${total})`; + } +} diff --git a/screenshot/src/screenshot.ts b/screenshot/src/screenshot.ts index 9e8bf5d..49bcada 100644 --- a/screenshot/src/screenshot.ts +++ b/screenshot/src/screenshot.ts @@ -1,25 +1,23 @@ -import { chromium, type Browser, type Page } from 'playwright'; +import { chromium, type Browser, type BrowserContext, type Page, type Route } from 'playwright'; import { existsSync, readdirSync } from 'fs'; +import { NetworkCache } from './network-cache.js'; const VIEWPORT = { width: 1200, height: 630 }; const NAVIGATION_TIMEOUT = 15_000; -const TILE_BUFFER_MS = 500; +const READY_TIMEOUT = 15_000; +const RENDER_BUFFER_MS = 200; const POOL_SIZE = 3; let browser: Browser | null = null; +let sharedContext: BrowserContext | null = null; const pagePool: Page[] = []; let warmingUp = false; +const networkCache = new NetworkCache(); -/** - * Detect if a GPU is available for hardware-accelerated rendering. - * Checks for DRI devices on Linux which indicate GPU + driver availability. - */ function detectGpu(): boolean { try { - // Check for DRI render nodes (Linux) if (existsSync('/dev/dri')) { const devices = readdirSync('/dev/dri'); - // Look for renderD* (render nodes) or card* (display cards) const hasGpu = devices.some((d) => d.startsWith('renderD') || d.startsWith('card')); if (hasGpu) { console.log(`GPU detected: /dev/dri contains ${devices.join(', ')}`); @@ -27,7 +25,7 @@ function detectGpu(): boolean { } } } catch { - // Ignore errors - fall back to software rendering + // Fall through to software rendering } console.log('No GPU detected, using SwiftShader software rendering'); return false; @@ -41,14 +39,31 @@ function getBrowserArgs(): string[] { '--disable-dev-shm-usage', '--enable-webgl', '--ignore-gpu-blocklist', + // Prevent timer/renderer throttling in headless mode + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + // Ensure compositor finishes all stages before screenshot capture + '--run-all-compositor-stages-before-draw', + // Skip color profile conversion overhead + '--force-color-profile=srgb', + // Reduce non-essential browser features + '--disable-domain-reliability', + '--disable-features=TranslateUI', ]; if (hasGpu) { - // Use hardware GPU acceleration return [...baseArgs, '--enable-gpu', '--use-gl=egl']; } else { - // Fall back to SwiftShader software rendering - return [...baseArgs, '--disable-gpu', '--use-gl=swiftshader']; + return [ + ...baseArgs, + '--disable-gpu', + '--use-gl=swiftshader', + // Run the GPU (SwiftShader) work in the browser process to eliminate + // IPC overhead between browser and GPU processes — significant win + // when all GL calls are CPU-bound anyway + '--in-process-gpu', + ]; } } @@ -61,22 +76,71 @@ async function ensureBrowser(): Promise { return browser; } -async function createPage(): Promise { +async function ensureContext(): Promise { + if (sharedContext) return sharedContext; + const instance = await ensureBrowser(); - const context = await instance.newContext({ + sharedContext = await instance.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1, }); + + // Set up response caching at context level — all pages share the cache. + // This means tiles fetched during pre-warm or a previous screenshot are + // served instantly for subsequent screenshots. + await sharedContext.route('**/*', async (route: Route) => { + const url = route.request().url(); + + // Non-cacheable requests (API data, etc.) pass through directly + if (!networkCache.shouldCache(url)) { + await route.continue(); + return; + } + + // Cache hit — fulfill from memory, zero network + const cached = networkCache.get(url); + if (cached) { + await route.fulfill({ + status: cached.status, + headers: cached.headers, + body: cached.body, + }); + return; + } + + // Cache miss — fetch, cache, fulfill + try { + const response = await route.fetch(); + const body = await response.body(); + const entry = { + body, + headers: response.headers(), + status: response.status(), + }; + networkCache.set(url, entry); + await route.fulfill({ + status: entry.status, + headers: entry.headers, + body: entry.body, + }); + } catch { + await route.continue().catch(() => {}); + } + }); + + return sharedContext; +} + +async function createPage(): Promise { + const context = await ensureContext(); return context.newPage(); } async function warmPool(): Promise { if (warmingUp) return; warmingUp = true; - try { - const instance = await ensureBrowser(); - while (pagePool.length < POOL_SIZE && instance.isConnected()) { + while (pagePool.length < POOL_SIZE) { const page = await createPage(); pagePool.push(page); } @@ -86,17 +150,12 @@ async function warmPool(): Promise { } async function acquirePage(): Promise { - // Try to get a page from the pool const page = pagePool.shift(); if (page && !page.isClosed()) { - // Refill pool in background warmPool().catch(() => {}); return page; } - - // No pooled page available, create one const newPage = await createPage(); - // Start warming pool in background warmPool().catch(() => {}); return newPage; } @@ -104,62 +163,88 @@ async function acquirePage(): Promise { async function releasePage(page: Page): Promise { try { if (page.isClosed()) return; - - // Reset page state for reuse + // Navigate to blank to free rendered page resources (DOM, WebGL context) + // while keeping the page object alive for V8 code cache reuse await page.goto('about:blank', { timeout: 5000 }).catch(() => {}); - if (!page.isClosed() && pagePool.length < POOL_SIZE) { pagePool.push(page); } else { - await page.context().close().catch(() => {}); + await page.close().catch(() => {}); } } catch { - await page.context().close().catch(() => {}); + await page.close().catch(() => {}); } } +/** + * Pre-warm the browser and populate the network cache by loading the app once. + * Subsequent screenshots benefit from cached JS/CSS bundles, map tiles, + * and V8 compiled bytecode — eliminating cold-start latency. + */ +export async function initialize(appUrl: string): Promise { + console.log('Pre-warming browser and caches...'); + const page = await createPage(); + try { + await page.goto(`${appUrl}/?screenshot=1`, { + waitUntil: 'load', + timeout: 30_000, + }); + // Wait for the app to fully load and cache all resources + try { + await page.waitForFunction('window.__screenshot_ready === true', { + timeout: READY_TIMEOUT, + }); + } catch { + // Non-fatal — cache will still have JS/CSS/tiles from the partial load + } + console.log(`Pre-warm complete. Cache: ${networkCache.stats()}`); + } catch (err) { + console.warn('Pre-warm failed (non-fatal):', err); + } finally { + await page.close().catch(() => {}); + } + await warmPool(); +} + export async function takeScreenshot(url: string): Promise { const page = await acquirePage(); - - // Log browser console messages for diagnostics - page.on('console', (msg) => { - if (msg.type() === 'error' || msg.type() === 'warning') { - console.log(`[browser ${msg.type()}] ${msg.text()}`); - } - }); - page.on('pageerror', (err) => { - console.log(`[browser exception] ${err.message}`); - }); + const t0 = performance.now(); try { - // Use domcontentloaded instead of networkidle - let __screenshot_ready handle readiness const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT, }); + const t1 = performance.now(); if (response) { - console.log(`Page loaded: ${response.status()} ${response.statusText()}`); + console.log(` Navigate: ${(t1 - t0).toFixed(0)}ms (status ${response.status()})`); } - // Wait for the frontend to signal readiness + // Wait for the frontend to signal that data is loaded and layers created try { await page.waitForFunction('window.__screenshot_ready === true', { - timeout: NAVIGATION_TIMEOUT, + timeout: READY_TIMEOUT, }); - console.log('Frontend signalled ready'); } catch { - console.warn('Timed out waiting for __screenshot_ready, proceeding with partial screenshot'); + console.warn(' Timed out waiting for __screenshot_ready'); } + const t2 = performance.now(); + console.log(` Ready: ${(t2 - t1).toFixed(0)}ms`); - // Extra buffer for map tiles to finish rendering - await page.waitForTimeout(TILE_BUFFER_MS); + // Brief buffer for SwiftShader to finish rendering the WebGL frame. + // Reduced from 500ms → 200ms since tiles now load from the in-memory + // cache and don't need network round-trips. + await page.waitForTimeout(RENDER_BUFFER_MS); + + // JPEG at quality 85: ~3-5x faster encoding than PNG with negligible + // visual difference for screenshot content (map + text overlay) + const screenshot = await page.screenshot({ type: 'jpeg', quality: 85 }); + const t3 = performance.now(); + console.log(` Capture: ${(t3 - t2).toFixed(0)}ms | Total: ${(t3 - t0).toFixed(0)}ms`); + console.log(` Cache: ${networkCache.stats()}`); - const screenshot = await page.screenshot({ type: 'png' }); return Buffer.from(screenshot); } finally { - // Remove listeners before releasing page back to pool - page.removeAllListeners('console'); - page.removeAllListeners('pageerror'); await releasePage(page); } } @@ -182,24 +267,25 @@ export async function checkWebGL(): Promise> { vendor: debugExt ? g.getParameter(debugExt.UNMASKED_VENDOR_WEBGL) : 'unknown', }; }); - return info; + return { ...info, cache: networkCache.stats() }; } finally { await releasePage(page); } } export async function closeBrowser(): Promise { - // Close all pooled pages for (const page of pagePool) { - await page.context().close().catch(() => {}); + await page.close().catch(() => {}); } pagePool.length = 0; + if (sharedContext) { + await sharedContext.close().catch(() => {}); + sharedContext = null; + } + if (browser) { await browser.close().catch(() => {}); browser = null; } } - -// Pre-warm the pool on module load -warmPool().catch(() => {}); diff --git a/screenshot/src/server.ts b/screenshot/src/server.ts index 0599885..ff91c42 100644 --- a/screenshot/src/server.ts +++ b/screenshot/src/server.ts @@ -1,6 +1,6 @@ import express from 'express'; import { ScreenshotCache } from './cache.js'; -import { takeScreenshot, checkWebGL, closeBrowser } from './screenshot.js'; +import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js'; const PORT = parseInt(process.env.PORT || '8002', 10); const APP_URL = process.env.APP_URL; @@ -63,7 +63,7 @@ app.get('/screenshot', async (req, res) => { // Check cache first const cached = cache.get(cacheKey); if (cached) { - res.setHeader('Content-Type', 'image/png'); + res.setHeader('Content-Type', 'image/jpeg'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('X-Cache', 'HIT'); cached.pipe(res); @@ -75,15 +75,15 @@ app.get('/screenshot', async (req, res) => { const url = `${APP_URL}${pagePath}?${qs}`; console.log(`Taking screenshot: ${url}`); - const png = await takeScreenshot(url); + const jpeg = await takeScreenshot(url); // Cache it - cache.set(cacheKey, png); + cache.set(cacheKey, jpeg); - res.setHeader('Content-Type', 'image/png'); + res.setHeader('Content-Type', 'image/jpeg'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('X-Cache', 'MISS'); - res.send(png); + res.send(jpeg); } catch (err) { console.error('Screenshot failed:', err); res.status(500).json({ error: 'Screenshot failed' }); @@ -94,6 +94,13 @@ const server = app.listen(PORT, () => { console.log(`Screenshot service listening on port ${PORT}`); console.log(` APP_URL: ${APP_URL}`); console.log(` CACHE_DIR: ${CACHE_DIR}`); + + // Pre-warm browser and populate network cache in background. + // The health endpoint is available immediately; screenshot requests + // during warm-up will still work (just slower on the first call). + initialize(APP_URL).catch((err) => { + console.error('Initialization failed:', err); + }); }); // Graceful shutdown diff --git a/server-rs/src/aggregation.rs b/server-rs/src/aggregation.rs index 30a0970..c2fe149 100644 --- a/server-rs/src/aggregation.rs +++ b/server-rs/src/aggregation.rs @@ -1,3 +1,6 @@ +use crate::consts::NAN_U16; +use crate::data::QuantRef; + /// Per-cell accumulator for aggregating features (min/max/sum/count). /// Uses Box<[T]> instead of Vec to avoid storing capacity (saves 8 bytes per field per cell). /// Shared by hexagon and postcode aggregation routes. @@ -20,16 +23,23 @@ impl Aggregator { } } - /// Add a row using row-major feature_data layout. + /// Add a row using row-major feature_data layout (quantized u16). /// feature_data[row * num_features + feat_idx] — all features for one row - /// are contiguous, so this reads a single cache line per ~8 features. + /// are contiguous, so this reads a single cache line per ~16 features. #[inline] - pub fn add_row(&mut self, feature_data: &[f32], row: usize, num_features: usize) { + pub fn add_row( + &mut self, + feature_data: &[u16], + row: usize, + num_features: usize, + quant: &QuantRef, + ) { self.count += 1; let base = row * num_features; let row_slice = &feature_data[base..base + num_features]; - for (feat_index, &value) in row_slice.iter().enumerate() { - if value.is_finite() { + for (feat_index, &raw) in row_slice.iter().enumerate() { + if raw != NAN_U16 { + let value = quant.decode(feat_index, raw); if value < self.mins[feat_index] { self.mins[feat_index] = value; } @@ -46,16 +56,18 @@ impl Aggregator { #[inline] pub fn add_row_selective( &mut self, - feature_data: &[f32], + feature_data: &[u16], row: usize, num_features: usize, indices: &[usize], + quant: &QuantRef, ) { self.count += 1; let base = row * num_features; for &feat_index in indices { - let value = feature_data[base + feat_index]; - if value.is_finite() { + let raw = feature_data[base + feat_index]; + if raw != NAN_U16 { + let value = quant.decode(feat_index, raw); if value < self.mins[feat_index] { self.mins[feat_index] = value; } diff --git a/server-rs/src/consts.rs b/server-rs/src/consts.rs index 923e514..c5c0b1d 100644 --- a/server-rs/src/consts.rs +++ b/server-rs/src/consts.rs @@ -1,3 +1,6 @@ +pub const NAN_U16: u16 = u16::MAX; +pub const QUANT_SCALE: f32 = 65534.0; + pub const HISTOGRAM_BINS: usize = 100; pub const H3_PRECOMPUTE_MAX: u8 = 12; diff --git a/server-rs/src/data.rs b/server-rs/src/data.rs index 69d8f90..5d0ea2f 100644 --- a/server-rs/src/data.rs +++ b/server-rs/src/data.rs @@ -7,5 +7,7 @@ pub mod travel_time; pub use places::PlaceData; pub use poi::{POICategoryGroup, POIData}; pub use postcodes::PostcodeData; -pub use property::{precompute_h3, FeatureStats, Histogram, PropertyData, RenovationEvent}; +pub use property::{ + precompute_h3, FeatureStats, Histogram, PropertyData, QuantRef, RenovationEvent, +}; pub use travel_time::{slugify, TravelTimeStore}; diff --git a/server-rs/src/data/poi.rs b/server-rs/src/data/poi.rs index 6aa268d..c909682 100644 --- a/server-rs/src/data/poi.rs +++ b/server-rs/src/data/poi.rs @@ -18,7 +18,12 @@ pub struct POICategoryGroup { } pub struct POIData { - pub id: Vec, + /// Contiguous buffer holding all POI ID strings end-to-end. + id_buffer: String, + /// Byte offset into `id_buffer` where each row's ID starts. + id_offsets: Vec, + /// Length in bytes of each row's ID. + id_lengths: Vec, pub group: InternedColumn, pub category: InternedColumn, pub name: Vec, @@ -31,6 +36,15 @@ pub struct POIData { pub priority: Vec, } +impl POIData { + /// Get the ID string for a given row. + pub fn id(&self, row: usize) -> &str { + let offset = self.id_offsets[row] as usize; + let length = self.id_lengths[row] as usize; + &self.id_buffer[offset..offset + length] + } +} + fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result> { let column = df .column(name) @@ -72,7 +86,7 @@ impl POIData { let row_count = df.height(); info!("Loaded {} POIs", row_count); - let id: Vec = extract_str_col(&df, "id")?; + let id_raw: Vec = extract_str_col(&df, "id")?; let name = extract_str_col(&df, "name")?; let category_raw = extract_str_col(&df, "category")?; let group_raw = extract_str_col(&df, "group")?; @@ -80,6 +94,19 @@ impl POIData { let lng = extract_f32_col(&df, "lng", 0.0)?; let emoji_raw = extract_str_col(&df, "emoji")?; + // Pack POI IDs into a contiguous buffer + let total_id_bytes: usize = id_raw.iter().map(|s| s.len()).sum(); + let mut id_buffer = String::with_capacity(total_id_bytes); + let mut id_offsets = Vec::with_capacity(row_count); + let mut id_lengths = Vec::with_capacity(row_count); + for s in &id_raw { + let offset = id_buffer.len() as u32; + let length = s.len().min(u8::MAX as usize) as u8; + id_offsets.push(offset); + id_lengths.push(length); + id_buffer.push_str(&s[..length as usize]); + } + let category = InternedColumn::build(&category_raw); let group = InternedColumn::build(&group_raw); let emoji = InternedColumn::build(&emoji_raw); @@ -99,7 +126,9 @@ impl POIData { info!("POI data loading complete."); Ok(POIData { - id, + id_buffer, + id_offsets, + id_lengths, name, category, group, diff --git a/server-rs/src/data/property.rs b/server-rs/src/data/property.rs index 8c073f4..a93293e 100644 --- a/server-rs/src/data/property.rs +++ b/server-rs/src/data/property.rs @@ -7,7 +7,7 @@ use std::path::Path; use rustc_hash::FxHashMap; -use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS}; +use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS, NAN_U16, QUANT_SCALE}; use crate::features::{self, Bounds}; fn is_numeric_dtype(dtype: &DataType) -> bool { @@ -47,6 +47,38 @@ pub struct Histogram { pub counts: Vec, } +impl Histogram { + /// Return the bin index for a given value using the outlier-bracket layout. + #[cfg(test)] + pub fn bin_for_value(&self, value: f32) -> usize { + let num_bins = self.counts.len(); + if value < self.p1 { + 0 + } else if value >= self.p99 { + num_bins - 1 + } else { + let middle_bins = num_bins.saturating_sub(2); + if middle_bins > 0 && self.p99 > self.p1 { + let width = (self.p99 - self.p1) / middle_bins as f32; + let middle_bin = ((value - self.p1) / width) as usize; + (1 + middle_bin).min(num_bins - 2) + } else { + num_bins / 2 + } + } + } + + /// Width of a single middle bin (bins 1..n-2). + #[cfg(test)] + pub fn middle_bin_width(&self) -> f32 { + let middle_bins = self.counts.len().saturating_sub(2); + if middle_bins > 0 && self.p99 > self.p1 { + (self.p99 - self.p1) / middle_bins as f32 + } else { + 0.0 + } + } +} pub struct FeatureStats { pub slider_min: f32, @@ -60,14 +92,67 @@ pub struct RenovationEvent { pub event: String, } +/// Lightweight reference to quantization parameters for decoding u16 feature data. +pub struct QuantRef<'a> { + pub dequant_a: &'a [f32], + pub quant_min: &'a [f32], + pub quant_range: &'a [f32], + pub num_numeric: usize, +} + +impl QuantRef<'_> { + /// Decode a raw u16 value back to f32. + #[inline] + pub fn decode(&self, feat_idx: usize, raw: u16) -> f32 { + if raw == NAN_U16 { + return f32::NAN; + } + if feat_idx >= self.num_numeric { + raw as f32 + } else { + raw as f32 * self.dequant_a[feat_idx] + self.quant_min[feat_idx] + } + } + + /// Encode a filter minimum bound to u16 (floors to include boundary values). + #[inline] + pub fn encode_min(&self, feat_idx: usize, value: f32) -> u16 { + if !value.is_finite() || self.quant_range[feat_idx] == 0.0 { + return 0; + } + let norm = (value - self.quant_min[feat_idx]) / self.quant_range[feat_idx]; + (norm * QUANT_SCALE).floor().clamp(0.0, QUANT_SCALE) as u16 + } + + /// Encode a filter maximum bound to u16 (ceils to include boundary values). + #[inline] + pub fn encode_max(&self, feat_idx: usize, value: f32) -> u16 { + if !value.is_finite() || self.quant_range[feat_idx] == 0.0 { + return QUANT_SCALE as u16; + } + let norm = (value - self.quant_min[feat_idx]) / self.quant_range[feat_idx]; + (norm * QUANT_SCALE).ceil().clamp(0.0, QUANT_SCALE) as u16 + } +} + pub struct PropertyData { pub lat: Vec, pub lon: Vec, pub feature_names: Vec, pub num_features: usize, + /// Number of numeric features (enum features start at this index). + pub num_numeric: usize, /// Row-major flat array: feature_data[row * num_features + feat_idx]. - /// NaN = null. For enum features, stores the index as f32 (0.0, 1.0, etc). - pub feature_data: Vec, + /// Quantized to u16. NaN sentinel = u16::MAX (65535). + /// Numeric features: encoded via (val - min) / range * 65534. + /// Enum features: stored directly as u16 cast of the f32 index. + pub feature_data: Vec, + /// Per-feature: range / QUANT_SCALE for fast decode. + dequant_a: Vec, + /// Per-feature: minimum value (offset for dequantization). + quant_min: Vec, + /// Per-feature: max - min (for encoding filter bounds). + quant_range: Vec, pub feature_stats: Vec, /// Contiguous buffer holding all address strings end-to-end. address_buffer: String, @@ -79,7 +164,7 @@ pub struct PropertyData { postcode_interner: lasso::RodeoReader, postcode_keys: Vec, /// For enum features: maps feature index to list of possible string values. - /// Index in values list corresponds to the f32 value stored in feature_data. + /// Index in values list corresponds to the u16 value stored in feature_data. pub enum_values: rustc_hash::FxHashMap>, /// Per-row flag: true = construction date is approximate (from EPC band), /// false = exact (from new-build transaction date). @@ -91,10 +176,11 @@ pub struct PropertyData { /// Per-row listing features (key feature bullet points from online listings). /// Only rows with features are present in the map. listing_features: FxHashMap>, - /// Per-row optional string columns from online listings. - listing_url: Vec>, - property_sub_type: Vec>, - price_qualifier: Vec>, + /// Sparse per-row optional string columns from online listings. + /// Only rows with non-empty values are stored (saves ~1 GB vs Vec>). + listing_url: FxHashMap, + property_sub_type: FxHashMap, + price_qualifier: FxHashMap, } impl PropertyData { @@ -139,17 +225,43 @@ impl PropertyData { /// Get listing URL for a given row. pub fn listing_url(&self, row: usize) -> Option<&str> { - self.listing_url[row].as_deref() + self.listing_url.get(&(row as u32)).map(String::as_str) } /// Get property sub-type for a given row. pub fn property_sub_type(&self, row: usize) -> Option<&str> { - self.property_sub_type[row].as_deref() + self.property_sub_type + .get(&(row as u32)) + .map(String::as_str) } /// Get price qualifier for a given row. pub fn price_qualifier(&self, row: usize) -> Option<&str> { - self.price_qualifier[row].as_deref() + self.price_qualifier.get(&(row as u32)).map(String::as_str) + } + + /// Decode a single feature value from quantized u16 storage. + #[inline] + pub fn get_feature(&self, row: usize, feat_idx: usize) -> f32 { + let raw = self.feature_data[row * self.num_features + feat_idx]; + if raw == NAN_U16 { + return f32::NAN; + } + if feat_idx >= self.num_numeric { + raw as f32 + } else { + raw as f32 * self.dequant_a[feat_idx] + self.quant_min[feat_idx] + } + } + + /// Get a QuantRef for passing to aggregation/filter functions. + pub fn quant_ref(&self) -> QuantRef<'_> { + QuantRef { + dequant_a: &self.dequant_a, + quant_min: &self.quant_min, + quant_range: &self.quant_range, + num_numeric: self.num_numeric, + } } } @@ -355,13 +467,12 @@ pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result> { .zip(lon.par_iter()) .enumerate() .map(|(i, (&latitude, &longitude))| { - let coord = h3o::LatLng::new(latitude as f64, longitude as f64) - .unwrap_or_else(|err| { - panic!( - "Invalid coordinates at row {}: lat={}, lon={}: {}", - i, latitude, longitude, err - ) - }); + let coord = h3o::LatLng::new(latitude as f64, longitude as f64).unwrap_or_else(|err| { + panic!( + "Invalid coordinates at row {}: lat={}, lon={}: {}", + i, latitude, longitude, err + ) + }); u64::from(coord.to_cell(h3_res)) }) .collect(); @@ -378,7 +489,10 @@ impl PropertyData { listings_rent_path: &Path, ) -> anyhow::Result { // Load postcode.parquet - tracing::info!("Loading postcode features from {:?}", postcode_features_path); + tracing::info!( + "Loading postcode features from {:?}", + postcode_features_path + ); let postcode_df = LazyFrame::scan_parquet(postcode_features_path, Default::default()) .context("Failed to scan postcode parquet")? .collect() @@ -623,6 +737,16 @@ impl PropertyData { }) .collect::>>()?; + // Compute quantization parameters from feature stats (numeric features) + let mut quant_min = Vec::with_capacity(num_features); + let mut quant_range = Vec::with_capacity(num_features); + for stats in &numeric_feature_stats { + let min = stats.histogram.min; + let max = stats.histogram.max; + quant_min.push(min); + quant_range.push(if max > min { max - min } else { 0.0 }); + } + tracing::info!("Extracting string columns"); let extract_string_col = |df: &DataFrame, name: &str| -> anyhow::Result> { let column = df @@ -928,19 +1052,34 @@ impl PropertyData { map }; - // Permute optional string columns - let listing_url: Vec> = perm - .iter() - .map(|&old_row| listing_url_raw[old_row as usize].clone()) - .collect(); - let property_sub_type: Vec> = perm - .iter() - .map(|&old_row| property_sub_type_raw[old_row as usize].clone()) - .collect(); - let price_qualifier: Vec> = perm - .iter() - .map(|&old_row| price_qualifier_raw[old_row as usize].clone()) - .collect(); + // Permute optional string columns into sparse HashMaps + let listing_url: FxHashMap = { + let mut map = FxHashMap::default(); + for (new_row, &old_row) in perm.iter().enumerate() { + if let Some(ref s) = listing_url_raw[old_row as usize] { + map.insert(new_row as u32, s.clone()); + } + } + map + }; + let property_sub_type: FxHashMap = { + let mut map = FxHashMap::default(); + for (new_row, &old_row) in perm.iter().enumerate() { + if let Some(ref s) = property_sub_type_raw[old_row as usize] { + map.insert(new_row as u32, s.clone()); + } + } + map + }; + let price_qualifier: FxHashMap = { + let mut map = FxHashMap::default(); + for (new_row, &old_row) in perm.iter().enumerate() { + if let Some(ref s) = price_qualifier_raw[old_row as usize] { + map.insert(new_row as u32, s.clone()); + } + } + map + }; // Build enum_values map: feature_index -> list of string values let mut enum_values: rustc_hash::FxHashMap> = @@ -967,24 +1106,47 @@ impl PropertyData { counts: vec![0; num_values.max(1)], }, }); + // Enum features: not quantized, stored directly as u16 + quant_min.push(0.0); + quant_range.push(0.0); } + let dequant_a: Vec = quant_range + .iter() + .map(|&r| if r > 0.0 { r / QUANT_SCALE } else { 0.0 }) + .collect(); // Transpose to row-major AND apply spatial permutation in one pass. - // Combines numeric and enum features into a single feature_data array. - tracing::info!("Transposing to row-major layout (spatially sorted)"); - let mut feature_data = vec![f32::NAN; row_count * num_features]; + // Combines numeric and enum features into a single feature_data array, quantized to u16. + tracing::info!("Transposing to row-major layout (spatially sorted, quantized to u16)"); + let mut feature_data = vec![NAN_U16; row_count * num_features]; feature_data .par_chunks_mut(num_features) .enumerate() .for_each(|(new_row, row_slice)| { let old_index = perm[new_row] as usize; - // Numeric features + // Numeric features: quantize to u16 for (feat_idx, col_vec) in numeric_col_major.iter().enumerate() { - row_slice[feat_idx] = col_vec[old_index]; + let value = col_vec[old_index]; + row_slice[feat_idx] = if value.is_finite() { + let range = quant_range[feat_idx]; + if range > 0.0 { + let normalized = (value - quant_min[feat_idx]) / range; + (normalized * QUANT_SCALE).round().clamp(0.0, QUANT_SCALE) as u16 + } else { + 0 + } + } else { + NAN_U16 + }; } - // Enum features (stored as f32 indices) + // Enum features: store as u16 directly for (enum_idx, (_, encoded)) in enum_col_major.iter().enumerate() { - row_slice[num_numeric + enum_idx] = encoded[old_index]; + let value = encoded[old_index]; + row_slice[num_numeric + enum_idx] = if value.is_finite() { + value as u16 + } else { + NAN_U16 + }; } }); @@ -995,7 +1157,11 @@ impl PropertyData { lon, feature_names, num_features, + num_numeric, feature_data, + dequant_a, + quant_min, + quant_range, feature_stats, address_buffer, address_offsets, diff --git a/server-rs/src/data/travel_time.rs b/server-rs/src/data/travel_time.rs index ea5f14a..a3a2ae7 100644 --- a/server-rs/src/data/travel_time.rs +++ b/server-rs/src/data/travel_time.rs @@ -124,10 +124,7 @@ impl TravelTimeStore { if file_name.ends_with(".parquet") { let file_stem = file_name.trim_end_matches(".parquet"); let slug = strip_numeric_prefix(file_stem).to_string(); - slug_to_file.insert( - (mode.clone(), slug.clone()), - file_stem.to_string(), - ); + slug_to_file.insert((mode.clone(), slug.clone()), file_stem.to_string()); slugs.insert(slug); } } @@ -207,10 +204,7 @@ impl TravelTimeStore { for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() { if let (Some(pc), Some(min)) = (pc, min) { let best_min = best.as_ref().and_then(|b| b.get(i)); - let journey = journeys - .as_ref() - .and_then(|j| j.get(i)) - .map(Arc::from); + let journey = journeys.as_ref().and_then(|j| j.get(i)).map(Arc::from); map.insert( pc.to_string(), TravelDataRow { @@ -274,10 +268,15 @@ mod tests { #[test] fn strip_numeric_prefix_basic() { - assert_eq!(strip_numeric_prefix("000000-bank-tube-station"), "bank-tube-station"); + assert_eq!( + strip_numeric_prefix("000000-bank-tube-station"), + "bank-tube-station" + ); assert_eq!(strip_numeric_prefix("000123-abbey-hey"), "abbey-hey"); - assert_eq!(strip_numeric_prefix("bank-tube-station"), "bank-tube-station"); + assert_eq!( + strip_numeric_prefix("bank-tube-station"), + "bank-tube-station" + ); assert_eq!(strip_numeric_prefix("london"), "london"); } } - diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs index 2b09964..ff4f35b 100644 --- a/server-rs/src/features.rs +++ b/server-rs/src/features.rs @@ -62,7 +62,6 @@ pub struct EnumFeatureGroup { pub features: &'static [EnumFeatureConfig], } - pub static FEATURE_GROUPS: &[FeatureGroup] = &[ FeatureGroup { name: "Properties in the area", diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index 930c411..e656143 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -1,3 +1,5 @@ +#![allow(clippy::min_ident_chars)] + mod aggregation; mod auth; mod consts; @@ -17,11 +19,11 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{bail, Context}; -use consts::SERVICE_CALL_TIMEOUT; use axum::middleware; use axum::routing::{any, get, patch, post}; use axum::Router; use clap::Parser; +use consts::SERVICE_CALL_TIMEOUT; use tower::limit::ConcurrencyLimitLayer; use tower_http::compression::CompressionLayer; @@ -36,7 +38,10 @@ use tracing_subscriber::EnvFilter; use state::AppState; #[derive(Parser)] -#[command(name = "perfect-postcode", about = "Perfect Postcode property map server")] +#[command( + name = "perfect-postcode", + about = "Perfect Postcode property map server" +)] struct Cli { /// Path to properties.parquet (one row per historical property) #[arg(long)] @@ -129,7 +134,6 @@ struct Cli { /// Google OAuth client secret for PocketBase SSO #[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")] google_oauth_client_secret: String, - } #[tokio::main] @@ -137,8 +141,7 @@ async fn main() -> anyhow::Result<()> { let file_appender = tracing_appender::rolling::daily("logs", "server.log"); let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); - let env_filter = - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::registry() .with(env_filter) @@ -332,10 +335,7 @@ async fn main() -> anyhow::Result<()> { info!("Gemini configured (model: {})", cli.gemini_model); let tt_path = &cli.travel_times; if !tt_path.exists() { - bail!( - "Travel times directory not found: {}", - tt_path.display() - ); + bail!("Travel times directory not found: {}", tt_path.display()); } info!("Loading travel time data from {}", tt_path.display()); let travel_time_store = { @@ -476,7 +476,9 @@ async fn main() -> anyhow::Result<()> { ) .route( "/api/travel-destinations", - get(move |query| routes::get_travel_destinations(state_travel_destinations.clone(), query)), + get(move |query| { + routes::get_travel_destinations(state_travel_destinations.clone(), query) + }), ) .route( "/api/journey", @@ -490,24 +492,34 @@ async fn main() -> anyhow::Result<()> { ) .route( "/api/hexagon-stats", - get(move |ext, query| routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)), + get(move |ext, query| { + routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query) + }), ) .route( "/api/postcode-stats", - get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)), + get(move |ext, query| { + routes::get_postcode_stats(state_postcode_stats.clone(), ext, query) + }), ) .route( "/api/postcode-properties", - get(move |ext, query| routes::get_postcode_properties(state_postcode_properties.clone(), ext, query)), + get(move |ext, query| { + routes::get_postcode_properties(state_postcode_properties.clone(), ext, query) + }), ) .route( "/api/screenshot", - get(move |headers, query| routes::get_screenshot(state_screenshot.clone(), headers, query)), + get(move |headers, query| { + routes::get_screenshot(state_screenshot.clone(), headers, query) + }), ) .route( "/api/export", - get(move |headers, ext, query| routes::get_export(state_export.clone(), headers, ext, query)) - .layer(ConcurrencyLimitLayer::new(3)), + get(move |headers, ext, query| { + routes::get_export(state_export.clone(), headers, ext, query) + }) + .layer(ConcurrencyLimitLayer::new(3)), ) .route("/api/me", get(routes::get_me)) .route( @@ -525,9 +537,7 @@ async fn main() -> anyhow::Result<()> { ) .route( "/api/newsletter", - patch(move |ext, body| { - routes::patch_newsletter(state_newsletter.clone(), ext, body) - }), + patch(move |ext, body| routes::patch_newsletter(state_newsletter.clone(), ext, body)), ) .route( "/api/pricing", @@ -546,8 +556,9 @@ async fn main() -> anyhow::Result<()> { ) .route( "/api/invites", - get(move |ext| routes::get_invites(state_invites_list.clone(), ext)) - .post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)), + get(move |ext| routes::get_invites(state_invites_list.clone(), ext)).post( + move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body), + ), ) .route( "/api/invite/{code}", @@ -591,35 +602,35 @@ async fn main() -> anyhow::Result<()> { ); let app = if let Some(ref dist) = cli.dist { - api.fallback_service( - ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))), - ) + api.fallback_service(ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html")))) } else { api } - .layer(middleware::from_fn(metrics::track_metrics)) - .layer(middleware::from_fn(auth::auth_middleware)) - .layer(middleware::from_fn( - move |req: axum::extract::Request, next: middleware::Next| { - let st = state_crawler.clone(); - async move { - // Inject state into request extensions for auth + OG middleware - let (mut parts, body) = req.into_parts(); - parts.extensions.insert(st); - let req = axum::extract::Request::from_parts(parts, body); - og_middleware::og_middleware(req, next).await - } - }, - )) - .layer(cors) - .layer(CompressionLayer::new().zstd(true).gzip(true)) - .layer(TraceLayer::new_for_http()); + .layer(middleware::from_fn(metrics::track_metrics)) + .layer(middleware::from_fn(auth::auth_middleware)) + .layer(middleware::from_fn( + move |req: axum::extract::Request, next: middleware::Next| { + let st = state_crawler.clone(); + async move { + // Inject state into request extensions for auth + OG middleware + let (mut parts, body) = req.into_parts(); + parts.extensions.insert(st); + let req = axum::extract::Request::from_parts(parts, body); + og_middleware::og_middleware(req, next).await + } + }, + )) + .layer(cors) + .layer(CompressionLayer::new().zstd(true).gzip(true)) + .layer(TraceLayer::new_for_http()); // Lock all current and future memory pages to prevent swapping unsafe { if libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) != 0 { let err = std::io::Error::last_os_error(); - tracing::warn!("mlockall failed (need CAP_IPC_LOCK or sufficient RLIMIT_MEMLOCK): {err}"); + tracing::warn!( + "mlockall failed (need CAP_IPC_LOCK or sufficient RLIMIT_MEMLOCK): {err}" + ); } else { info!("All memory pages locked (mlockall)"); } diff --git a/server-rs/src/og_middleware.rs b/server-rs/src/og_middleware.rs index 95fa358..81e4227 100644 --- a/server-rs/src/og_middleware.rs +++ b/server-rs/src/og_middleware.rs @@ -8,7 +8,8 @@ use axum::response::Response; use crate::state::AppState; -const OG_PLACEHOLDER: &str = r#""#; +const OG_PLACEHOLDER: &str = + r#""#; pub async fn og_middleware(request: Request, next: Next) -> Response { let path = request.uri().path().to_string(); @@ -51,10 +52,7 @@ pub async fn og_middleware(request: Request, next: Next) -> Response { let og_image_url = if is_invite { // Include path= so the screenshot service navigates to /invite/CODE if query_string.is_empty() { - format!( - "{}/api/screenshot?og=1&path={}", - state.public_url, path - ) + format!("{}/api/screenshot?og=1&path={}", state.public_url, path) } else { format!( "{}/api/screenshot?og=1&path={}&{}", diff --git a/server-rs/src/parsing/fields.rs b/server-rs/src/parsing/fields.rs index 70a24b7..2ea2e3c 100644 --- a/server-rs/src/parsing/fields.rs +++ b/server-rs/src/parsing/fields.rs @@ -24,12 +24,7 @@ pub fn parse_field_indices( } match name_to_index.get(name) { Some(&idx) => indices.push(idx), - None => { - return Err(( - StatusCode::BAD_REQUEST, - format!("Unknown field: {}", name), - )) - } + None => return Err((StatusCode::BAD_REQUEST, format!("Unknown field: {}", name))), } } Ok(Some(indices)) diff --git a/server-rs/src/parsing/filters.rs b/server-rs/src/parsing/filters.rs index c7d6bcd..ed5093a 100644 --- a/server-rs/src/parsing/filters.rs +++ b/server-rs/src/parsing/filters.rs @@ -1,20 +1,22 @@ use rustc_hash::{FxHashMap, FxHashSet}; -/// Filter for numeric features: value must be in [min, max] range. +use crate::consts::NAN_U16; +use crate::data::QuantRef; + +/// Filter for numeric features: value must be in [min_u16, max_u16] range (quantized). #[derive(Debug)] pub struct ParsedFilter { pub feat_idx: usize, - pub min: f32, - pub max: f32, + pub min_u16: u16, + pub max_u16: u16, } -/// Filter for enum features: value must be one of the allowed indices. -/// Uses FxHashSet (f32 bits) for O(1) lookups instead of O(n) Vec::contains. +/// Filter for enum features: value must be one of the allowed u16 indices. #[derive(Debug)] pub struct ParsedEnumFilter { pub feat_idx: usize, - /// Allowed enum indices stored as f32 bits for exact comparison - pub allowed: FxHashSet, + /// Allowed enum indices as u16. + pub allowed: FxHashSet, } /// Parse `;;`-separated filter string into numeric and enum filters. @@ -26,6 +28,7 @@ pub fn parse_filters( filter_str: Option<&str>, feature_name_to_index: &FxHashMap, enum_values: &FxHashMap>, + quant: &QuantRef, ) -> Result<(Vec, Vec), String> { let mut numeric = Vec::new(); let mut enums = Vec::new(); @@ -50,20 +53,20 @@ pub fn parse_filters( // Check if this is an enum feature if let Some(values) = enum_values.get(&feat_idx) { - // Enum filter: convert string values to f32 indices (stored as bits for O(1) lookup) - let allowed: FxHashSet = rest + // Enum filter: convert string values to u16 indices + let allowed: FxHashSet = rest .split('|') .filter_map(|value| { let value = value.trim(); values .iter() .position(|existing| existing == value) - .map(|position| (position as f32).to_bits()) + .map(|position| position as u16) }) .collect(); enums.push(ParsedEnumFilter { feat_idx, allowed }); } else { - // Numeric filter: parse min:max + // Numeric filter: parse min:max and encode to u16 let num_parts: Vec<&str> = rest.splitn(2, ':').collect(); if num_parts.len() != 2 { return Err(format!( @@ -78,7 +81,11 @@ pub fn parse_filters( .trim() .parse::() .map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?; - numeric.push(ParsedFilter { feat_idx, min, max }); + numeric.push(ParsedFilter { + feat_idx, + min_u16: quant.encode_min(feat_idx, min), + max_u16: quant.encode_max(feat_idx, max), + }); } } @@ -86,23 +93,22 @@ pub fn parse_filters( } /// Check if a row passes all filters. -/// All features (numeric and enum) are stored in feature_data as f32. +/// All features (numeric and enum) are stored in feature_data as quantized u16. pub fn row_passes_filters( row: usize, filters: &[ParsedFilter], enum_filters: &[ParsedEnumFilter], - feature_data: &[f32], + feature_data: &[u16], num_features: usize, ) -> bool { let base = row * num_features; filters.iter().all(|filter| { - let value = feature_data[base + filter.feat_idx]; - value.is_finite() && value >= filter.min && value <= filter.max + let raw = feature_data[base + filter.feat_idx]; + raw != NAN_U16 && raw >= filter.min_u16 && raw <= filter.max_u16 }) && enum_filters.iter().all(|filter| { - let value = feature_data[base + filter.feat_idx]; - // O(1) lookup using f32 bits as key - value.is_finite() && filter.allowed.contains(&value.to_bits()) + let raw = feature_data[base + filter.feat_idx]; + raw != NAN_U16 && filter.allowed.contains(&raw) }) } @@ -151,52 +157,127 @@ mod tests { map } - fn allowed_set(values: &[f32]) -> FxHashSet { - values.iter().map(|v| v.to_bits()).collect() + /// Build a test QuantRef with known parameters. + /// num_numeric indicates how many of the features are numeric (the rest are enum). + fn test_quant(num_features: usize, num_numeric: usize) -> TestQuant { + // For numeric features: use min=0, range=1000 (simple mapping) + let mut quant_min = vec![0.0f32; num_features]; + let mut quant_range = vec![1000.0f32; num_features]; + let mut dequant_a = vec![1000.0 / crate::consts::QUANT_SCALE; num_features]; + // Enum features: no quantization + for i in num_numeric..num_features { + quant_min[i] = 0.0; + quant_range[i] = 0.0; + dequant_a[i] = 0.0; + } + TestQuant { + dequant_a, + quant_min, + quant_range, + num_numeric, + } + } + + struct TestQuant { + dequant_a: Vec, + quant_min: Vec, + quant_range: Vec, + num_numeric: usize, + } + + impl TestQuant { + fn as_ref(&self) -> QuantRef<'_> { + QuantRef { + dequant_a: &self.dequant_a, + quant_min: &self.quant_min, + quant_range: &self.quant_range, + num_numeric: self.num_numeric, + } + } + + /// Encode a f32 value to u16 using the test quantization parameters. + fn encode(&self, feat_idx: usize, value: f32) -> u16 { + if !value.is_finite() { + return NAN_U16; + } + if feat_idx >= self.num_numeric { + // Enum: store directly + return value as u16; + } + let range = self.quant_range[feat_idx]; + if range > 0.0 { + let normalized = (value - self.quant_min[feat_idx]) / range; + (normalized * crate::consts::QUANT_SCALE) + .round() + .clamp(0.0, crate::consts::QUANT_SCALE) as u16 + } else { + 0 + } + } } #[test] fn parse_filters_numeric() { + let tq = test_quant(3, 2); let (numeric, enums) = parse_filters( Some("price:100:500"), &feature_name_to_index(), &enum_values(), + &tq.as_ref(), ) .unwrap(); assert_eq!(numeric.len(), 1); assert_eq!(numeric[0].feat_idx, 0); - assert_eq!(numeric[0].min, 100.0); - assert_eq!(numeric[0].max, 500.0); + // min_u16 should be floor(100/1000 * 65534) = floor(6553.4) = 6553 + assert_eq!(numeric[0].min_u16, 6553); + // max_u16 should be ceil(500/1000 * 65534) = ceil(32767.0) = 32767 + assert_eq!(numeric[0].max_u16, 32767); assert!(enums.is_empty()); } #[test] fn parse_filters_enum() { - let (numeric, enums) = - parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values()).unwrap(); + let tq = test_quant(3, 2); + let (numeric, enums) = parse_filters( + Some("rating:A|C"), + &feature_name_to_index(), + &enum_values(), + &tq.as_ref(), + ) + .unwrap(); assert!(numeric.is_empty()); assert_eq!(enums.len(), 1); assert_eq!(enums[0].feat_idx, 2); - assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // A = index 0 - assert!(enums[0].allowed.contains(&(2.0_f32).to_bits())); // C = index 2 + assert!(enums[0].allowed.contains(&0)); // A = index 0 + assert!(enums[0].allowed.contains(&2)); // C = index 2 assert_eq!(enums[0].allowed.len(), 2); } #[test] fn parse_filters_empty() { - let (n, e) = parse_filters(None, &feature_name_to_index(), &enum_values()).unwrap(); + let tq = test_quant(3, 2); + let (n, e) = + parse_filters(None, &feature_name_to_index(), &enum_values(), &tq.as_ref()).unwrap(); assert!(n.is_empty() && e.is_empty()); - let (n, e) = parse_filters(Some(""), &feature_name_to_index(), &enum_values()).unwrap(); + let (n, e) = parse_filters( + Some(""), + &feature_name_to_index(), + &enum_values(), + &tq.as_ref(), + ) + .unwrap(); assert!(n.is_empty() && e.is_empty()); } #[test] fn parse_filters_unknown_feature_errors() { + let tq = test_quant(3, 2); let result = parse_filters( Some("unknown:1:2"), &feature_name_to_index(), &enum_values(), + &tq.as_ref(), ); assert!(result.is_err()); assert!(result.unwrap_err().contains("Unknown feature")); @@ -204,12 +285,13 @@ mod tests { #[test] fn row_passes_numeric_filter() { + let tq = test_quant(1, 1); let filters = vec![ParsedFilter { feat_idx: 0, - min: 10.0, - max: 20.0, + min_u16: tq.as_ref().encode_min(0, 10.0), + max_u16: tq.as_ref().encode_max(0, 20.0), }]; - let data = vec![15.0, 5.0, f32::NAN]; + let data = vec![tq.encode(0, 15.0), tq.encode(0, 5.0), NAN_U16]; assert!(row_passes_filters(0, &filters, &[], &data, 1)); assert!(!row_passes_filters(1, &filters, &[], &data, 1)); @@ -218,12 +300,18 @@ mod tests { #[test] fn row_passes_enum_filter() { - let allowed: FxHashSet = [0.0_f32, 2.0].iter().map(|v| v.to_bits()).collect(); + let tq = test_quant(1, 0); // all enum + let allowed: FxHashSet = [0u16, 2].into_iter().collect(); let filters = vec![ParsedEnumFilter { feat_idx: 0, allowed, }]; - let data = vec![0.0, 1.0, 2.0, f32::NAN]; + let data = vec![ + tq.encode(0, 0.0), + tq.encode(0, 1.0), + tq.encode(0, 2.0), + NAN_U16, + ]; assert!(row_passes_filters(0, &[], &filters, &data, 1)); assert!(!row_passes_filters(1, &[], &filters, &data, 1)); @@ -233,10 +321,12 @@ mod tests { #[test] fn parse_multiple_numeric_filters() { + let tq = test_quant(4, 2); let (numeric, _enums) = parse_filters( Some("Price:100000:500000;;Area:50:200"), &extended_feature_map(), &extended_enum_values(), + &tq.as_ref(), ) .unwrap(); @@ -247,10 +337,12 @@ mod tests { #[test] fn parse_mixed_filters() { + let tq = test_quant(4, 2); let (numeric, enums) = parse_filters( Some("Price:100000:500000;;Type:Semi|Terraced"), &extended_feature_map(), &extended_enum_values(), + &tq.as_ref(), ) .unwrap(); @@ -260,10 +352,12 @@ mod tests { #[test] fn parse_invalid_numeric_format_errors() { + let tq = test_quant(4, 2); let result = parse_filters( Some("Price:not_a_number:500000"), &extended_feature_map(), &extended_enum_values(), + &tq.as_ref(), ); assert!(result.is_err()); @@ -272,25 +366,29 @@ mod tests { #[test] fn parse_enum_with_unknown_value() { + let tq = test_quant(4, 2); let (_numeric, enums) = parse_filters( Some("Type:Detached|Unknown|Flats/Maisonettes"), &extended_feature_map(), &extended_enum_values(), + &tq.as_ref(), ) .unwrap(); assert_eq!(enums.len(), 1); - assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // Detached - assert!(enums[0].allowed.contains(&(3.0_f32).to_bits())); // Flats/Maisonettes + assert!(enums[0].allowed.contains(&0)); // Detached + assert!(enums[0].allowed.contains(&3)); // Flats/Maisonettes assert_eq!(enums[0].allowed.len(), 2); } #[test] fn parse_filter_with_whitespace() { + let tq = test_quant(4, 2); let (numeric, enums) = parse_filters( Some("Price : 100000 : 500000 ;; Type : Detached | Flats/Maisonettes"), &extended_feature_map(), &extended_enum_values(), + &tq.as_ref(), ) .unwrap(); @@ -300,25 +398,30 @@ mod tests { #[test] fn row_passes_no_filters() { - let feature_data = vec![100.0_f32, 50.0]; + let tq = test_quant(2, 2); + let feature_data = vec![tq.encode(0, 100.0), tq.encode(1, 50.0)]; assert!(row_passes_filters(0, &[], &[], &feature_data, 2)); } #[test] fn row_passes_numeric_filter_at_boundary() { + let tq = test_quant(1, 1); let filters = vec![ParsedFilter { feat_idx: 0, - min: 100.0, - max: 200.0, + min_u16: tq.as_ref().encode_min(0, 100.0), + max_u16: tq.as_ref().encode_max(0, 200.0), }]; - assert!(row_passes_filters(0, &filters, &[], &[100.0], 1)); - assert!(row_passes_filters(0, &filters, &[], &[200.0], 1)); + let data_100 = vec![tq.encode(0, 100.0)]; + let data_200 = vec![tq.encode(0, 200.0)]; + assert!(row_passes_filters(0, &filters, &[], &data_100, 1)); + assert!(row_passes_filters(0, &filters, &[], &data_200, 1)); } #[test] fn row_fails_empty_enum_filter() { - let feature_data = vec![1.0_f32]; + let tq = test_quant(1, 0); + let feature_data = vec![tq.encode(0, 1.0)]; let enum_filters = vec![ParsedEnumFilter { feat_idx: 0, allowed: FxHashSet::default(), @@ -328,16 +431,17 @@ mod tests { #[test] fn multiple_filters_all_must_pass() { - let feature_data = vec![150.0_f32, 1.0]; + let tq = test_quant(2, 1); // feat 0 = numeric, feat 1 = enum + let feature_data = vec![tq.encode(0, 150.0), tq.encode(1, 1.0)]; let numeric_filters = vec![ParsedFilter { feat_idx: 0, - min: 100.0, - max: 200.0, + min_u16: tq.as_ref().encode_min(0, 100.0), + max_u16: tq.as_ref().encode_max(0, 200.0), }]; let enum_filters = vec![ParsedEnumFilter { feat_idx: 1, - allowed: allowed_set(&[1.0, 2.0]), + allowed: [1u16, 2].into_iter().collect(), }]; assert!(row_passes_filters( @@ -350,7 +454,7 @@ mod tests { let enum_filters_fail = vec![ParsedEnumFilter { feat_idx: 1, - allowed: allowed_set(&[0.0, 2.0]), + allowed: [0u16, 2].into_iter().collect(), }]; assert!(!row_passes_filters( 0, @@ -363,17 +467,21 @@ mod tests { #[test] fn row_major_layout_correct_indexing() { + let tq = test_quant(2, 1); // feat 0 = numeric, feat 1 = enum let feature_data = vec![ - 100.0_f32, 0.0, // Row 0 - 200.0, 1.0, // Row 1 - 300.0, 2.0, // Row 2 + tq.encode(0, 100.0), + tq.encode(1, 0.0), // Row 0 + tq.encode(0, 200.0), + tq.encode(1, 1.0), // Row 1 + tq.encode(0, 300.0), + tq.encode(1, 2.0), // Row 2 ]; let num_features = 2; let filters = vec![ParsedFilter { feat_idx: 0, - min: 150.0, - max: 250.0, + min_u16: tq.as_ref().encode_min(0, 150.0), + max_u16: tq.as_ref().encode_max(0, 250.0), }]; assert!(!row_passes_filters( @@ -401,22 +509,24 @@ mod tests { #[test] fn filter_at_float_precision_boundary() { - let value = 100.0_f32; + let tq = test_quant(1, 1); + let value = tq.encode(0, 100.0); let filter = ParsedFilter { feat_idx: 0, - min: 100.0 - f32::EPSILON, - max: 100.0 + f32::EPSILON, + min_u16: tq.as_ref().encode_min(0, 100.0 - f32::EPSILON), + max_u16: tq.as_ref().encode_max(0, 100.0 + f32::EPSILON), }; assert!(row_passes_filters(0, &[filter], &[], &[value], 1)); } #[test] - fn enum_filter_with_fractional_index() { - let feature_data = vec![1.5_f32]; // Not exactly 1.0 or 2.0 + fn enum_filter_rejects_non_matching_index() { + let tq = test_quant(1, 0); // all enum + let feature_data = vec![tq.encode(0, 3.0)]; // index 3 let enum_filters = vec![ParsedEnumFilter { feat_idx: 0, - allowed: allowed_set(&[1.0, 2.0]), + allowed: [1u16, 2].into_iter().collect(), }]; assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1)); diff --git a/server-rs/src/parsing/h3.rs b/server-rs/src/parsing/h3.rs index 35d3147..c053af6 100644 --- a/server-rs/src/parsing/h3.rs +++ b/server-rs/src/parsing/h3.rs @@ -39,7 +39,10 @@ pub fn cell_for_row( return max_cell; } let cell = h3o::CellIndex::try_from(max_cell).expect("precomputed H3 cell must be valid"); - u64::from(cell.parent(h3_res).expect("parent resolution must be valid for precomputed cell")) + u64::from( + cell.parent(h3_res) + .expect("parent resolution must be valid for precomputed cell"), + ) } /// Whether the given resolution requires computing a parent from precomputed cells. diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs index b1a4aa3..22f1153 100644 --- a/server-rs/src/pocketbase.rs +++ b/server-rs/src/pocketbase.rs @@ -214,11 +214,7 @@ async fn find_users_collection_id( /// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection. /// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields. -async fn ensure_user_fields( - client: &Client, - base_url: &str, - token: &str, -) -> anyhow::Result<()> { +async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/users"); let resp = client .get(&url) @@ -243,7 +239,12 @@ async fn ensure_user_fields( let has_ai_tokens_used = fields.iter().any(|f| f["name"] == "ai_tokens_used"); let has_ai_tokens_week = fields.iter().any(|f| f["name"] == "ai_tokens_week"); - if has_is_admin && has_subscription && has_newsletter && has_ai_tokens_used && has_ai_tokens_week { + if has_is_admin + && has_subscription + && has_newsletter + && has_ai_tokens_used + && has_ai_tokens_week + { info!("PocketBase users collection already has all required fields"); return Ok(()); } diff --git a/server-rs/src/routes.rs b/server-rs/src/routes.rs index 946e763..2be3dbf 100644 --- a/server-rs/src/routes.rs +++ b/server-rs/src/routes.rs @@ -7,24 +7,24 @@ pub(crate) mod hexagons; mod invites; mod journey; mod me; +mod newsletter; mod pb_proxy; mod places; mod pois; mod postcode_properties; mod postcode_stats; mod postcodes; +pub(crate) mod pricing; pub(crate) mod properties; mod screenshot; mod shorten; mod stats; mod streetview; mod stripe_webhook; -mod newsletter; -pub(crate) mod pricing; mod tiles; -pub(crate) mod travel_time; mod travel_destinations; mod travel_modes; +pub(crate) mod travel_time; pub use ai_filters::{build_system_prompt, post_ai_filters}; pub use checkout::post_checkout; @@ -32,21 +32,21 @@ pub use export::get_export; pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse}; pub use hexagon_stats::get_hexagon_stats; pub use hexagons::get_hexagons; +pub use invites::{get_invite, get_invites, post_invites, post_redeem_invite}; +pub use journey::get_journey; pub use me::get_me; +pub use newsletter::patch_newsletter; pub use pb_proxy::proxy_to_pocketbase; pub use places::get_places; pub use pois::{get_poi_categories, get_pois}; pub use postcode_properties::get_postcode_properties; pub use postcode_stats::get_postcode_stats; pub use postcodes::{get_postcode_lookup, get_postcodes}; +pub use pricing::get_pricing; pub use properties::get_hexagon_properties; pub use screenshot::{fetch_screenshot_bytes, get_screenshot}; pub use shorten::{get_short_url, post_shorten}; pub use streetview::get_streetview; -pub use invites::{get_invite, get_invites, post_invites, post_redeem_invite}; -pub use journey::get_journey; -pub use newsletter::patch_newsletter; -pub use pricing::get_pricing; pub use stripe_webhook::post_stripe_webhook; pub use tiles::{get_style, get_tile, init_tile_reader}; pub use travel_destinations::get_travel_destinations; diff --git a/server-rs/src/routes/ai_filters.rs b/server-rs/src/routes/ai_filters.rs index b8911b5..93b44b3 100644 --- a/server-rs/src/routes/ai_filters.rs +++ b/server-rs/src/routes/ai_filters.rs @@ -125,7 +125,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu let slug_set = match tt_store.destinations.get(mode) { Some(slugs) => slugs, - None => return json!({ "results": [], "message": format!("No travel data available for mode '{}'", mode) }), + None => { + return json!({ "results": [], "message": format!("No travel data available for mode '{}'", mode) }) + } }; // Find places matching the query that have travel time data. @@ -154,7 +156,11 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu matches.truncate(10); if matches.is_empty() { - info!(query = query, mode = mode, "Destination search returned no results"); + info!( + query = query, + mode = mode, + "Destination search returned no results" + ); return json!({ "results": [], "message": format!("No travel time data available for '{}' by {}. This destination cannot be used as a travel time filter.", query, mode) @@ -597,10 +603,7 @@ pub async fn post_ai_filters( .and_then(|c| c.get("content")) .ok_or_else(|| { warn!("Malformed Gemini response: missing candidates[0].content"); - ( - StatusCode::BAD_GATEWAY, - "Malformed Gemini response".into(), - ) + (StatusCode::BAD_GATEWAY, "Malformed Gemini response".into()) })?; let parts = candidate @@ -608,10 +611,7 @@ pub async fn post_ai_filters( .and_then(|p| p.as_array()) .ok_or_else(|| { warn!("Malformed Gemini response: missing parts array"); - ( - StatusCode::BAD_GATEWAY, - "Malformed Gemini response".into(), - ) + (StatusCode::BAD_GATEWAY, "Malformed Gemini response".into()) })?; // Check if the model made a function call. @@ -621,17 +621,10 @@ pub async fn post_ai_filters( let fn_name = fc.get("name").and_then(|n| n.as_str()).unwrap_or(""); let fn_args = fc.get("args").cloned().unwrap_or(json!({})); - info!( - function = fn_name, - round = round, - "AI called tool" - ); + info!(function = fn_name, round = round, "AI called tool"); let fn_result = if fn_name == "search_destinations" { - let query = fn_args - .get("query") - .and_then(|q| q.as_str()) - .unwrap_or(""); + let query = fn_args.get("query").and_then(|q| q.as_str()).unwrap_or(""); let mode = fn_args .get("mode") .and_then(|m| m.as_str()) @@ -710,7 +703,10 @@ pub async fn post_ai_filters( } // Exhausted tool rounds without getting a final text response - warn!("AI exhausted {} tool-calling rounds without final response", MAX_TOOL_ROUNDS); + warn!( + "AI exhausted {} tool-calling rounds without final response", + MAX_TOOL_ROUNDS + ); Err(( StatusCode::BAD_GATEWAY, "AI could not complete the request".into(), @@ -746,7 +742,11 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec anyhow::Result<()> { let pb_url = state.pocketbase_url.trim_end_matches('/'); - let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?; + let token = auth_superuser( + &state.http_client, + pb_url, + &state.pocketbase_admin_email, + &state.pocketbase_admin_password, + ) + .await?; let url = format!("{pb_url}/api/collections/users/records/{user_id}"); let resp = state @@ -151,10 +160,7 @@ async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> { /// Check if a referral invite code exists and is unused. async fn validate_referral_invite(state: &AppState, code: &str) -> bool { // Only allow alphanumeric codes to prevent PocketBase filter injection - if code.is_empty() - || code.len() > 20 - || !code.bytes().all(|b| b.is_ascii_alphanumeric()) - { + if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { return false; } diff --git a/server-rs/src/routes/export.rs b/server-rs/src/routes/export.rs index 987b81f..7a45ba4 100644 --- a/server-rs/src/routes/export.rs +++ b/server-rs/src/routes/export.rs @@ -12,6 +12,8 @@ use serde::Deserialize; use tracing::{info, warn}; use crate::auth::OptionalUser; +use crate::consts::NAN_U16; +use crate::data::QuantRef; use crate::licensing::check_license_bounds; use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters}; use crate::routes::{fetch_screenshot_bytes, FeatureInfo}; @@ -50,18 +52,20 @@ impl PostcodeExportAgg { #[inline] fn add_row( &mut self, - feature_data: &[f32], + feature_data: &[u16], row: usize, num_features: usize, enum_indices: &FxHashMap, + quant: &QuantRef, ) { self.count += 1; let base = row * num_features; let row_slice = &feature_data[base..base + num_features]; - for (feat_idx, &value) in row_slice.iter().enumerate() { - if !value.is_finite() { + for (feat_idx, &raw) in row_slice.iter().enumerate() { + if raw == NAN_U16 { continue; } + let value = quant.decode(feat_idx, raw); if enum_indices.contains_key(&feat_idx) { *self .enum_freqs @@ -131,10 +135,12 @@ pub async fn get_export( check_license_bounds(&user.0, (south, west, north, east))?; + let quant = state.data.quant_ref(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, + &quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let filters_str = params.filters; @@ -188,6 +194,7 @@ pub async fn get_export( let t0 = std::time::Instant::now(); let num_features = state.data.num_features; let feature_data = &state.data.feature_data; + let quant = state.data.quant_ref(); let feature_names = &state.data.feature_names; let enum_values = &state.data.enum_values; let postcode_data = &state.postcode_data; @@ -222,7 +229,7 @@ pub async fn get_export( for (pc_idx, rows) in postcode_rows { let mut agg = PostcodeExportAgg::new(num_features); for &row in &rows { - agg.add_row(feature_data, row, num_features, &enum_indices); + agg.add_row(feature_data, row, num_features, &enum_indices, &quant); } if agg.count > 0 { postcode_aggs.push((pc_idx, agg)); @@ -470,11 +477,9 @@ pub async fn get_export( let mode_idx = mode_f32 as usize; if let Some(values) = enum_values.get(&feat_idx) { if mode_idx < values.len() { - sheet - .write_string(row, col, &values[mode_idx]) - .map_err(|e| { - format!("Failed to write enum value: {e}") - })?; + sheet.write_string(row, col, &values[mode_idx]).map_err( + |e| format!("Failed to write enum value: {e}"), + )?; } } } @@ -486,13 +491,11 @@ pub async fn get_export( if let Some(fmt) = feat_num_fmts.get(&feat_idx) { sheet .write_number_with_format(row, col, mean, fmt) - .map_err(|e| { - format!("Failed to write numeric value: {e}") - })?; + .map_err(|e| format!("Failed to write numeric value: {e}"))?; } else { - sheet.write_number(row, col, mean).map_err(|e| { - format!("Failed to write numeric value: {e}") - })?; + sheet + .write_number(row, col, mean) + .map_err(|e| format!("Failed to write numeric value: {e}"))?; } } } diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs index 84ff2cc..e1646b5 100644 --- a/server-rs/src/routes/hexagon_stats.rs +++ b/server-rs/src/routes/hexagon_stats.rs @@ -100,10 +100,12 @@ pub async fn get_hexagon_stats( check_license_bounds(&user.0, h3_bounds)?; let h3_str = params.h3; + let quant = state.data.quant_ref(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, + &quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); @@ -114,10 +116,7 @@ pub async fn get_hexagon_stats( // Load travel time data for central_postcode selection (if requested) let journey_travel_data = match (¶ms.journey_mode, ¶ms.journey_slug) { (Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => { - state - .travel_time_store - .get(mode, slug) - .ok() + state.travel_time_store.get(mode, slug).ok() } _ => None, }; @@ -209,18 +208,13 @@ pub async fn get_hexagon_stats( None }; - let price_history = stats::extract_price_history( - &matching_rows, - feature_data, - num_features, - &state.feature_name_to_index, - ); + let price_history = + stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index); let (numeric_features, enum_features_out) = stats::compute_feature_stats( &matching_rows, - feature_data, + &state.data, &state.data.feature_names, - num_features, &state.data.enum_values, &state.data.feature_stats, fields_specified, diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs index fe082b2..d62743f 100644 --- a/server-rs/src/routes/hexagons.rs +++ b/server-rs/src/routes/hexagons.rs @@ -40,7 +40,6 @@ pub struct HexagonParams { travel: Option, } - /// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds. #[allow(clippy::too_many_arguments)] fn build_feature_maps( @@ -144,10 +143,12 @@ pub async fn get_hexagons( check_license_bounds(&user.0, (south, west, north, east))?; } + let quant = state.data.quant_ref(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, + &quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); @@ -185,6 +186,7 @@ pub async fn get_hexagons( let num_features = state.data.num_features; let feature_data = &state.data.feature_data; + let quant = state.data.quant_ref(); let (pc_interner, pc_keys) = state.data.postcode_parts(); let min_keys = &state.min_keys; let max_keys = &state.max_keys; @@ -196,8 +198,9 @@ pub async fn get_hexagons( let need_parent = needs_parent(resolution); let mut groups: FxHashMap = FxHashMap::default(); - let mut travel_aggs: Vec> = - (0..travel_entries.len()).map(|_| FxHashMap::default()).collect(); + let mut travel_aggs: Vec> = (0..travel_entries.len()) + .map(|_| FxHashMap::default()) + .collect(); // Main aggregation loop let aggregate_row = @@ -246,9 +249,15 @@ pub async fn get_hexagons( .entry(cell_id) .or_insert_with(|| Aggregator::new(num_features)); if let Some(sel_indices) = field_indices.as_deref() { - aggregation.add_row_selective(feature_data, row, num_features, sel_indices); + aggregation.add_row_selective( + feature_data, + row, + num_features, + sel_indices, + &quant, + ); } else { - aggregation.add_row(feature_data, row, num_features); + aggregation.add_row(feature_data, row, num_features, &quant); } // Aggregate travel time diff --git a/server-rs/src/routes/invites.rs b/server-rs/src/routes/invites.rs index 1d773ae..a52a207 100644 --- a/server-rs/src/routes/invites.rs +++ b/server-rs/src/routes/invites.rs @@ -107,13 +107,23 @@ pub async fn post_invites( } else if user.subscription == "licensed" { "referral" } else { - return (StatusCode::FORBIDDEN, "Only licensed users can create invites").into_response(); + return ( + StatusCode::FORBIDDEN, + "Only licensed users can create invites", + ) + .into_response(); }; let code = generate_invite_code(); let pb_url = state.pocketbase_url.trim_end_matches('/'); - let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await + let token = match auth_superuser( + &state.http_client, + pb_url, + &state.pocketbase_admin_email, + &state.pocketbase_admin_password, + ) + .await { Ok(t) => t, Err(err) => { @@ -190,7 +200,13 @@ pub async fn get_invite( let pb_url = state.pocketbase_url.trim_end_matches('/'); - let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await + let token = match auth_superuser( + &state.http_client, + pb_url, + &state.pocketbase_admin_email, + &state.pocketbase_admin_password, + ) + .await { Ok(t) => t, Err(err) => { @@ -205,9 +221,12 @@ pub async fn get_invite( urlencoding::encode(&filter) ); - let res = match state.http_client.get(&url) + let res = match state + .http_client + .get(&url) .header("Authorization", format!("Bearer {token}")) - .send().await + .send() + .await { Ok(r) => r, Err(err) => { @@ -235,8 +254,7 @@ pub async fn get_invite( // Look up inviter's name (email local part) let invited_by = if !created_by.is_empty() { - let user_url = - format!("{pb_url}/api/collections/users/records/{created_by}"); + let user_url = format!("{pb_url}/api/collections/users/records/{created_by}"); match state .http_client .get(&user_url) @@ -245,8 +263,7 @@ pub async fn get_invite( .await { Ok(resp) if resp.status().is_success() => { - let user_body: serde_json::Value = - resp.json().await.unwrap_or_default(); + let user_body: serde_json::Value = resp.json().await.unwrap_or_default(); user_body["email"] .as_str() .and_then(|e| e.split('@').next()) @@ -305,7 +322,13 @@ pub async fn post_redeem_invite( let pb_url = state.pocketbase_url.trim_end_matches('/'); - let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await + let token = match auth_superuser( + &state.http_client, + pb_url, + &state.pocketbase_admin_email, + &state.pocketbase_admin_password, + ) + .await { Ok(t) => t, Err(err) => { @@ -315,18 +338,18 @@ pub async fn post_redeem_invite( }; // Look up invite - let filter = format!( - "code=\"{}\" && used_by_id=\"\"", - req.code - ); + let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code); let lookup_url = format!( "{pb_url}/api/collections/invites/records?filter={}&perPage=1", urlencoding::encode(&filter) ); - let res = match state.http_client.get(&lookup_url) + let res = match state + .http_client + .get(&lookup_url) .header("Authorization", format!("Bearer {token}")) - .send().await + .send() + .await { Ok(r) => r, Err(err) => { @@ -428,7 +451,10 @@ pub async fn post_redeem_invite( ("cancel_url", cancel_url), ("client_reference_id", user.id.clone()), ("customer_email", user.email.clone()), - ("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()), + ( + "discounts[0][coupon]", + state.stripe_referral_coupon_id.clone(), + ), ]; let stripe_res = state @@ -442,10 +468,7 @@ pub async fn post_redeem_invite( match stripe_res { Ok(resp) if resp.status().is_success() => { let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default(); - let checkout_url = stripe_body["url"] - .as_str() - .unwrap_or_default() - .to_string(); + let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string(); info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created"); Json(RedeemResponse { result: "checkout".to_string(), @@ -494,9 +517,7 @@ pub async fn get_invites( format!("created_by=\"{}\"", user.id) }; - let mut url = format!( - "{pb_url}/api/collections/invites/records?sort=-created&perPage=200" - ); + let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200"); if !filter.is_empty() { url.push_str(&format!("&filter={}", urlencoding::encode(&filter))); } diff --git a/server-rs/src/routes/newsletter.rs b/server-rs/src/routes/newsletter.rs index 9c37efc..04ff8b1 100644 --- a/server-rs/src/routes/newsletter.rs +++ b/server-rs/src/routes/newsletter.rs @@ -27,7 +27,13 @@ pub async fn patch_newsletter( let pb_url = state.pocketbase_url.trim_end_matches('/'); - let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await + let token = match auth_superuser( + &state.http_client, + pb_url, + &state.pocketbase_admin_email, + &state.pocketbase_admin_password, + ) + .await { Ok(t) => t, Err(err) => { diff --git a/server-rs/src/routes/pois.rs b/server-rs/src/routes/pois.rs index 7de6ffb..5bbe5a8 100644 --- a/server-rs/src/routes/pois.rs +++ b/server-rs/src/routes/pois.rs @@ -85,7 +85,7 @@ pub async fn get_pois( let pois: Vec = matching_rows .iter() .map(|&row| POI { - id: state.poi_data.id[row].clone(), + id: state.poi_data.id(row).to_string(), name: state.poi_data.name[row].clone(), category: state.poi_data.category.get(row).to_string(), group: state.poi_data.group.get(row).to_string(), diff --git a/server-rs/src/routes/postcode_properties.rs b/server-rs/src/routes/postcode_properties.rs index 55268b1..4043923 100644 --- a/server-rs/src/routes/postcode_properties.rs +++ b/server-rs/src/routes/postcode_properties.rs @@ -46,10 +46,12 @@ pub async fn get_postcode_properties( check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?; + let quant = state.data.quant_ref(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, + &quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); @@ -105,8 +107,11 @@ pub async fn get_postcode_properties( .take(limit) .map(|&row| { super::properties::build_property( - row, &state, feature_names, feature_name_to_index, feature_data, - num_features, enum_values, + row, + &state, + feature_names, + feature_name_to_index, + enum_values, ) }) .collect(); diff --git a/server-rs/src/routes/postcode_stats.rs b/server-rs/src/routes/postcode_stats.rs index fbb9fb8..fdab6f5 100644 --- a/server-rs/src/routes/postcode_stats.rs +++ b/server-rs/src/routes/postcode_stats.rs @@ -50,10 +50,12 @@ pub async fn get_postcode_stats( // License check using postcode centroid check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?; + let quant = state.data.quant_ref(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, + &quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); @@ -96,18 +98,13 @@ pub async fn get_postcode_stats( let total_count = matching_rows.len(); - let price_history = stats::extract_price_history( - &matching_rows, - feature_data, - num_features, - &state.feature_name_to_index, - ); + let price_history = + stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index); let (numeric_features, enum_features_out) = stats::compute_feature_stats( &matching_rows, - feature_data, + &state.data, &state.data.feature_names, - num_features, &state.data.enum_values, &state.data.feature_stats, fields_specified, diff --git a/server-rs/src/routes/postcodes.rs b/server-rs/src/routes/postcodes.rs index 7376398..db52f37 100644 --- a/server-rs/src/routes/postcodes.rs +++ b/server-rs/src/routes/postcodes.rs @@ -76,10 +76,12 @@ pub async fn get_postcodes( check_license_bounds(&user.0, (south, west, north, east))?; + let quant = state.data.quant_ref(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, + &quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); @@ -118,6 +120,7 @@ pub async fn get_postcodes( let num_features = state.data.num_features; let feature_data = &state.data.feature_data; + let quant = state.data.quant_ref(); let min_keys = &state.min_keys; let max_keys = &state.max_keys; let avg_keys = &state.avg_keys; @@ -185,18 +188,20 @@ pub async fn get_postcodes( .or_insert_with(|| Aggregator::new(num_features)); for &row in rows { if has_selective { - agg.add_row_selective(feature_data, row, num_features, sel_indices); + agg.add_row_selective(feature_data, row, num_features, sel_indices, &quant); } else { - agg.add_row(feature_data, row, num_features); + agg.add_row(feature_data, row, num_features, &quant); } } // Aggregate travel times for this postcode if has_travel { let postcode = &postcode_data.postcodes[pc_idx]; - let tt_aggs = travel_aggs - .entry(pc_idx) - .or_insert_with(|| (0..travel_entries.len()).map(|_| TravelTimeAgg::new()).collect()); + let tt_aggs = travel_aggs.entry(pc_idx).or_insert_with(|| { + (0..travel_entries.len()) + .map(|_| TravelTimeAgg::new()) + .collect() + }); for (ti, entry) in travel_entries.iter().enumerate() { if let Some(row_data) = travel_data[ti].get(postcode.as_str()) { let minutes = if entry.use_best { diff --git a/server-rs/src/routes/pricing.rs b/server-rs/src/routes/pricing.rs index 60be2f3..0008b07 100644 --- a/server-rs/src/routes/pricing.rs +++ b/server-rs/src/routes/pricing.rs @@ -12,9 +12,9 @@ use crate::state::AppState; /// Pricing tiers: (cumulative user cap, price in pence). const TIERS: &[(u64, u64)] = &[ (1, 0), // First 10 users: free - (20, 1000), // Next 10: £10 - (45, 2500), // Next 25: £25 - (95, 5000), // Next 50: £50 + (20, 1000), // Next 10: £10 + (45, 2500), // Next 25: £25 + (95, 5000), // Next 50: £50 ]; const FINAL_PRICE_PENCE: u64 = 10000; // £100 after 95 @@ -45,7 +45,13 @@ pub fn price_for_count(count: u64) -> u64 { /// Count users with subscription="licensed" in PocketBase. pub async fn count_licensed_users(state: &AppState) -> anyhow::Result { let pb_url = state.pocketbase_url.trim_end_matches('/'); - let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?; + let token = auth_superuser( + &state.http_client, + pb_url, + &state.pocketbase_admin_email, + &state.pocketbase_admin_password, + ) + .await?; let filter = "subscription=\"licensed\""; let url = format!( diff --git a/server-rs/src/routes/properties.rs b/server-rs/src/routes/properties.rs index a5809c6..45d484c 100644 --- a/server-rs/src/routes/properties.rs +++ b/server-rs/src/routes/properties.rs @@ -11,12 +11,12 @@ use tracing::{info, warn}; use crate::auth::OptionalUser; use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT}; +use crate::data::RenovationEvent; use crate::licensing::check_license_bounds; use crate::parsing::{ cell_for_row, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters, validate_h3_resolution, }; -use crate::data::RenovationEvent; use crate::state::AppState; #[derive(Deserialize)] @@ -78,18 +78,17 @@ fn non_empty_string(text: &str) -> Option { } /// Look up an enum feature value by column name. -/// Uses the unified feature model: enum values stored as f32 indices in feature_data. +/// Uses the unified feature model: enum values stored as u16 indices in feature_data. fn lookup_enum_value( feature_name_to_index: &FxHashMap, - feature_data: &[f32], - num_features: usize, + data: &crate::data::PropertyData, enum_values: &FxHashMap>, row: usize, name: &str, ) -> Option { let &feat_idx = feature_name_to_index.get(name)?; let values = enum_values.get(&feat_idx)?; - let value = feature_data[row * num_features + feat_idx]; + let value = data.get_feature(row, feat_idx); if value.is_finite() { let idx = value as usize; values.get(idx).cloned() @@ -103,17 +102,14 @@ pub fn build_property( state: &AppState, feature_names: &[String], feature_name_to_index: &FxHashMap, - feature_data: &[f32], - num_features: usize, enum_values: &FxHashMap>, ) -> Property { let mut features = FxHashMap::default(); - let base = row * num_features; for (feat_idx, feat_name) in feature_names.iter().enumerate() { if enum_values.contains_key(&feat_idx) { continue; } - let value = feature_data[base + feat_idx]; + let value = state.data.get_feature(row, feat_idx); if value.is_finite() { features.insert(feat_name.clone(), value); } @@ -124,26 +120,50 @@ pub fn build_property( postcode: non_empty_string(state.data.postcode(row)), is_construction_date_approximate: Some(state.data.is_approx_build_date(row)), property_type: lookup_enum_value( - feature_name_to_index, feature_data, num_features, enum_values, row, "Property type", + feature_name_to_index, + &state.data, + enum_values, + row, + "Property type", ), built_form: lookup_enum_value( - feature_name_to_index, feature_data, num_features, enum_values, row, "Property type/built form", + feature_name_to_index, + &state.data, + enum_values, + row, + "Property type/built form", ), duration: lookup_enum_value( - feature_name_to_index, feature_data, num_features, enum_values, row, "Leasehold/Freehold", + feature_name_to_index, + &state.data, + enum_values, + row, + "Leasehold/Freehold", ), current_energy_rating: lookup_enum_value( - feature_name_to_index, feature_data, num_features, enum_values, row, "Current energy rating", + feature_name_to_index, + &state.data, + enum_values, + row, + "Current energy rating", ), potential_energy_rating: lookup_enum_value( - feature_name_to_index, feature_data, num_features, enum_values, row, "Potential energy rating", + feature_name_to_index, + &state.data, + enum_values, + row, + "Potential energy rating", ), lat: state.data.lat[row], lon: state.data.lon[row], renovation_history: state.data.renovation_history(row).to_vec(), listing_features: state.data.listing_features(row).to_vec(), listing_status: lookup_enum_value( - feature_name_to_index, feature_data, num_features, enum_values, row, "Listing status", + feature_name_to_index, + &state.data, + enum_values, + row, + "Listing status", ), listing_url: state.data.listing_url(row).map(String::from), property_sub_type: state.data.property_sub_type(row).map(String::from), @@ -175,10 +195,12 @@ pub async fn get_hexagon_properties( check_license_bounds(&user.0, h3_bounds)?; let h3_str = params.h3; + let quant = state.data.quant_ref(); let (parsed_filters, parsed_enum_filters) = parse_filters( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, + &quant, ) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); @@ -233,8 +255,11 @@ pub async fn get_hexagon_properties( .take(limit) .map(|&row| { build_property( - row, &state, feature_names, feature_name_to_index, feature_data, - num_features, enum_values, + row, + &state, + feature_names, + feature_name_to_index, + enum_values, ) }) .collect(); diff --git a/server-rs/src/routes/screenshot.rs b/server-rs/src/routes/screenshot.rs index 9404958..1311c46 100644 --- a/server-rs/src/routes/screenshot.rs +++ b/server-rs/src/routes/screenshot.rs @@ -7,7 +7,7 @@ use tracing::{info, warn}; use crate::state::AppState; -/// Fetch a PNG screenshot from the screenshot service. +/// Fetch a JPEG screenshot from the screenshot service. /// Used by both the `/api/screenshot` proxy and the xlsx export. pub async fn fetch_screenshot_bytes( state: &AppState, @@ -31,9 +31,7 @@ pub async fn fetch_screenshot_bytes( Ok(resp) => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - Err(format!( - "Screenshot service returned {status}: {body}" - )) + Err(format!("Screenshot service returned {status}: {body}")) } Err(err) => Err(format!("Failed to reach screenshot service: {err}")), } @@ -51,7 +49,7 @@ pub async fn get_screenshot( Ok(bytes) => ( StatusCode::OK, [ - (header::CONTENT_TYPE, "image/png"), + (header::CONTENT_TYPE, "image/jpeg"), (header::CACHE_CONTROL, "public, max-age=86400"), ], bytes, diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs index d4feaec..e63b498 100644 --- a/server-rs/src/routes/shorten.rs +++ b/server-rs/src/routes/shorten.rs @@ -65,9 +65,7 @@ pub async fn post_shorten(state: Arc, Json(req): Json) let res = state .http_client - .post(format!( - "{pb_url}/api/collections/short_urls/records" - )) + .post(format!("{pb_url}/api/collections/short_urls/records")) .header("Authorization", format!("Bearer {token}")) .json(&record) .send() @@ -95,10 +93,7 @@ pub async fn post_shorten(state: Arc, Json(req): Json) } pub async fn get_short_url(state: Arc, Path(code): Path) -> Response { - if code.is_empty() - || code.len() > 20 - || !code.bytes().all(|b| b.is_ascii_alphanumeric()) - { + if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { return StatusCode::BAD_REQUEST.into_response(); } diff --git a/server-rs/src/routes/stats.rs b/server-rs/src/routes/stats.rs index b94ab66..2b77e96 100644 --- a/server-rs/src/routes/stats.rs +++ b/server-rs/src/routes/stats.rs @@ -4,15 +4,14 @@ use rustc_hash::FxHashMap; use tracing::warn; use crate::consts::MAX_PRICE_HISTORY_POINTS; -use crate::data::FeatureStats; +use crate::data::{FeatureStats, PropertyData}; use super::hexagon_stats::{EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint}; /// Extract price history (year, price) pairs from matching rows, downsampled if needed. pub fn extract_price_history( matching_rows: &[usize], - feature_data: &[f32], - num_features: usize, + data: &PropertyData, feature_name_to_index: &FxHashMap, ) -> Vec { let year_idx = feature_name_to_index @@ -24,8 +23,8 @@ pub fn extract_price_history( let mut points: Vec = matching_rows .iter() .filter_map(|&row| { - let year = feature_data[row * num_features + yi]; - let price = feature_data[row * num_features + pi]; + let year = data.get_feature(row, yi); + let price = data.get_feature(row, pi); if year.is_finite() && price.is_finite() { Some(PricePoint { year, price }) } else { @@ -55,9 +54,8 @@ pub fn extract_price_history( #[allow(clippy::too_many_arguments)] pub fn compute_feature_stats( matching_rows: &[usize], - feature_data: &[f32], + data: &PropertyData, feature_names: &[String], - num_features: usize, enum_values: &FxHashMap>, feature_stats_data: &[FeatureStats], fields_specified: bool, @@ -74,7 +72,7 @@ pub fn compute_feature_stats( if let Some(ev) = enum_values.get(&feature_index) { let mut value_counts = vec![0u64; ev.len()]; for &row in matching_rows { - let value = feature_data[row * num_features + feature_index]; + let value = data.get_feature(row, feature_index); if value.is_finite() { let idx = value as usize; if idx < value_counts.len() { @@ -123,7 +121,7 @@ pub fn compute_feature_stats( }; for &row in matching_rows { - let value = feature_data[row * num_features + feature_index]; + let value = data.get_feature(row, feature_index); if value.is_finite() { count += 1; if value < min_value { diff --git a/server-rs/src/routes/stripe_webhook.rs b/server-rs/src/routes/stripe_webhook.rs index 4076ba8..092e373 100644 --- a/server-rs/src/routes/stripe_webhook.rs +++ b/server-rs/src/routes/stripe_webhook.rs @@ -55,7 +55,10 @@ pub async fn post_stripe_webhook( ) -> Response { let webhook_secret = &state.stripe_webhook_secret; - let sig_header = match headers.get("stripe-signature").and_then(|h| h.to_str().ok()) { + let sig_header = match headers + .get("stripe-signature") + .and_then(|h| h.to_str().ok()) + { Some(s) => s, None => { warn!("Missing Stripe-Signature header"); @@ -90,8 +93,13 @@ pub async fn post_stripe_webhook( // Update user subscription to "licensed" via PocketBase superuser auth let pb_url = state.pocketbase_url.trim_end_matches('/'); - let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password) - .await + let token = match auth_superuser( + &state.http_client, + pb_url, + &state.pocketbase_admin_email, + &state.pocketbase_admin_password, + ) + .await { Ok(t) => t, Err(err) => { @@ -112,12 +120,18 @@ pub async fn post_stripe_webhook( match res { Ok(resp) if resp.status().is_success() => { state.token_cache.invalidate_by_user_id(user_id); - info!(user_id, "User subscription updated to licensed via Stripe webhook"); + info!( + user_id, + "User subscription updated to licensed via Stripe webhook" + ); } Ok(resp) => { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); - warn!(user_id, "Failed to update user subscription ({status}): {text}"); + warn!( + user_id, + "Failed to update user subscription ({status}): {text}" + ); } Err(err) => { warn!(user_id, "PocketBase request error in webhook: {err}"); diff --git a/server-rs/src/routes/tiles.rs b/server-rs/src/routes/tiles.rs index ca5ae99..837d76f 100644 --- a/server-rs/src/routes/tiles.rs +++ b/server-rs/src/routes/tiles.rs @@ -71,7 +71,10 @@ pub async fn get_style( .unwrap_or_default(); // Build absolute tile URL using the configured public URL (not the Host header) - let tile_url = format!("{}/api/tiles/{{z}}/{{x}}/{{y}}", public_url.trim_end_matches('/')); + let tile_url = format!( + "{}/api/tiles/{{z}}/{{x}}/{{y}}", + public_url.trim_end_matches('/') + ); let style = build_style(is_dark, &layers, &tile_url); Ok(( diff --git a/server-rs/src/state.rs b/server-rs/src/state.rs index 99ebd58..ee442ad 100644 --- a/server-rs/src/state.rs +++ b/server-rs/src/state.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use rustc_hash::FxHashMap; use crate::auth::TokenCache; -use crate::data::{POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore}; +use crate::data::{ + POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore, +}; use crate::routes::FeaturesResponse; use crate::utils::GridIndex;