good stuff

This commit is contained in:
Andras Schmelczer 2026-03-15 21:10:54 +00:00
parent ea8389ef40
commit f4de0eeb9f
39 changed files with 5165 additions and 348 deletions

View file

@ -38,7 +38,7 @@ services:
SCREENSHOT_URL: http://screenshot:8002
GEMINI_API_KEY: AIzaSyC2mQDcEwILHM3uOE2C-lxUQbQrKTX9Xi4
GEMINI_MODEL: gemini-3-flash-preview
PUBLIC_URL: https://perfectpostcodes.schmelczer.dev
PUBLIC_URL: https://perfect-postcodes.co.uk
GOOGLE_MAPS_API_KEY: "AIzaSyBgBn9LjrxHCjb9j1LZbLYpEdCJj-NkHPY"
STRIPE_SECRET_KEY: sk_test_51SyVcePRjj2bdyn1HLkatQ5onwp8kamm41tjMcRdxXnJYWVPsVd9usMTOSNtNdGhrjbsrtNbgTdKXICg2qBiocEn00PvNDC0d3
STRIPE_WEBHOOK_SECRET: whsec_pIkGZblYlcN2VesTxq4pk1cDqdxOQ1y0

View file

@ -92,5 +92,14 @@ def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
},
)
# Derive asking price per sqm for buy listings
if channel == "buy":
df = df.with_columns(
(pl.col("Asking price") / pl.col("Total floor area (sqm)"))
.round(0)
.cast(pl.Int32, strict=False)
.alias("Asking price per sqm"),
)
df.write_parquet(path)
log.info("Wrote %d properties to %s", len(df), path)

View file

@ -0,0 +1,9 @@
User-agent: *
Allow: /
Disallow: /api/
Disallow: /metrics
Disallow: /health
Disallow: /pb/
Disallow: /s/
Sitemap: https://perfect-postcode.co.uk/sitemap.xml

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://perfect-postcode.co.uk/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/dashboard</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/learn</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/pricing</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

View file

@ -18,6 +18,7 @@ import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
import { useAuth } from './hooks/useAuth';
import { useTelemetry } from './hooks/useTelemetry';
import { useSavedSearches } from './hooks/useSavedSearches';
import { useSavedProperties } from './hooks/useSavedProperties';
@ -107,6 +108,7 @@ export default function App() {
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
useTelemetry();
const {
user,
loading: authLoading,
@ -337,12 +339,14 @@ export default function App() {
searches={savedSearches.searches}
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
savedProperties={savedProperties.properties}
propertiesLoading={savedProperties.loading}
onDeleteProperty={savedProperties.deleteProperty}
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
onOpenProperty={(postcode) => {
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
}}

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
@ -71,6 +71,63 @@ function DeleteDialog({
);
}
function NotesInput({
value,
onSave,
}: {
value: string;
onSave: (notes: string) => void;
}) {
const [text, setText] = useState(value);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Sync from parent when value changes externally
useEffect(() => {
setText(value);
}, [value]);
const autoResize = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, []);
// Resize on mount and when text changes
useEffect(() => {
autoResize();
}, [text, autoResize]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
setText(newText);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onSave(newText), 600);
},
[onSave]
);
// Save immediately on blur
const handleBlur = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (text !== value) onSave(text);
}, [text, value, onSave]);
return (
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jot down your thoughts..."
rows={1}
className="w-full resize-none overflow-hidden rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 px-3 py-1.5 text-sm text-warm-700 dark:text-warm-300 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
/>
);
}
function formatPropertyPrice(data: SavedPropertyData): string | null {
if (data.askingPrice) return `£${formatNumber(data.askingPrice)}`;
if (data.askingRent) return `£${formatNumber(data.askingRent)}/mo`;
@ -93,11 +150,13 @@ function SavedSearchesTab({
searches,
loading,
onDelete,
onUpdateNotes,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -181,10 +240,17 @@ function SavedSearchesTab({
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">
{summarizeParams(search.params)}
</p>
<div className="mb-3">
<NotesInput
value={search.notes}
onSave={(notes) => onUpdateNotes(search.id, notes)}
/>
</div>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
@ -234,11 +300,13 @@ function SavedPropertiesTab({
properties,
loading,
onDelete,
onUpdateNotes,
onOpen,
}: {
properties: SavedProperty[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onOpen: (postcode: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -294,10 +362,17 @@ function SavedPropertiesTab({
{details && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">{details}</p>
)}
<p className="text-xs text-warm-400 dark:text-warm-500 mb-3">
<p className="text-xs text-warm-400 dark:text-warm-500 mb-2">
{formatRelativeTime(prop.created)}
</p>
<div className="mb-3">
<NotesInput
value={prop.notes}
onSave={(notes) => onUpdateNotes(prop.id, notes)}
/>
</div>
<div className="flex gap-2">
<button
onClick={() => onOpen(prop.postcode)}
@ -344,19 +419,23 @@ export function SavedPage({
searches,
searchesLoading,
onDeleteSearch,
onUpdateSearchNotes,
onOpenSearch,
savedProperties,
propertiesLoading,
onDeleteProperty,
onUpdatePropertyNotes,
onOpenProperty,
}: {
searches: SavedSearch[];
searchesLoading: boolean;
onDeleteSearch: (id: string) => Promise<void>;
onUpdateSearchNotes: (id: string, notes: string) => void;
onOpenSearch: (params: string) => void;
savedProperties: SavedProperty[];
propertiesLoading: boolean;
onDeleteProperty: (id: string) => Promise<void>;
onUpdatePropertyNotes: (id: string, notes: string) => void;
onOpenProperty: (postcode: string) => void;
}) {
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
@ -396,6 +475,7 @@ export function SavedPage({
searches={searches}
loading={searchesLoading}
onDelete={onDeleteSearch}
onUpdateNotes={onUpdateSearchNotes}
onOpen={onOpenSearch}
/>
) : (
@ -403,6 +483,7 @@ export function SavedPage({
properties={savedProperties}
loading={propertiesLoading}
onDelete={onDeleteProperty}
onUpdateNotes={onUpdatePropertyNotes}
onOpen={onOpenProperty}
/>
)}

View file

@ -167,15 +167,15 @@ export default function InvitePage({
return (
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900">
<div className="w-[90%] bg-white dark:bg-warm-800 rounded-3xl border border-warm-200 dark:border-warm-700 shadow-xl overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-20 py-16 text-center">
<h2 className="text-[144px] leading-tight font-bold text-white mb-6">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-16 py-10 text-center">
<h2 className="text-7xl leading-tight font-bold text-white mb-3">
{isValid
? isAdminInvite
? 'You\u2019re invited!'
: 'Special offer!'
: 'Perfect Postcode'}
</h2>
<p className="text-warm-300 text-6xl leading-snug">
<p className="text-warm-300 text-3xl leading-snug">
{isValid && invite.invited_by
? isAdminInvite
? `${invite.invited_by} has invited you to get free lifetime access.`
@ -187,19 +187,19 @@ export default function InvitePage({
: 'Explore every neighbourhood in England'}
</p>
</div>
<div className="px-20 py-14 text-center">
<div className="px-16 py-8 text-center">
{isValid && !isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="mb-8">
<span className="text-warm-400 dark:text-warm-500 line-through text-8xl mr-6">
<div className="mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-5xl mr-4">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-[192px] leading-none font-extrabold text-teal-600 dark:text-teal-400">
<span className="text-[96px] leading-none font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-4 text-6xl">/once</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">/once</span>
</div>
)}
<p className="text-warm-600 dark:text-warm-400 text-6xl">
<p className="text-warm-600 dark:text-warm-400 text-3xl">
Property prices, energy ratings, crime stats, school ratings &amp; more
</p>
</div>

View file

@ -132,101 +132,176 @@ interface FAQItem {
answer: string;
}
const FAQ_ITEMS: FAQItem[] = [
interface FAQSection {
title: string;
items: FAQItem[];
}
const FAQ_SECTIONS: FAQSection[] = [
{
question: 'Are the prices shown current market values?',
answer:
'No. The prices shown are the last known sale price recorded by HM Land Registry, which is the price the property actually sold for. A property last sold in 2005 will show its 2005 price. These are not valuations or estimates of current market value. You can use the "Last known price" filter to focus on properties sold within a recent date range, and compare against the median rental prices for the local authority.',
title: 'Finding Your Area',
items: [
{
question: "I don't even know which areas to look at \u2014 can this help with that?",
answer:
"That's exactly what it's for. Set your filters (budget, commute time, low crime, good schools \u2014 whatever matters) and the map lights up to show you where ticks every box. No more Googling \"best areas to live near Manchester\" at 1am.",
},
{
question: "I'm moving somewhere I've never been \u2014 how do I even start?",
answer:
"Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes \u2014 it's like having a local's knowledge of every neighbourhood in England.",
},
{
question: 'How do I find areas that tick all my boxes at once?',
answer:
'Stack multiple filters \u2014 say, crime below average, good schools, and commute under 40 minutes \u2014 then colour the map by price to spot the affordable sweet spots. The map updates live as you drag sliders, so you can watch neighbourhoods light up or drop off in real time.',
},
],
},
{
question: 'What is the "Estimated current price" and how is it calculated?',
answer:
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time - this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records - such as extensions or renovations - also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
title: 'Commute & Travel',
items: [
{
question: 'Can I see how long my commute would actually be from different areas?',
answer:
"Set your workplace as a destination and we'll colour every postcode by journey time \u2014 by car, bike, or public transport. Filter to your max commute and the rest disappears, so you're only looking at areas that actually work.",
},
{
question: 'How is that better than checking Google Maps?',
answer:
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
},
],
},
{
question: 'How are current for-sale and for-rent listings found?',
answer:
'Properties currently on the market are sourced by periodically searching independent property portals (Rightmove, OnTheMarket, and Zoopla). These listings are fuzzy-matched by address to existing Land Registry records so that current asking prices appear alongside the historical sale price, EPC data, and all area-level statistics. You can filter by "Listing status" to show only properties currently for sale or for rent. When you click on a hexagon, you\'ll also see direct links to search Rightmove, OnTheMarket, and Zoopla for that area, pre-filled with your active price filters.',
title: 'Budget & Value',
items: [
{
question: 'How do I find areas where I get the most space for my money?',
answer:
"Filter by price per sqm \u2014 you'll instantly see which postcodes give you the most square footage per pound. Pair it with the energy rating filter to avoid cheap-but-freezing money pits.",
},
{
question: "How do I make sure a cheap area isn't cheap for a reason?",
answer:
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable AND scores well on the stuff that matters, that's your hidden gem \u2014 not just a cheap postcode with a catch.",
},
],
},
{
question: 'What area does this cover?',
answer:
'England. The core datasets - Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax - all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
title: 'Safety & Neighbourhood',
items: [
{
question: 'How can I check if an area is safe before I move there?',
answer:
"We overlay real police-recorded crime data \u2014 broken down by type \u2014 onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers, so you're not relying on gut feeling.",
},
{
question: 'I keep finding flats that look great online, then the area turns out to be grim.',
answer:
"That's why we built this. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map \u2014 so you know what a neighbourhood is actually like before you waste a Saturday viewing.",
},
],
},
{
question: 'Why is data missing for my property?',
answer:
'There are a few common reasons. If a property has never been sold (or was last sold before Land Registry digital records began in 1995), there will be no price record. EPC data may be missing if the property has never had an energy assessment, or if the owner has opted out of public disclosure. Floor area, number of rooms, and energy ratings all come from EPC records, so a missing EPC means those fields will be blank. Finally, the fuzzy address matching between EPC and Land Registry records occasionally fails for unusual addresses.',
title: 'Families & Schools',
items: [
{
question: 'Can I find areas with good schools AND low crime in one search?',
answer:
'Yes \u2014 stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, then watch the map highlight only the areas that tick every box. No more cross-referencing five different websites with a spreadsheet.',
},
{
question: 'How do I know if a neighbourhood has parks and playgrounds nearby?',
answer:
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
},
],
},
{
question: "How do I find areas that match what I'm looking for?",
answer:
'Use the Filters panel on the left. Add filters for the features you care about - for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source - this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
title: 'Environment & Quality of Life',
items: [
{
question: 'Can I find energy-efficient homes that aren\'t on a noisy road?',
answer:
'Filter by EPC rating (A\u2013C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
},
{
question: 'Does it show flood or subsidence risk?',
answer:
"We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before you fall in love with a property. Filter it out early and save yourself the surveyor's surprise.",
},
{
question: 'Can I find areas with fast broadband that are actually quiet?',
answer:
'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to spot the sweet spots instantly.',
},
],
},
{
question: 'How does the travel time feature work?',
answer:
'Click the travel time icon in the filters panel, search for a destination (any address or postcode in England), and choose a transport mode (car, bicycle, walking, or public transport). The map will colour hexagons by average journey time to that destination. You can add a time range filter to only show areas within, say, 30 minutes. Multiple destinations can be added simultaneously to find areas that are well-connected to several places.',
title: 'Why Perfect Postcode',
items: [
{
question: 'I already use Rightmove \u2014 what does this add?',
answer:
"Rightmove shows you houses. We show you areas. You'll see 56 layers of data \u2014 crime rates, school ratings, broadband speeds, noise levels, deprivation scores \u2014 all on one map, so you can judge a neighbourhood before you even look at listings.",
},
{
question: "Can't I just research all this myself for free?",
answer:
'You could spend weeks cross-referencing police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have all 56 datasets filterable and colour-coded on one map in seconds. Your time has a price too.',
},
{
question: 'Where does the data actually come from?',
answer:
"Every dataset comes from official UK government sources \u2014 Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up \u2014 you can verify any record against the original source.",
},
],
},
{
question: "Can I export the data I'm looking at?",
answer:
'Yes. Use the export button to download the currently filtered properties within your map view as an Excel spreadsheet. The export respects all your active filters, so you can narrow down to exactly the properties you want before downloading.',
title: 'Pricing & Access',
items: [
{
question: 'Is it really worth paying for a property search tool?',
answer:
"You're making a decision worth \u00a3200k\u2013\u00a3500k or more. Even spotting one red flag \u2014 a noisy road, poor broadband, rising crime \u2014 that changes your mind could save you years of regret. This costs less than a single viewing trip in petrol.",
},
{
question: "Is this another subscription that'll drain my account?",
answer:
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you're curious about a new area, and it's still there if you ever move again.",
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore all features within inner London (roughly zones 1\u20132). To access data for the rest of England, you need lifetime access.',
},
{
question: 'Can I get a refund?',
answer:
'Yes \u2014 we offer a 30-day money-back guarantee. If you\u2019re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
},
],
},
{
question: 'What do the deprivation scores mean?',
answer:
'The English Indices of Deprivation 2025 rank every small area (LSOA, roughly 1,500 people) in England from most to least deprived. A rank of 1 means the most deprived area in the country. The scores cover seven domains: Income, Employment, Education, Health, Crime, Barriers to Housing & Services, and Living Environment. Each domain can be filtered independently. Lower rank numbers indicate higher deprivation.',
},
{
question: 'How reliable is the crime data at this scale?',
answer:
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA - an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
},
{
question: 'What does the school rating represent?',
answer:
'The school rating is the average Ofsted inspection outcome for state-funded schools near each postcode. Ofsted grades schools from 1 (Outstanding) to 4 (Inadequate). A value of 1.5 for a postcode means the nearby schools average between Outstanding and Good. This covers primary and secondary schools with inspection results as at April 2025.',
},
{
question: 'What happens when I zoom in very far?',
answer:
'At lower zoom levels, properties are grouped into hexagons that get smaller as you zoom in. When you zoom past level 15, the map switches from hexagons to individual postcode polygons, showing the actual postcode boundary shapes. Click any postcode polygon to see the properties within it.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL. Anyone who opens that link will see the same map position, zoom level, filters, pinned feature, and active POI categories.',
},
{
question: 'How can I remove my property from the map?',
answer:
'Property sale prices are public records from HM Land Registry and cannot be removed. EPC data (energy rating, floor area, number of rooms, etc.) can be removed by opting out of public disclosure through the government\u2019s official process at gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure. Once opted out, your EPC data will no longer appear in future data updates.',
},
{
question: 'How often is the data updated?',
answer:
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source. All updates are included with your access at no extra cost.',
},
{
question: 'What data is included?',
answer:
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need lifetime access.',
},
{
question: 'What does "lifetime" mean?',
answer:
'Your access never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
},
{
question: 'Can I get a refund?',
answer:
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@perfect-postcode.co.uk within 30 days of purchase for a full refund.',
title: 'Tips & Tricks',
items: [
{
question: 'How do I use the AI filter instead of adding filters one by one?',
answer:
'Type what you want in plain English \u2014 something like "quiet area near good schools with fast broadband under \u00a3400k" \u2014 and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
},
{
question: 'Can I save a search and come back to it later?',
answer:
'Hit the save button and everything is captured \u2014 your filters, zoom level, and which data layer you\u2019re colouring by. Pick up exactly where you left off or share the link with your partner.',
},
{
question: "Can I export the data I'm looking at?",
answer:
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
},
],
},
];
@ -409,12 +484,21 @@ export default function LearnPage() {
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Perfect Postcode works, where the data comes from, and how
to use the map.
Whether you&apos;re buying, renting, or just exploring &mdash; here&apos;s how Perfect
Postcode helps you find the right area.
</p>
<div className="space-y-3">
{FAQ_ITEMS.map((item, index) => (
<FAQItemCard key={index} item={item} />
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} item={item} />
))}
</div>
</div>
))}
</div>
</div>

View file

@ -61,6 +61,18 @@ export default memo(function AiFilterInput({
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState(false);
const loadingMessage = useLoadingMessage(loading);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!expanded || loading) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setExpanded(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [expanded, loading]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
@ -94,7 +106,7 @@ export default memo(function AiFilterInput({
if (!expanded) {
return (
<div className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<button
type="button"
onClick={() => setExpanded(true)}
@ -110,7 +122,7 @@ export default memo(function AiFilterInput({
}
return (
<div className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>

View file

@ -43,7 +43,7 @@ export default function ExternalSearchLinks({
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{urls.rightmove ? (
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
Rightmove
@ -59,6 +59,11 @@ export default function ExternalSearchLinks({
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
Zoopla
</a>
{urls.openrent && (
<a href={urls.openrent} target="_blank" rel="noopener noreferrer" className={linkClass}>
OpenRent
</a>
)}
</div>
</div>
);

View file

@ -178,11 +178,19 @@ export default memo(function Filters({
[features, enabledFeatures]
);
const parkedFiltersRef = useRef<FeatureFilters>({});
const handleListingSelect = useCallback(
(type: ListingType) => {
// Track what will be active after swaps (to avoid conflicts with restoration)
const activeAfterSwaps = new Set<string>();
for (const name of Object.keys(filters)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) continue;
if (isAllowed(name, type)) {
activeAfterSwaps.add(name);
continue;
}
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
@ -191,15 +199,42 @@ export default memo(function Filters({
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
activeAfterSwaps.add(counterpart);
swapped = true;
break;
}
}
if (!swapped) {
parkedFiltersRef.current[name] = filters[name];
onRemoveFilter(name);
}
}
// Restore parked filters that are now allowed in the new mode
const restored: string[] = [];
for (const [name, value] of Object.entries(parkedFiltersRef.current)) {
if (isAllowed(name, type) && !activeAfterSwaps.has(name)) {
onFilterChange(name, value);
activeAfterSwaps.add(name);
restored.push(name);
} else if (!isAllowed(name, type)) {
// Try restoring as linked counterpart
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type) && !activeAfterSwaps.has(counterpart)) {
onFilterChange(counterpart, value);
activeAfterSwaps.add(counterpart);
restored.push(name);
break;
}
}
}
}
for (const name of restored) {
delete parkedFiltersRef.current[name];
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
@ -232,7 +267,7 @@ export default memo(function Filters({
const handleAddTravelTimeAndScroll = useCallback(
(mode: TransportMode) => {
expandGroup('Travel Time');
expandGroup('Transport');
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
onTravelTimeAddEntry(mode);
},
@ -253,6 +288,16 @@ export default memo(function Filters({
[enabledFeatureList]
);
// Ensure "Transport" group exists in active filters when travel time entries are present
const mergedGroups = useMemo(() => {
if (travelTimeEntries.length === 0) return enabledGroups;
if (enabledGroups.some((g) => g.name === 'Transport')) return enabledGroups;
const groups = [...enabledGroups];
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
return groups;
}, [enabledGroups, travelTimeEntries.length]);
const percentileScales = useMemo(() => {
const scales = new Map<string, PercentileScale>();
for (const f of features) {
@ -396,13 +441,13 @@ export default memo(function Filters({
<div className="flex items-center justify-between">
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
/>
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
@ -460,7 +505,6 @@ export default memo(function Filters({
<div className="flex items-center justify-between gap-1">
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
className="min-w-0 shrink"
/>
@ -468,6 +512,7 @@ export default memo(function Filters({
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>

View file

@ -159,13 +159,6 @@ export default function Header({
>
Invite
</a>
<a
href={PAGE_PATHS.account}
className={tabClass('account')}
onClick={(e) => navLink('account', e)}
>
Account
</a>
</>
)}
<a
@ -245,7 +238,7 @@ export default function Header({
{!isMobile && (
<>
{user ? (
<UserMenu user={user} onLogout={onLogout} />
<UserMenu user={user} theme={theme} onToggleTheme={onToggleTheme} onLogout={onLogout} />
) : (
<>
<button
@ -275,8 +268,8 @@ export default function Header({
</button>
)}
{/* Theme toggle (desktop only) */}
{!isMobile && (
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
{!isMobile && !user && (
<button
onClick={onToggleTheme}
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"

View file

@ -1,7 +1,19 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
export default function UserMenu({
user,
theme,
onToggleTheme,
onLogout,
}: {
user: AuthUser;
theme: 'light' | 'dark';
onToggleTheme: () => void;
onLogout: () => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -48,6 +60,17 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
</div>
</div>
<div className="p-1">
<button
onClick={onToggleTheme}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
{theme === 'light' ? (
<SunIcon className="w-4 h-4" />
) : (
<MoonIcon className="w-4 h-4" />
)}
Theme: {theme === 'light' ? 'Light' : 'Dark'}
</button>
<a
href="/account"
onClick={() => setOpen(false)}

View file

@ -24,6 +24,7 @@ export interface SavedProperty {
address: string;
postcode: string;
data: SavedPropertyData;
notes: string;
created: string;
}
@ -58,6 +59,7 @@ export function useSavedProperties(userId: string | null) {
address: raw.address as string,
postcode: raw.postcode as string,
data,
notes: (raw.notes as string) || '',
created: r.created,
};
})
@ -135,6 +137,15 @@ export function useSavedProperties(userId: string | null) {
[properties]
);
const updatePropertyNotes = useCallback(async (id: string, notes: string) => {
try {
await pb.collection('saved_properties').update(id, { notes });
setProperties((prev) => prev.map((p) => (p.id === id ? { ...p, notes } : p)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update notes');
}
}, []);
return {
properties,
loading,
@ -144,5 +155,6 @@ export function useSavedProperties(userId: string | null) {
deleteProperty,
isPropertySaved,
getSavedPropertyId,
updatePropertyNotes,
};
}

View file

@ -8,6 +8,7 @@ export interface SavedSearch {
name: string;
params: string;
screenshotUrl: string;
notes: string;
created: string;
}
@ -34,6 +35,7 @@ export function useSavedSearches(userId: string | null) {
screenshotUrl: (r as Record<string, unknown>).screenshot
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
: '',
notes: ((r as Record<string, unknown>).notes as string) || '',
created: r.created,
}))
);
@ -72,9 +74,10 @@ export function useSavedSearches(userId: string | null) {
})
.then((blob) => {
const patch = new FormData();
patch.append('screenshot', blob, 'screenshot.png');
patch.append('screenshot', blob, 'screenshot.jpg');
return pb.collection('saved_searches').update(record.id, patch);
})
.then(() => fetchSearches())
.catch((err) => {
console.warn('Background screenshot failed:', err);
});
@ -100,5 +103,23 @@ export function useSavedSearches(userId: string | null) {
}
}, []);
return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch };
const updateSearchNotes = useCallback(async (id: string, notes: string) => {
try {
await pb.collection('saved_searches').update(id, { notes });
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update notes');
}
}, []);
return {
searches,
loading,
saving,
error,
fetchSearches,
saveSearch,
deleteSearch,
updateSearchNotes,
};
}

View file

@ -79,7 +79,12 @@ export function buildPropertySearchUrls({
location,
filters,
rightmoveLocationId,
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
}: SearchUrlOptions): {
rightmove: string | null;
onthemarket: string;
zoopla: string;
openrent: string | null;
} | null {
const { postcode, resolution, isPostcode } = location;
if (!postcode) return null;
@ -192,5 +197,27 @@ export function buildPropertySearchUrls({
}
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
return { rightmove, onthemarket, zoopla };
// OpenRent — rent mode only
const listingStatus = filters['Listing status'];
const isRent =
Array.isArray(listingStatus) &&
typeof listingStatus[0] === 'string' &&
(listingStatus as string[]).includes('For rent');
let openrent: string | null = null;
if (isRent) {
const orParams = new URLSearchParams();
orParams.set('term', postcode);
const rentFilter = filters['Asking rent (monthly)'];
const minRent =
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
const maxRent =
Array.isArray(rentFilter) && typeof rentFilter[1] === 'number' ? rentFilter[1] : undefined;
if (minRent !== undefined) orParams.set('prices_min', String(Math.round(minRent)));
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
openrent = `https://www.openrent.com/properties-to-rent?${orParams.toString()}`;
}
return { rightmove, onthemarket, zoopla, openrent };
}

3833
grafana-dashboard.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,10 @@ export class ScreenshotCache {
normalized.tab = params.get('tab')!;
}
if (params.get('og')) {
normalized.og = params.get('og')!;
}
if (params.get('path')) {
normalized.path = params.get('path')!;
}

View file

@ -180,29 +180,49 @@ async function releasePage(page: Page): Promise<void> {
* 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.
*
* Retries with exponential backoff if the frontend isn't up yet (common during
* docker-compose startup where the frontend may still be installing/building).
*/
export async function initialize(appUrl: string): Promise<void> {
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
const MAX_RETRIES = 10;
const INITIAL_DELAY_MS = 2_000;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const page = await createPage();
try {
await page.waitForFunction('window.__screenshot_ready === true', {
timeout: READY_TIMEOUT,
await page.goto(`${appUrl}/?screenshot=1`, {
waitUntil: 'load',
timeout: 30_000,
});
} catch {
// Non-fatal — cache will still have JS/CSS/tiles from the partial load
// 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()}`);
await page.close().catch(() => {});
await warmPool();
return;
} catch (err) {
await page.close().catch(() => {});
if (attempt === MAX_RETRIES) {
console.warn(`Pre-warm failed after ${MAX_RETRIES} attempts (non-fatal):`, err);
} else {
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt - 1);
console.log(`Pre-warm attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${(delay / 1000).toFixed(0)}s...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
console.log(`Pre-warm complete. Cache: ${networkCache.stats()}`);
} catch (err) {
console.warn('Pre-warm failed (non-fatal):', err);
} finally {
await page.close().catch(() => {});
}
// Even if pre-warm failed, still warm the page pool so first real
// screenshot requests don't pay for page creation
await warmPool();
}

View file

@ -52,6 +52,23 @@ impl Aggregator {
}
}
/// Merge another aggregator's results into this one.
pub fn merge(&mut self, other: &Aggregator) {
self.count += other.count;
for i in 0..self.mins.len() {
if other.feat_counts[i] > 0 {
if other.mins[i] < self.mins[i] {
self.mins[i] = other.mins[i];
}
if other.maxs[i] > self.maxs[i] {
self.maxs[i] = other.maxs[i];
}
self.sums[i] += other.sums[i];
self.feat_counts[i] += other.feat_counts[i];
}
}
}
/// Add a row, only aggregating the features at the given indices.
#[inline]
pub fn add_row_selective(

View file

@ -38,11 +38,12 @@ struct Properties {
pub struct PostcodeData {
/// Postcode strings
pub postcodes: Vec<String>,
/// All polygon parts per postcode: polygons[i] = list of outer rings
/// Single Polygon → 1 ring, MultiPolygon → N rings
pub polygons: Vec<Vec<Vec<[f32; 2]>>>,
/// Centroid (lat, lon) for lookups
pub centroids: Vec<(f32, f32)>,
/// Precomputed AABB per postcode: (south, west, north, east) as f32
pub aabbs: Vec<(f32, f32, f32, f32)>,
/// Precomputed GeoJSON geometry Value per postcode
pub geometries: Vec<serde_json::Value>,
/// Lookup from postcode string to index
pub postcode_to_idx: FxHashMap<String, usize>,
}
@ -96,6 +97,7 @@ impl PostcodeData {
let mut local_postcodes = Vec::new();
let mut local_polygons = Vec::new();
let mut local_centroids = Vec::new();
let mut local_aabbs: Vec<(f32, f32, f32, f32)> = Vec::new();
for feature in collection.features {
let postcode = feature.properties.postcodes;
@ -140,20 +142,44 @@ impl PostcodeData {
(sum_lat / count, sum_lon / count)
};
// Compute AABB across all rings
let (mut aabb_south, mut aabb_north) = (f32::INFINITY, f32::NEG_INFINITY);
let (mut aabb_west, mut aabb_east) = (f32::INFINITY, f32::NEG_INFINITY);
for ring in &rings {
for &[lon, lat] in ring {
if lat < aabb_south {
aabb_south = lat;
}
if lat > aabb_north {
aabb_north = lat;
}
if lon < aabb_west {
aabb_west = lon;
}
if lon > aabb_east {
aabb_east = lon;
}
}
}
local_postcodes.push(postcode);
local_polygons.push(rings);
local_centroids.push(centroid);
local_aabbs.push((aabb_south, aabb_west, aabb_north, aabb_east));
}
Ok::<_, anyhow::Error>((local_postcodes, local_polygons, local_centroids))
Ok::<_, anyhow::Error>((local_postcodes, local_polygons, local_centroids, local_aabbs))
})
.collect::<Result<Vec<_>, _>>()?;
let mut aabbs: Vec<(f32, f32, f32, f32)> = Vec::new();
// Flatten results
for (local_postcodes, local_polygons, local_centroids) in file_results {
for (local_postcodes, local_polygons, local_centroids, local_aabbs) in file_results {
postcodes.extend(local_postcodes);
polygons.extend(local_polygons);
centroids.extend(local_centroids);
aabbs.extend(local_aabbs);
}
debug!(
@ -167,12 +193,49 @@ impl PostcodeData {
postcode_to_idx.insert(postcode.clone(), idx);
}
// Precompute GeoJSON geometry for each postcode
let geometries: Vec<serde_json::Value> = polygons
.iter()
.map(|rings| {
if rings.len() == 1 {
let coords: Vec<serde_json::Value> = rings[0]
.iter()
.map(|[lon, lat]| {
serde_json::Value::Array(vec![
serde_json::Value::from(*lon as f64),
serde_json::Value::from(*lat as f64),
])
})
.collect();
serde_json::json!({"type": "Polygon", "coordinates": [coords]})
} else {
let polys: Vec<serde_json::Value> = rings
.iter()
.map(|ring| {
let coords: Vec<serde_json::Value> = ring
.iter()
.map(|[lon, lat]| {
serde_json::Value::Array(vec![
serde_json::Value::from(*lon as f64),
serde_json::Value::from(*lat as f64),
])
})
.collect();
serde_json::Value::Array(vec![serde_json::Value::Array(coords)])
})
.collect();
serde_json::json!({"type": "MultiPolygon", "coordinates": polys})
}
})
.collect();
info!(postcodes = postcodes.len(), "Postcode boundary data ready");
Ok(PostcodeData {
postcodes,
polygons,
centroids,
aabbs,
geometries,
postcode_to_idx,
})
}

View file

@ -80,7 +80,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: true,
modes: &[],
modes: &["historical"],
linked: "",
},
FeatureConfig {
@ -114,7 +114,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
modes: &["historical"],
linked: "",
},
FeatureConfig {
@ -132,7 +132,24 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
modes: &["historical"],
linked: "",
linked: "Asking price per sqm",
},
FeatureConfig {
name: "Asking price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 100.0,
description: "Asking price divided by total floor area",
detail: "Calculated by dividing the listed asking price by the total floor area. Only available for properties currently listed for sale where floor area data exists.",
source: "online-listings",
prefix: "£",
suffix: "",
raw: false,
absolute: false,
modes: &["buy"],
linked: "Est. price per sqm",
},
FeatureConfig {
name: "Total floor area (sqm)",
@ -165,7 +182,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " m",
raw: false,
absolute: false,
modes: &[],
modes: &["historical"],
linked: "",
},
FeatureConfig {
@ -213,7 +230,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: true,
absolute: false,
modes: &[],
modes: &["historical"],
linked: "",
},
FeatureConfig {

View file

@ -339,7 +339,7 @@ async fn main() -> anyhow::Result<()> {
}
info!("Loading travel time data from {}", tt_path.display());
let travel_time_store = {
let store = data::TravelTimeStore::load(tt_path, 50)?;
let store = data::TravelTimeStore::load(tt_path, 200)?;
info!(
modes = store.available_modes.len(),
"Travel time store loaded"
@ -399,6 +399,9 @@ async fn main() -> anyhow::Result<()> {
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
});
// Start background PocketBase metrics poller (users, saved searches/properties counts)
pocketbase::start_metrics_poller(state.clone());
let cors = CorsLayer::new()
.allow_origin(
state
@ -440,6 +443,7 @@ async fn main() -> anyhow::Result<()> {
let state_invite_get = state.clone();
let state_redeem_invite = state.clone();
let state_journey = state.clone();
let state_telemetry = state.clone();
let api = Router::new()
.route(
@ -573,6 +577,13 @@ async fn main() -> anyhow::Result<()> {
.route(
"/s/{code}",
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
)
.route(
"/api/telemetry",
post(move |ext, headers, body| {
let _ = state_telemetry.clone();
routes::post_telemetry(ext, headers, body)
}),
);
// Add tile routes

View file

@ -44,11 +44,69 @@ pub async fn track_metrics(request: Request<Body>, next: Next) -> Response {
}
/// Normalize paths to avoid high cardinality from dynamic segments.
///
/// Groups dynamic segments into parameterized placeholders and collapses
/// static assets, PocketBase proxy paths, and unknown paths to prevent
/// Prometheus label cardinality explosion from bot scans and unique URLs.
fn normalize_path(path: &str) -> String {
// Tiles: /api/tiles/5/16/10 → /api/tiles/:z/:x/:y
if path.starts_with("/api/tiles/") && !path.ends_with("style.json") {
return "/api/tiles/:z/:x/:y".to_string();
}
path.to_string()
// Invite API: /api/invite/abc123 → /api/invite/:code
if path.starts_with("/api/invite/") {
return "/api/invite/:code".to_string();
}
// PocketBase proxy: /pb/api/files/... → /pb/api/files/:path
if path.starts_with("/pb/api/files/") {
return "/pb/api/files/:path".to_string();
}
// PocketBase proxy: /pb/api/... → keep collection-level granularity
if path.starts_with("/pb/api/collections/") {
// /pb/api/collections/users/auth-with-password → keep as-is (bounded set)
// /pb/api/collections/saved_searches/records/abc → /pb/api/collections/saved_searches/records/:id
let parts: Vec<&str> = path.splitn(6, '/').collect();
if parts.len() >= 6 && parts[4] == "records" {
return format!("/pb/api/collections/{}/records/:id", parts[3]);
}
return path.to_string();
}
// Short URLs: /s/abc → /s/:code
if path.starts_with("/s/") {
return "/s/:code".to_string();
}
// Invite pages: /invite/abc → /invite/:code
if path.starts_with("/invite/") {
return "/invite/:code".to_string();
}
// Static assets: /assets/* → /assets/:file
if path.starts_with("/assets/") {
return "/assets/:file".to_string();
}
// Known application routes and API endpoints — keep as-is
if path.starts_with("/api/")
|| matches!(
path,
"/" | "/health"
| "/metrics"
| "/dashboard"
| "/pricing"
| "/account"
| "/saved"
| "/invites"
| "/learn"
| "/bundle.js"
| "/main.css"
| "/favicon.ico"
| "/house.png"
| "/robots.txt"
| "/sitemap.xml"
)
{
return path.to_string();
}
// Everything else (bot scans, probes, etc.) → /other
"/other".to_string()
}
/// Handler for the /metrics endpoint.

View file

@ -6,4 +6,4 @@ mod h3;
pub use bounds::{bounds_intersect, h3_cell_bounds, parse_bounds, require_bounds};
pub use fields::{parse_field_indices, parse_field_set};
pub use filters::{parse_filters, row_passes_filters, ParsedEnumFilter, ParsedFilter};
pub use h3::{cell_for_row, needs_parent, validate_h3_resolution};
pub use h3::{cell_for_row, cell_for_row_cached, needs_parent, validate_h3_resolution};

View file

@ -89,6 +89,10 @@ pub fn parse_filters(
}
}
// Sort by selectivity: more selective filters first for early rejection
numeric.sort_unstable_by_key(|f| f.max_u16.wrapping_sub(f.min_u16));
enums.sort_unstable_by_key(|f| f.allowed.len());
Ok((numeric, enums))
}

View file

@ -1,4 +1,5 @@
use axum::http::StatusCode;
use rustc_hash::FxHashMap;
use tracing::warn;
use crate::consts::{H3_PRECOMPUTE_MAX, H3_REQUEST_MAX, H3_REQUEST_MIN};
@ -45,6 +46,28 @@ pub fn cell_for_row(
)
}
/// Like cell_for_row but caches parent lookups in the provided map.
#[inline]
pub fn cell_for_row_cached(
row: usize,
precomputed: &[u64],
h3_res: h3o::Resolution,
need_parent: bool,
cache: &mut FxHashMap<u64, u64>,
) -> u64 {
let max_cell = precomputed[row];
if !need_parent || max_cell == 0 {
return max_cell;
}
*cache.entry(max_cell).or_insert_with(|| {
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"),
)
})
}
/// Whether the given resolution requires computing a parent from precomputed cells.
#[inline]
pub fn needs_parent(resolution: u8) -> bool {

View file

@ -1,6 +1,12 @@
use std::sync::Arc;
use std::time::Duration;
use metrics::gauge;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::info;
use tracing::{info, warn};
use crate::state::AppState;
#[derive(Deserialize)]
struct AuthResponse {
@ -344,6 +350,116 @@ async fn ensure_saved_searches_rules(
ensure_user_owned_rules(client, base_url, token, "saved_searches").await
}
/// Ensure the `saved_searches` collection has a `screenshot` file field.
/// This field was added after the initial collection schema — existing deployments
/// need it patched in so the frontend can attach screenshot JPEGs to saved searches.
async fn ensure_screenshot_field(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/saved_searches");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch saved_searches collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("saved_searches collection has no fields array"))?;
if fields.iter().any(|f| f["name"] == "screenshot") {
return Ok(());
}
let mut new_fields = fields.clone();
new_fields.push(serde_json::json!({
"name": "screenshot",
"type": "file",
"required": false,
"maxSelect": 1,
"maxSize": 10485760,
"mimeTypes": ["image/png", "image/jpeg", "image/webp"],
}));
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to add screenshot field to saved_searches ({status}): {text}");
}
info!("Added screenshot file field to PocketBase collection 'saved_searches'");
Ok(())
}
/// Ensure a collection has a `notes` text field for user annotations.
async fn ensure_notes_field(
client: &Client,
base_url: &str,
token: &str,
collection_name: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/{collection_name}");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch {collection_name} collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("{collection_name} collection has no fields array"))?;
if fields.iter().any(|f| f["name"] == "notes") {
return Ok(());
}
let mut new_fields = fields.clone();
new_fields.push(serde_json::json!({
"name": "notes",
"type": "text",
"required": false,
}));
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to add notes field to {collection_name} ({status}): {text}");
}
info!("Added notes text field to PocketBase collection '{collection_name}'");
Ok(())
}
/// Ensure a collection has `created` and `updated` autodate fields.
/// PocketBase 0.23+ no longer adds these automatically — they must be explicit.
async fn ensure_autodate_fields(
@ -445,6 +561,7 @@ pub async fn ensure_collections(
Field::text("name", true),
Field::text("params", true),
Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]),
Field::text("notes", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
@ -459,6 +576,8 @@ pub async fn ensure_collections(
} else {
ensure_saved_searches_rules(client, base_url, &token).await?;
ensure_autodate_fields(client, base_url, &token, "saved_searches").await?;
ensure_screenshot_field(client, base_url, &token).await?;
ensure_notes_field(client, base_url, &token, "saved_searches").await?;
}
if !existing.iter().any(|n| n == "saved_properties") {
@ -476,6 +595,7 @@ pub async fn ensure_collections(
Field::text("address", true),
Field::text("postcode", true),
Field::text("data", false),
Field::text("notes", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
@ -490,6 +610,7 @@ pub async fn ensure_collections(
} else {
ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?;
ensure_autodate_fields(client, base_url, &token, "saved_properties").await?;
ensure_notes_field(client, base_url, &token, "saved_properties").await?;
}
if !existing.iter().any(|n| n == "invites") {
@ -639,3 +760,106 @@ pub async fn ensure_oauth_providers(
info!("PocketBase OAuth configured on users collection");
Ok(())
}
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
/// and exposes them as Prometheus gauges.
pub fn start_metrics_poller(state: Arc<AppState>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
poll_pocketbase_counts(&state).await;
}
});
}
async fn poll_pocketbase_counts(state: &AppState) {
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
{
Ok(tk) => tk,
Err(err) => {
warn!("PocketBase metrics poll auth failed: {err}");
return;
}
};
// Simple collection counts
for (collection, metric_name) in [
("users", "pocketbase_users_total"),
("saved_searches", "pocketbase_saved_searches_total"),
("saved_properties", "pocketbase_saved_properties_total"),
] {
if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await {
gauge!(metric_name).set(total as f64);
}
}
// Invite metrics: by type and redeemed status
for (filter, metric, labels) in [
(None, "invites_total", ("type", "all")),
(
Some(r#"invite_type="admin""#),
"invites_total",
("type", "admin"),
),
(
Some(r#"invite_type="referral""#),
"invites_total",
("type", "referral"),
),
(
Some(r#"used_by_id!=""#),
"invites_total",
("type", "redeemed"),
),
] {
if let Some(total) = pb_count(&state.http_client, pb_url, &token, "invites", filter).await
{
gauge!(metric, labels.0 => labels.1.to_string()).set(total as f64);
}
}
}
async fn pb_count(
client: &reqwest::Client,
pb_url: &str,
token: &str,
collection: &str,
filter: Option<&str>,
) -> Option<u64> {
let mut url = format!("{pb_url}/api/collections/{collection}/records?perPage=1");
if let Some(f) = filter {
url.push_str(&format!("&filter={}", urlencoding::encode(f)));
}
match client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.json::<serde_json::Value>().await {
return body.get("totalItems").and_then(|v| v.as_u64());
}
None
}
Ok(resp) => {
warn!(
"PocketBase {collection} count query failed: {}",
resp.status()
);
None
}
Err(err) => {
warn!("PocketBase {collection} count query error: {err}");
None
}
}
}

View file

@ -21,6 +21,7 @@ mod shorten;
mod stats;
mod streetview;
mod stripe_webhook;
mod telemetry;
mod tiles;
mod travel_destinations;
mod travel_modes;
@ -48,6 +49,7 @@ pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
pub use shorten::{get_short_url, post_shorten};
pub use streetview::get_streetview;
pub use stripe_webhook::post_stripe_webhook;
pub use telemetry::post_telemetry;
pub use tiles::{get_style, get_tile, init_tile_reader};
pub use travel_destinations::get_travel_destinations;
pub use travel_modes::get_travel_modes;

View file

@ -5,6 +5,7 @@ use axum::response::Json;
use axum::Extension;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use metrics::counter;
use tracing::{info, warn};
use crate::auth::OptionalUser;
@ -527,6 +528,7 @@ pub async fn post_ai_filters(
};
if tokens_used >= AI_FILTERS_WEEKLY_TOKEN_LIMIT {
counter!("ai_requests_total", "status" => "rate_limited").increment(1);
return Err((
StatusCode::TOO_MANY_REQUESTS,
"Weekly AI usage limit reached. Resets next week.".into(),
@ -695,6 +697,9 @@ pub async fn post_ai_filters(
let new_total = tokens_used + total_tokens_accumulated;
update_ai_usage(&state, &user.id, new_total, current_week).await;
counter!("ai_tokens_total").increment(total_tokens_accumulated);
counter!("ai_requests_total", "status" => "success").increment(1);
return Ok(Json(AiFiltersResponse {
filters,
travel_time_filters,

View file

@ -6,14 +6,15 @@ use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::check_license_bounds;
use crate::parsing::{
cell_for_row, h3_cell_bounds, needs_parent, parse_field_set, parse_filters, row_passes_filters,
validate_h3_resolution,
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters,
row_passes_filters, validate_h3_resolution,
};
use crate::state::AppState;
@ -132,12 +133,14 @@ pub async fn get_hexagon_stats(
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
if cell_for_row(row, precomputed, h3_res, need_parent) == cell_u64
if cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache)
== cell_u64
&& row_passes_filters(
row,
&parsed_filters,

View file

@ -4,9 +4,11 @@ use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use metrics::histogram;
use tracing::info;
use crate::aggregation::Aggregator;
@ -15,12 +17,15 @@ use crate::consts::{DEMO_BOUNDS, MAX_CELLS_PER_REQUEST};
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::parsing::{
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
bounds_intersect, cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_indices,
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
};
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
use crate::state::AppState;
/// Row count threshold above which we use rayon parallel aggregation.
const PARALLEL_THRESHOLD: usize = 50_000;
#[derive(Serialize)]
pub struct HexagonsResponse {
features: Vec<Map<String, Value>>,
@ -202,11 +207,67 @@ pub async fn get_hexagons(
.map(|_| FxHashMap::default())
.collect();
// Main aggregation loop
let aggregate_row =
|row: usize,
groups: &mut FxHashMap<u64, Aggregator>,
travel_aggs: &mut [FxHashMap<u64, TravelTimeAgg>]| {
// Collect row indices for threshold-based sequential/parallel aggregation
let row_indices = state.grid.query(south, west, north, east);
if row_indices.len() >= PARALLEL_THRESHOLD && !has_travel {
// Parallel path: split rows across rayon threads, each with local accumulators
let chunk_size = (row_indices.len() / rayon::current_num_threads()).max(1000);
let thread_results: Vec<FxHashMap<u64, Aggregator>> = row_indices
.par_chunks(chunk_size)
.map(|chunk| {
let mut local_groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
for &row_idx in chunk {
let row = row_idx as usize;
if !row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) {
continue;
}
let cell_id =
cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache);
let agg = local_groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
if let Some(sel_indices) = field_indices.as_deref() {
agg.add_row_selective(
feature_data,
row,
num_features,
sel_indices,
&quant,
);
} else {
agg.add_row(feature_data, row, num_features, &quant);
}
}
local_groups
})
.collect();
// Merge thread-local results into the main groups map
for local_groups in thread_results {
for (cell_id, local_agg) in local_groups {
let agg = groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
agg.merge(&local_agg);
}
}
} else {
// Sequential path (also handles travel time which needs postcode lookups)
let mut travel_minutes: Vec<Option<i16>> = Vec::with_capacity(travel_entries.len());
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
'row: for &row_idx in &row_indices {
let row = row_idx as usize;
// Regular filters
if !row_passes_filters(
row,
@ -215,14 +276,13 @@ pub async fn get_hexagons(
feature_data,
num_features,
) {
return;
continue;
}
// Travel time filter: check each entry with a range
let mut travel_minutes: Vec<Option<i16>> = Vec::new();
if has_travel {
travel_minutes.clear();
let postcode = pc_interner.resolve(&pc_keys[row]);
travel_minutes.reserve(travel_entries.len());
for (ti, entry) in travel_entries.iter().enumerate() {
let row_data = travel_data[ti].get(postcode);
let minutes = row_data.map(|r| {
@ -236,13 +296,14 @@ pub async fn get_hexagons(
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
match minutes {
Some(mins) if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
_ => return, // Filtered out
_ => continue 'row, // Filtered out (jump to next row_idx)
}
}
}
}
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
let cell_id =
cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache);
// Aggregate regular features
let aggregation = groups
@ -269,13 +330,8 @@ pub async fn get_hexagons(
agg.add(*mins as f32);
}
}
};
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
aggregate_row(row_idx as usize, &mut groups, &mut travel_aggs);
});
}
};
let t_agg = t0.elapsed();
@ -296,9 +352,12 @@ pub async fn get_hexagons(
features.truncate(MAX_CELLS_PER_REQUEST);
}
let parallel = row_indices.len() >= PARALLEL_THRESHOLD && !has_travel;
let t_total = t0.elapsed();
info!(
resolution,
rows = row_indices.len(),
parallel,
cells_before_filter = groups.len(),
cells_after_filter = features.len(),
truncated,
@ -311,6 +370,8 @@ pub async fn get_hexagons(
"GET /api/hexagons"
);
histogram!("hexagons_response_count").record(features.len() as f64);
Ok(HexagonsResponse { features })
})
.await

View file

@ -41,13 +41,22 @@ pub async fn get_pois(
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
let (south, west, north, east) = require_bounds(params.bounds)?;
let category_filter: Option<rustc_hash::FxHashSet<String>> = params
let category_filter: Option<rustc_hash::FxHashSet<u16>> = params
.categories
.as_deref()
.filter(|text| !text.is_empty())
.map(|text| {
text.split(',')
.map(|part| part.trim().to_string())
.filter_map(|part| {
let name = part.trim();
state
.poi_data
.category
.values
.iter()
.position(|v| v == name)
.map(|pos| pos as u16)
})
.collect()
});
let categories_raw = params.categories;
@ -63,7 +72,7 @@ pub async fn get_pois(
.filter_map(|&row_idx| {
let row = row_idx as usize;
if let Some(ref categories) = category_filter {
if !categories.contains(state.poi_data.category.get(row)) {
if !categories.contains(&state.poi_data.category.indices[row]) {
return None;
}
}

View file

@ -7,6 +7,7 @@ use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use metrics::histogram;
use tracing::info;
use crate::aggregation::Aggregator;
@ -38,34 +39,6 @@ pub struct PostcodeParams {
travel: Option<String>,
}
/// Build a GeoJSON geometry object from postcode polygon rings.
/// Returns Polygon for 1 ring, MultiPolygon for 2+ rings.
fn build_postcode_geometry(rings: &[Vec<[f32; 2]>]) -> Value {
if rings.len() == 1 {
let coords: Vec<Value> = rings[0]
.iter()
.map(|[lon, lat]| {
Value::Array(vec![Value::from(*lon as f64), Value::from(*lat as f64)])
})
.collect();
serde_json::json!({ "type": "Polygon", "coordinates": [coords] })
} else {
let polys: Vec<Value> = rings
.iter()
.map(|ring| {
let coords: Vec<Value> = ring
.iter()
.map(|[lon, lat]| {
Value::Array(vec![Value::from(*lon as f64), Value::from(*lat as f64)])
})
.collect();
Value::Array(vec![Value::Array(coords)])
})
.collect();
serde_json::json!({ "type": "MultiPolygon", "coordinates": polys })
}
}
pub async fn get_postcodes(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
@ -128,9 +101,8 @@ pub async fn get_postcodes(
let has_selective = field_indices.is_some();
let sel_indices = field_indices.as_deref().unwrap_or(&[]);
// Build postcode -> rows mapping by iterating properties in bounds
// and grouping by their postcode
let mut postcode_rows: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
// Single-pass: aggregate directly into postcode_aggs while iterating properties in bounds
let mut postcode_aggs: FxHashMap<usize, Aggregator> = FxHashMap::default();
state
.grid
@ -146,16 +118,22 @@ pub async fn get_postcodes(
return;
}
// Get postcode for this property
let postcode = state.data.postcode(row);
if let Some(&pc_idx) = postcode_data.postcode_to_idx.get(postcode) {
postcode_rows.entry(pc_idx).or_default().push(row);
let agg = postcode_aggs
.entry(pc_idx)
.or_insert_with(|| Aggregator::new(num_features));
if has_selective {
agg.add_row_selective(feature_data, row, num_features, sel_indices, &quant);
} else {
agg.add_row(feature_data, row, num_features, &quant);
}
}
});
// Filter postcodes by travel time range (if specified)
if has_travel {
postcode_rows.retain(|&pc_idx, _rows| {
postcode_aggs.retain(|&pc_idx, _agg| {
let postcode = &postcode_data.postcodes[pc_idx];
for (ti, entry) in travel_entries.iter().enumerate() {
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
@ -176,26 +154,10 @@ pub async fn get_postcodes(
});
}
// Aggregate for each postcode that has properties in bounds
// (polygon intersection check happens later when building response)
let mut postcode_aggs: FxHashMap<usize, Aggregator> = FxHashMap::default();
// Travel time aggregation per postcode
let mut travel_aggs: FxHashMap<usize, Vec<TravelTimeAgg>> = FxHashMap::default();
for (&pc_idx, rows) in &postcode_rows {
let agg = postcode_aggs
.entry(pc_idx)
.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, &quant);
} else {
agg.add_row(feature_data, row, num_features, &quant);
}
}
// Aggregate travel times for this postcode
if has_travel {
if has_travel {
for &pc_idx in postcode_aggs.keys() {
let postcode = &postcode_data.postcodes[pc_idx];
let tt_aggs = travel_aggs.entry(pc_idx).or_insert_with(|| {
(0..travel_entries.len())
@ -225,37 +187,24 @@ pub async fn get_postcodes(
continue;
}
// Compute postcode polygon bounding box across ALL parts and check intersection
let rings = &postcode_data.polygons[pc_idx];
let (mut pc_south, mut pc_north) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut pc_west, mut pc_east) = (f64::INFINITY, f64::NEG_INFINITY);
for ring in rings {
for &[lon, lat] in ring {
let lon_f = lon as f64;
let lat_f = lat as f64;
if lat_f < pc_south {
pc_south = lat_f;
}
if lat_f > pc_north {
pc_north = lat_f;
}
if lon_f < pc_west {
pc_west = lon_f;
}
if lon_f > pc_east {
pc_east = lon_f;
}
}
}
// Use precomputed AABB for bounds intersection check
let (pc_south, pc_west, pc_north, pc_east) = postcode_data.aabbs[pc_idx];
if !bounds_intersect(
pc_south, pc_west, pc_north, pc_east, south, west, north, east,
pc_south as f64,
pc_west as f64,
pc_north as f64,
pc_east as f64,
south,
west,
north,
east,
) {
filtered_out += 1;
continue;
}
let geometry = build_postcode_geometry(rings);
let geometry = postcode_data.geometries[pc_idx].clone();
// Build properties
let centroid = postcode_data.centroids[pc_idx];
@ -327,6 +276,8 @@ pub async fn get_postcodes(
}
}
histogram!("postcodes_response_count").record(features.len() as f64);
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
let t_total = t0.elapsed();
info!(
@ -365,8 +316,7 @@ pub async fn get_postcode_lookup(
if let Some(&idx) = postcode_data.postcode_to_idx.get(&normalized) {
let (lat, lon) = postcode_data.centroids[idx];
let rings = &postcode_data.polygons[idx];
let geometry = build_postcode_geometry(rings);
let geometry = postcode_data.geometries[idx].clone();
info!(postcode = %normalized, "GET /api/postcode/{postcode}");
Ok(Json(serde_json::json!({

View file

@ -14,7 +14,7 @@ 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,
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
validate_h3_resolution,
};
use crate::state::AppState;
@ -220,12 +220,14 @@ pub async fn get_hexagon_properties(
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
if cell_for_row(row, precomputed, h3_res, need_parent) == cell_u64
if cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache)
== cell_u64
&& row_passes_filters(
row,
&parsed_filters,

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use axum::http::header::HeaderValue;
use axum::http::{header, HeaderMap, StatusCode, Uri};
use axum::response::IntoResponse;
use metrics::histogram;
use tracing::{info, warn};
use crate::state::AppState;
@ -44,8 +45,14 @@ pub async fn get_screenshot(
) -> impl IntoResponse {
let qs = uri.query().unwrap_or_default();
let auth = headers.get(header::AUTHORIZATION);
let is_og = qs.contains("og=1");
match fetch_screenshot_bytes(&state, qs, auth).await {
let t0 = std::time::Instant::now();
let result = fetch_screenshot_bytes(&state, qs, auth).await;
let kind = if is_og { "og" } else { "export" };
histogram!("screenshot_duration_seconds", "kind" => kind).record(t0.elapsed().as_secs_f64());
match result {
Ok(bytes) => (
StatusCode::OK,
[

View file

@ -50,7 +50,32 @@ pub fn extract_price_history(
}
}
/// Per-feature accumulator kind, determined once before the row loop.
enum FeatureAccum {
/// Numeric: track count, min, max, sum, histogram bins.
Numeric {
count: usize,
min_value: f32,
max_value: f32,
sum: f64,
bins: Vec<u64>,
p1: f32,
p99: f32,
middle_width: f32,
num_bins: usize,
global_min: f32,
global_max: f32,
},
/// Enum: count occurrences per variant index.
Enum {
value_counts: Vec<u64>,
},
/// Feature skipped (not in field_set).
Skip,
}
/// Compute per-feature stats (numeric histograms + enum counts) for the given rows.
/// Single-pass: iterates rows in the outer loop for cache-friendly row-major access.
#[allow(clippy::too_many_arguments)]
pub fn compute_feature_stats(
matching_rows: &[usize],
@ -61,107 +86,161 @@ pub fn compute_feature_stats(
fields_specified: bool,
field_set: &HashSet<String>,
) -> (Vec<NumericFeatureStats>, Vec<EnumFeatureStats>) {
let num_features = feature_names.len();
// Pre-allocate accumulators for all features
let mut accums: Vec<FeatureAccum> = (0..num_features)
.map(|fi| {
let feature_name = &feature_names[fi];
if fields_specified && !field_set.contains(feature_name.as_str()) {
return FeatureAccum::Skip;
}
if let Some(ev) = enum_values.get(&fi) {
FeatureAccum::Enum {
value_counts: vec![0u64; ev.len()],
}
} else {
let global_hist = &feature_stats_data[fi].histogram;
let p1 = global_hist.p1;
let p99 = global_hist.p99;
let num_bins = global_hist.counts.len();
let middle_bins = num_bins.saturating_sub(2);
let middle_width = if middle_bins > 0 && p99 > p1 {
(p99 - p1) / middle_bins as f32
} else {
0.0
};
FeatureAccum::Numeric {
count: 0,
min_value: f32::INFINITY,
max_value: f32::NEG_INFINITY,
sum: 0.0,
bins: vec![0u64; num_bins],
p1,
p99,
middle_width,
num_bins,
global_min: global_hist.min,
global_max: global_hist.max,
}
}
})
.collect();
// Single pass: outer loop = rows, inner loop = features (cache-friendly row-major access)
for &row in matching_rows {
for (fi, accum) in accums.iter_mut().enumerate() {
match accum {
FeatureAccum::Skip => {}
FeatureAccum::Enum { value_counts } => {
let value = data.get_feature(row, fi);
if value.is_finite() {
let idx = value as usize;
if idx < value_counts.len() {
value_counts[idx] += 1;
} else {
warn!(
feature = feature_names[fi].as_str(),
idx,
max = value_counts.len(),
"Enum index out of bounds — possible data/schema mismatch"
);
}
}
}
FeatureAccum::Numeric {
count,
min_value,
max_value,
sum,
bins,
p1,
p99,
middle_width,
num_bins,
..
} => {
let value = data.get_feature(row, fi);
if value.is_finite() {
*count += 1;
if value < *min_value {
*min_value = value;
}
if value > *max_value {
*max_value = value;
}
*sum += value as f64;
let bin = if value < *p1 {
0
} else if value >= *p99 {
*num_bins - 1
} else if *middle_width > 0.0 {
let middle_bin = ((value - *p1) / *middle_width) as usize;
(1 + middle_bin).min(*num_bins - 2)
} else {
*num_bins / 2
};
bins[bin] += 1;
}
}
}
}
}
// Build response structs from accumulators
let mut numeric_features = Vec::new();
let mut enum_features_out = Vec::new();
for (feature_index, feature_name) in feature_names.iter().enumerate() {
if fields_specified && !field_set.contains(feature_name.as_str()) {
continue;
}
for (fi, accum) in accums.into_iter().enumerate() {
match accum {
FeatureAccum::Skip => {}
FeatureAccum::Enum { value_counts } => {
let ev = &enum_values[&fi];
let counts: HashMap<String, u64> = value_counts
.iter()
.enumerate()
.filter(|(_, &count)| count > 0)
.map(|(idx, &count)| (ev[idx].clone(), count))
.collect();
if let Some(ev) = enum_values.get(&feature_index) {
let mut value_counts = vec![0u64; ev.len()];
for &row in matching_rows {
let value = data.get_feature(row, feature_index);
if value.is_finite() {
let idx = value as usize;
if idx < value_counts.len() {
value_counts[idx] += 1;
} else {
warn!(
feature = feature_name.as_str(),
idx,
max = value_counts.len(),
"Enum index out of bounds — possible data/schema mismatch"
);
}
if !counts.is_empty() {
enum_features_out.push(EnumFeatureStats {
name: feature_names[fi].clone(),
counts,
});
}
}
let counts: HashMap<String, u64> = value_counts
.iter()
.enumerate()
.filter(|(_, &count)| count > 0)
.map(|(idx, &count)| (ev[idx].clone(), count))
.collect();
if !counts.is_empty() {
enum_features_out.push(EnumFeatureStats {
name: feature_name.clone(),
counts,
});
}
} else {
let global_hist = &feature_stats_data[feature_index].histogram;
let p1 = global_hist.p1;
let p99 = global_hist.p99;
let num_bins = global_hist.counts.len();
let mut count = 0usize;
let mut min_value = f32::INFINITY;
let mut max_value = f32::NEG_INFINITY;
let mut sum = 0.0f64;
let mut bins = vec![0u64; num_bins];
let middle_bins = num_bins.saturating_sub(2);
let middle_width = if middle_bins > 0 && p99 > p1 {
(p99 - p1) / middle_bins as f32
} else {
0.0
};
for &row in matching_rows {
let value = data.get_feature(row, feature_index);
if value.is_finite() {
count += 1;
if value < min_value {
min_value = value;
}
if value > max_value {
max_value = value;
}
sum += value as f64;
let bin = if value < p1 {
0
} else if value >= p99 {
num_bins - 1
} else if middle_width > 0.0 {
let middle_bin = ((value - p1) / middle_width) as usize;
(1 + middle_bin).min(num_bins - 2)
} else {
num_bins / 2
};
bins[bin] += 1;
FeatureAccum::Numeric {
count,
min_value,
max_value,
sum,
bins,
p1,
p99,
global_min,
global_max,
..
} => {
if count > 0 {
numeric_features.push(NumericFeatureStats {
name: feature_names[fi].clone(),
count,
min: min_value as f64,
max: max_value as f64,
mean: sum / count as f64,
histogram: HistogramStats {
min: global_min as f64,
max: global_max as f64,
p1: p1 as f64,
p99: p99 as f64,
counts: bins,
},
});
}
}
if count > 0 {
numeric_features.push(NumericFeatureStats {
name: feature_name.clone(),
count,
min: min_value as f64,
max: max_value as f64,
mean: sum / count as f64,
histogram: HistogramStats {
min: global_hist.min as f64,
max: global_hist.max as f64,
p1: p1 as f64,
p99: p99 as f64,
counts: bins,
},
});
}
}
}

View file

@ -0,0 +1,77 @@
use axum::http::{HeaderMap, StatusCode};
use axum::response::Json;
use axum::Extension;
use metrics::{counter, gauge};
use serde::Deserialize;
use crate::auth::OptionalUser;
#[derive(Deserialize)]
pub struct TelemetryPayload {
session_seconds: u64,
filter_count: u64,
/// Sent once on first beacon: the entry page path
#[serde(default)]
entry_path: Option<String>,
/// Sent once on first beacon: the document.referrer domain (or "direct")
#[serde(default)]
referrer: Option<String>,
}
pub async fn post_telemetry(
Extension(user): Extension<OptionalUser>,
headers: HeaderMap,
Json(payload): Json<TelemetryPayload>,
) -> StatusCode {
let user_label = match &user.0 {
Some(u) => u.email.clone(),
None => "anonymous".to_string(),
};
let ua = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
let browser = parse_browser(ua);
gauge!("user_session_seconds", "user" => user_label.clone(), "browser" => browser.clone())
.set(payload.session_seconds as f64);
gauge!("user_active_filters", "user" => user_label, "browser" => browser)
.set(payload.filter_count as f64);
// Entrypoint tracking (sent once per session)
if let Some(path) = &payload.entry_path {
let referrer = payload.referrer.as_deref().unwrap_or("direct");
counter!("entrypoint_total", "path" => normalize_entry_path(path), "referrer" => referrer.to_string())
.increment(1);
}
StatusCode::NO_CONTENT
}
/// Normalize entry paths to prevent cardinality explosion.
/// Keep known routes, parameterize dynamic segments.
fn normalize_entry_path(path: &str) -> String {
match path {
"/" | "/dashboard" | "/pricing" | "/learn" | "/saved" | "/invites" | "/account" => {
path.to_string()
}
p if p.starts_with("/invite/") => "/invite/:code".to_string(),
p if p.starts_with("/s/") => "/s/:code".to_string(),
_ => "/other".to_string(),
}
}
fn parse_browser(ua: &str) -> String {
if ua.contains("Firefox") {
"Firefox".into()
} else if ua.contains("Edg/") {
"Edge".into()
} else if ua.contains("Chrome") {
"Chrome".into()
} else if ua.contains("Safari") {
"Safari".into()
} else {
"Other".into()
}
}

View file

@ -34,7 +34,7 @@ pub struct AppState {
pub features_response: FeaturesResponse,
/// URL of the screenshot service (e.g. http://screenshot:8002)
pub screenshot_url: String,
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.schmelczer.dev)
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.dev)
pub public_url: String,
/// True when --dist is not provided (no static serving, relaxed auth checks)
pub is_dev: bool,