From 30d36a33d5c36fab94deb91c33246aa8117129da Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 26 Jun 2026 19:47:23 +0100 Subject: [PATCH 1/2] finish new demo --- frontend/src/App.tsx | 15 ++- frontend/src/components/map/Filters.tsx | 2 + frontend/src/components/map/MapPage.tsx | 49 ++++++--- .../components/map/filters/AddFilterPanel.tsx | 13 ++- frontend/src/components/map/map-page/types.ts | 1 - frontend/src/components/ui/AuthModal.tsx | 7 +- frontend/src/components/ui/Header.tsx | 28 +++-- frontend/src/components/ui/MobileMenu.tsx | 23 ++-- frontend/src/components/ui/UpgradeModal.tsx | 103 ++++++++++++------ frontend/src/hooks/useFilters.test.ts | 37 +++++++ frontend/src/i18n/locales/de.ts | 23 +++- frontend/src/i18n/locales/en.ts | 24 +++- frontend/src/i18n/locales/fr.ts | 22 +++- frontend/src/i18n/locales/hi.ts | 22 +++- frontend/src/i18n/locales/hu.ts | 22 +++- frontend/src/i18n/locales/zh.ts | 21 +++- 16 files changed, 318 insertions(+), 94 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e03eb6..243ad60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import type { ParseKeys } from 'i18next'; import type { ExportState } from './components/map/MapPage'; import { getSeoContentPage, @@ -277,6 +278,9 @@ export default function App() { const { startCheckout: startPostAuthCheckout } = useLicense(); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); + // Optional context-specific value-prop key for the auth modal (e.g. when a + // logged-out user taps Save/Share/Export). Null → the generic value prop. + const [authModalValuePropKey, setAuthModalValuePropKey] = useState(null); const [postAuthIntent, setPostAuthIntent] = useState(null); const postAuthCheckoutReturnPathRef = useRef(null); const authCompletedRef = useRef(false); @@ -294,13 +298,15 @@ export default function App() { ( tab: 'login' | 'register', intent: PostAuthIntent | null = null, - checkoutReturnPath?: string + checkoutReturnPath?: string, + valuePropKey?: ParseKeys | null ) => { authCompletedRef.current = false; postAuthCheckoutReturnPathRef.current = intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null; setPostAuthIntent(intent); setAuthModalTab(tab); + setAuthModalValuePropKey(valuePropKey ?? null); setShowAuthModal(true); clearError(); }, @@ -648,7 +654,7 @@ export default function App() { onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} - exportState={activePage === 'dashboard' && user ? exportState : null} + exportState={activePage === 'dashboard' ? exportState : null} dashboardParams={activePage === 'dashboard' ? dashboardParams : ''} dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady} onSaveSearch={ @@ -665,6 +671,9 @@ export default function App() { user={user} onLoginClick={() => openAuthModal('login')} onRegisterClick={() => openAuthModal('register')} + onAccountRequired={() => + openAuthModal('register', null, undefined, 'auth.dashboardActionsValueProp') + } onLogout={logout} isMobile={isMobile} /> @@ -753,7 +762,6 @@ export default function App() { user={user} onLoginClick={() => openAuthModal('login')} onRegisterClick={() => openAuthModal('register')} - onCheckoutLoginClick={(returnPath) => openAuthModal('login', 'checkout', returnPath)} onCheckoutRegisterClick={(returnPath) => openAuthModal('register', 'checkout', returnPath) } @@ -782,6 +790,7 @@ export default function App() { error={authError} onClearError={clearError} initialTab={authModalTab} + valuePropKey={authModalValuePropKey ?? undefined} /> )} {showSaveModal && ( diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 28e8f82..7018274 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -871,6 +871,7 @@ export default memo(function Filters({ {showPhilosophy && ( diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 27bf3cb..2a014b3 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -109,7 +109,6 @@ export default function MapPage({ user, onLoginClick, onRegisterClick, - onCheckoutLoginClick, onCheckoutRegisterClick, deferTutorial = false, onSaveSearch, @@ -348,6 +347,7 @@ export default function MapPage({ }, [ activeEntries, + effectiveFilterCap, fetchAiFilters, filters, filtersUnlimited, @@ -358,16 +358,28 @@ export default function MapPage({ ); // Travel-time entries share the non-paying user's filter cap, so block adding one - // when the combined feature + travel count is already at the limit. + // when the combined feature + travel count is already at the limit — and block it + // outright on a shared link, where non-paying users can't add filters at all. const handleAddTravelTimeEntry = useCallback( (mode: TransportMode) => { - if (!filtersUnlimited && Object.keys(filters).length + entries.length >= DEMO_MAX_FILTERS) { + if ( + !filtersUnlimited && + (lockFilterAdds || Object.keys(filters).length + entries.length >= effectiveFilterCap) + ) { handleFilterLimitReached(); return; } handleAddEntry(mode); }, - [filtersUnlimited, filters, entries, handleAddEntry, handleFilterLimitReached] + [ + filtersUnlimited, + lockFilterAdds, + effectiveFilterCap, + filters, + entries, + handleAddEntry, + handleFilterLimitReached, + ] ); const handleClearAll = useCallback(() => { @@ -691,13 +703,17 @@ export default function MapPage({ }, [onDashboardReadyChange]); // Clear the filter-cap upsell once it's no longer relevant: the user became - // licensed/admin, or dropped back under the cap by removing filters. Prevents a - // stale flag from resurfacing the modal spuriously. + // licensed/admin, or dropped back under the cap by removing filters. On a shared + // link adds are blocked regardless of count, so don't auto-clear there — the user + // dismisses it themselves. Prevents a stale flag from resurfacing spuriously. useEffect(() => { - if (filtersUnlimited || Object.keys(filters).length < DEMO_MAX_FILTERS) { + if ( + filtersUnlimited || + (!lockFilterAdds && Object.keys(filters).length < effectiveFilterCap) + ) { setFilterLimitHit(false); } - }, [filtersUnlimited, filters]); + }, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters]); const handleUpgradeClick = useCallback(() => { onNavigateTo('pricing'); @@ -1021,17 +1037,20 @@ export default function MapPage({ ) : null; - // The upgrade modal is shown when a non-paying user hits the filter cap. - // Dismissing it just closes the modal (the filters they have stay applied). + // The upgrade modal is shown when a non-paying user hits the filter cap (or tries + // to add filters on a shared link). Dismissing it just closes the modal (the + // filters they have stay applied). Anonymous users are nudged to register for free + // first (a higher filter allowance + save/share/export); paying is the way to + // unlimited. Registered users see the lifetime upgrade directly. const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited; const upgradeModal = showFilterUpgradeModal ? ( - onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() - } - onRegisterClick={() => + isLoggedIn={isLoggedIn} + reason={upgradeReason} + onLoginClick={onLoginClick} + onRegisterClick={onRegisterClick} + onRegisterAndUpgrade={() => onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick() } onStartCheckout={() => license.startCheckout(checkoutReturnPath)} diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx index d8c73cb..dfebf67 100644 --- a/frontend/src/components/map/filters/AddFilterPanel.tsx +++ b/frontend/src/components/map/filters/AddFilterPanel.tsx @@ -33,6 +33,7 @@ import { interface AddFilterPanelProps { collapsed: boolean; + isLoggedIn: boolean; isLicensed: boolean; availableFeatures: FeatureMeta[]; allFeatures: FeatureMeta[]; @@ -54,10 +55,13 @@ interface AddFilterPanelProps { onClearOpenInfoFeature?: () => void; onAddTravelTimeEntry: (mode: TransportMode) => void; onUpgradeClick?: () => void; + /** Anonymous-only: open the register prompt from the create-account nag. */ + onRegisterClick?: () => void; } export function AddFilterPanel({ collapsed, + isLoggedIn, isLicensed, availableFeatures, allFeatures, @@ -79,6 +83,7 @@ export function AddFilterPanel({ onClearOpenInfoFeature, onAddTravelTimeEntry, onUpgradeClick, + onRegisterClick, }: AddFilterPanelProps) { const { t } = useTranslation(); const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand(); @@ -182,16 +187,16 @@ export function AddFilterPanel({ {!isLicensed && (

- {t('filters.upgradePrompt')} + {isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}

- {t('filters.oneTimeLifetime')} + {isLoggedIn ? t('filters.oneTimeLifetime') : t('filters.registerSubPrompt')}

void; onRegisterClick: () => void; - onCheckoutLoginClick?: (returnPath?: string) => void; onCheckoutRegisterClick?: (returnPath?: string) => void; deferTutorial?: boolean; onSaveSearch?: (name: string, paramsOverride?: string) => Promise; diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index 408f9db..e226a72 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useId } from 'react'; import { Trans, useTranslation } from 'react-i18next'; +import type { ParseKeys } from 'i18next'; import { CloseIcon } from './icons/CloseIcon'; import { GoogleIcon } from './icons/GoogleIcon'; import { trackEvent } from '../../lib/analytics'; @@ -18,6 +19,7 @@ export default function AuthModal({ error, onClearError, initialTab = 'login', + valuePropKey, }: { onClose: () => void; onAuthenticated?: () => void; @@ -29,6 +31,9 @@ export default function AuthModal({ error: string | null; onClearError: () => void; initialTab?: 'login' | 'register'; + /** Translation key for the value-prop line, e.g. a context-specific nudge. Falls + * back to the generic `auth.valueProp`. */ + valuePropKey?: ParseKeys; }) { const { t } = useTranslation(); const [view, setView] = useState(initialTab); @@ -166,7 +171,7 @@ export default function AuthModal({ {/* Value prop */} {view !== 'forgot' && (

- {t('auth.valueProp')} + {t(valuePropKey ?? 'auth.valueProp')}

)} diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 51f00d2..0120ad3 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -91,6 +91,7 @@ export default function Header({ user, onLoginClick, onRegisterClick, + onAccountRequired, onLogout, isMobile, }: { @@ -110,6 +111,8 @@ export default function Header({ user: AuthUser | null; onLoginClick: () => void; onRegisterClick: () => void; + /** Logged-out click on a dashboard action (save/share/export) → register prompt. */ + onAccountRequired: () => void; onLogout: () => void; isMobile: boolean; }) { @@ -122,7 +125,10 @@ export default function Header({ () => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches ); const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth); - const dashboardActionsBlocked = activePage === 'dashboard' && (!user || dashboardActionsDisabled); + // Save/Share/Export are shown to everyone on the dashboard; a logged-out click + // opens the register prompt (see onAccountRequired) instead of acting. "Blocked" + // only applies to logged-in users while the dashboard isn't ready yet. + const dashboardActionsBlocked = activePage === 'dashboard' && !!user && dashboardActionsDisabled; useEffect(() => { const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY); @@ -281,12 +287,13 @@ export default function Header({ {/* Right side */}
- {/* Desktop-only dashboard actions */} - {!useSidebarNav && activePage === 'dashboard' && user && ( + {/* Desktop-only dashboard actions — shown to everyone; a logged-out click + opens the register prompt instead of acting. */} + {!useSidebarNav && activePage === 'dashboard' && ( <> {exportState && ( )} - {onSaveSearch && !editingSearch && ( + {!editingSearch && ( )} - {onSaveSearch && ( + {(onSaveSearch || !user) && ( + // Registered free user: pay to unlock unlimited filters. + <> +
+ + {priceLabel} + + {!isFree && ( + + {t('pricingPage.lifetime')} + + )} +
+

+ {isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')} +

+ + ) : ( -
+ // Anonymous: create a free account first (more filters + save/share/export), + // with the paid lifetime tier offered below as the route to unlimited. + <> -
+
+

+ {t('upgrade.lifetimeUpsell')} +

+ +
+ )} {error && ( diff --git a/frontend/src/hooks/useFilters.test.ts b/frontend/src/hooks/useFilters.test.ts index 37cd878..4f6648f 100644 --- a/frontend/src/hooks/useFilters.test.ts +++ b/frontend/src/hooks/useFilters.test.ts @@ -228,6 +228,43 @@ describe('useFilters', () => { expect(Object.keys(result.current.filters)).toHaveLength(2); }); + it('blocks adding new filters when lockAdds is set (shared link), but allows replacing existing ones', () => { + const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({ + name, + type: 'numeric', + min: 0, + max: 100, + })); + const onFilterLimitReached = vi.fn(); + + // A non-paying user on a shared link: still under the cap (5), but adds are + // locked. They can adjust the shared filter ('a') but not add a new one. + const { result } = renderHook(() => + useFilters({ + initialFilters: { a: [0, 100] }, + features: sixFeatures, + filterLimit: 5, + lockAdds: true, + onFilterLimitReached, + }) + ); + + // Adding a brand-new filter is blocked and reported, even though count < cap. + act(() => { + result.current.handleAddFilter('b'); + }); + expect(Object.keys(result.current.filters)).toHaveLength(1); + expect(result.current.filters.b).toBeUndefined(); + expect(onFilterLimitReached).toHaveBeenCalledTimes(1); + + // Re-applying the existing filter replaces it (no new key) and is allowed. + act(() => { + result.current.handleAddFilter('a'); + }); + expect(Object.keys(result.current.filters)).toHaveLength(1); + expect(onFilterLimitReached).toHaveBeenCalledTimes(1); + }); + it('does not cap filters when filterLimit is null (licensed users)', () => { const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({ name, diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 859d132..07b60fa 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -611,6 +611,8 @@ const de: Translations = { createAccount: 'Konto erstellen', resetPassword: 'Passwort zurücksetzen', valueProp: 'Speichere Suchen, merke Immobilien und erstelle eine Shortlist passender Gebiete.', + dashboardActionsValueProp: + 'Erstelle ein kostenloses Konto, um deine Suchen zu speichern, zu teilen und zu exportieren — kostenlos, keine Kreditkarte erforderlich.', continueWithGoogle: 'Weiter mit Google', email: 'E-Mail', emailPlaceholder: 'name@beispiel.de', @@ -634,7 +636,17 @@ const de: Translations = { upgrade: { title: 'Unbegrenzte Filter freischalten', description: - 'Kostenlose Konten können bis zu 3 Filter gleichzeitig kombinieren. Erhalte lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode und jede Nachbarschaft in England hinweg zu kombinieren. Einmal zahlen, für immer nutzen.', + 'Du hast alle 5 Filter deines kostenlosen Kontos genutzt. Erhalte lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode und jede Nachbarschaft in England hinweg zu kombinieren. Einmal zahlen, für immer nutzen.', + descriptionSharedUpgrade: + 'Um einer geteilten Suche weitere Filter hinzuzufügen, ist ein lebenslanger Zugang erforderlich — schalte unbegrenzte Filter für jeden Postcode in England frei.', + titleRegister: 'Kostenloses Konto erstellen', + descriptionRegister: + 'Kostenlose Konten schalten bis zu 5 Filter sowie Speichern, Teilen und Exportieren frei — keine Kreditkarte erforderlich. Willst du unbegrenzte Filter für ganz England? Wähle den lebenslangen Zugang.', + descriptionSharedRegister: + 'Das ist eine geteilte Suche. Erstelle ein kostenloses Konto, um deine eigene mit bis zu 5 Filtern zu erstellen und zu speichern, teilen & exportieren — oder wähle den lebenslangen Zugang für unbegrenzte Filter.', + registerFree: 'Kostenloses Konto erstellen', + lifetimeUpsell: 'Willst du unbegrenzte Filter?', + goLifetime: 'Lebenslangen Zugang erhalten — {{price}}', free: 'Kostenlos', freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.', oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.', @@ -643,7 +655,7 @@ const de: Translations = { upgradeFor: 'Upgrade für {{price}}', registerAndUpgrade: 'Registrieren & upgraden', alreadyHaveAccount: 'Bereits ein Konto? Anmelden', - continueFree: 'Mit 3 Filtern fortfahren', + continueFree: 'Erstmal weiter erkunden', checkoutFailed: 'Bezahlvorgang fehlgeschlagen', }, @@ -682,7 +694,12 @@ const de: Translations = { addFiltersHint: 'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die zu deinen Kriterien passen', upgradePrompt: - 'Finde passende Postcodes mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 40 kombinierbaren Filtern in ganz England.', + 'Wähle den lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode in England hinweg zu kombinieren.', + registerPrompt: + 'Erstelle ein kostenloses Konto, um bis zu 5 Filter zu kombinieren und deine Suchen zu speichern, zu teilen & zu exportieren.', + registerSubPrompt: + 'Kostenlos — keine Kreditkarte erforderlich. Unbegrenzte Filter mit lebenslangem Zugang.', + registerCta: 'Kostenloses Konto erstellen', oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.', upgradeToFullMap: 'Auf die vollständige Karte upgraden', chooseFilters: diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5ad081d..8a27a76 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -599,6 +599,8 @@ const en = { createAccount: 'Create account', resetPassword: 'Reset password', valueProp: 'Save searches, bookmark properties, and build a shortlist of areas that fit.', + dashboardActionsValueProp: + 'Create a free account to save, share and export your searches — free, no card needed.', continueWithGoogle: 'Continue with Google', email: 'Email', emailPlaceholder: 'you@example.com', @@ -620,9 +622,21 @@ const en = { // ── Upgrade Modal ────────────────────────────────── upgrade: { + // Registered (logged-in, non-paying) users — the lifetime upsell. title: 'Unlock unlimited filters', description: - 'Free accounts can combine up to 3 filters at a time. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.', + "You've used all 5 filters on your free account. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.", + descriptionSharedUpgrade: + 'Adding more filters to a shared search needs lifetime access — unlock unlimited filters across every postcode in England.', + // Anonymous users — create a free account first. + titleRegister: 'Create a free account', + descriptionRegister: + 'Free accounts unlock up to 5 filters plus save, share and export — no card needed. Want unlimited filters across all of England? Go lifetime.', + descriptionSharedRegister: + 'This is a shared search. Create a free account to build your own with up to 5 filters and save, share & export — or go lifetime for unlimited.', + registerFree: 'Create free account', + lifetimeUpsell: 'Want unlimited filters?', + goLifetime: 'Get lifetime access — {{price}}', free: 'Free', freeForEarly: 'Free for early adopters. No credit card required.', oneTimePayment: 'One-time payment. Lifetime access.', @@ -631,7 +645,7 @@ const en = { upgradeFor: 'Upgrade for {{price}}', registerAndUpgrade: 'Register & Upgrade', alreadyHaveAccount: 'Already have an account? Log in', - continueFree: 'Continue with 3 filters', + continueFree: 'Keep exploring for now', checkoutFailed: 'Checkout failed', }, @@ -669,9 +683,13 @@ const en = { findingPerfectPostcode: 'Finding the Perfect Postcode', addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria', upgradePrompt: - 'Find matching postcodes using crime, schools, noise, broadband, prices, and 40+ combinable filters across England.', + 'Go lifetime to stack unlimited filters across every postcode in England.', oneTimeLifetime: 'One-time payment, lifetime access.', upgradeToFullMap: 'Upgrade to full map', + registerPrompt: + 'Create a free account to combine up to 5 filters and save, share & export your searches.', + registerSubPrompt: 'Free — no card needed. Unlimited filters with lifetime access.', + registerCta: 'Create free account', chooseFilters: 'Click Add to filter. The small buttons show data details or colour the map.', searchFeatures: 'Search features...', noMatchingFeatures: 'No matching features', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 3c94b2d..b8905c2 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -624,6 +624,8 @@ const fr: Translations = { resetPassword: 'Réinitialiser le mot de passe', valueProp: 'Enregistrez vos recherches, ajoutez des biens en favoris et créez une sélection de secteurs adaptés à vos critères.', + dashboardActionsValueProp: + 'Créez un compte gratuit pour enregistrer, partager et exporter vos recherches — gratuit, sans carte bancaire.', continueWithGoogle: 'Continuer avec Google', email: 'E-mail', emailPlaceholder: 'vous@exemple.com', @@ -647,7 +649,17 @@ const fr: Translations = { upgrade: { title: 'Débloquez les filtres illimités', description: - 'Les comptes gratuits peuvent combiner jusqu’à 3 filtres à la fois. Obtenez un accès à vie pour superposer un nombre illimité de filtres sur tous les codes postaux et tous les quartiers d’Angleterre. Un seul paiement, pour toujours.', + 'Vous avez utilisé les 5 filtres de votre compte gratuit. Obtenez un accès à vie pour superposer un nombre illimité de filtres sur chaque code postal et quartier d’Angleterre. Un seul paiement, pour toujours.', + descriptionSharedUpgrade: + 'Ajouter des filtres supplémentaires à une recherche partagée nécessite un accès à vie — débloquez des filtres illimités sur chaque code postal d’Angleterre.', + titleRegister: 'Créer un compte gratuit', + descriptionRegister: + 'Les comptes gratuits débloquent jusqu’à 5 filtres ainsi que la sauvegarde, le partage et l’export — sans carte bancaire. Des filtres illimités dans toute l’Angleterre ? Passez à l’accès à vie.', + descriptionSharedRegister: + 'Il s’agit d’une recherche partagée. Créez un compte gratuit pour construire la vôtre avec jusqu’à 5 filtres et enregistrer, partager et exporter — ou passez à l’accès à vie pour des filtres illimités.', + registerFree: 'Créer un compte gratuit', + lifetimeUpsell: 'Des filtres illimités ?', + goLifetime: 'Obtenir l’accès à vie — {{price}}', free: 'Gratuit', freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.', oneTimePayment: 'Paiement unique. Accès à vie.', @@ -656,7 +668,7 @@ const fr: Translations = { upgradeFor: 'Passer à la version complète pour {{price}}', registerAndUpgrade: 'S’inscrire et passer à la version complète', alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous', - continueFree: 'Continuer avec 3 filtres', + continueFree: 'Continuer à explorer pour l’instant', checkoutFailed: 'Le paiement a échoué', }, @@ -696,9 +708,13 @@ const fr: Translations = { addFiltersHint: 'Ajoutez des filtres ci-dessous pour limiter la carte aux secteurs adaptés à vos critères', upgradePrompt: - 'Trouvez les codes postaux compatibles grâce aux filtres de criminalité, d’écoles, de bruit, de haut débit, de prix et plus de 40 filtres combinables dans toute l’Angleterre.', + 'Passez à l’accès à vie pour superposer un nombre illimité de filtres sur chaque code postal d’Angleterre.', oneTimeLifetime: 'Paiement unique, accès à vie.', upgradeToFullMap: 'Passer à la carte complète', + registerPrompt: + 'Créez un compte gratuit pour combiner jusqu’à 5 filtres et enregistrer, partager et exporter vos recherches.', + registerSubPrompt: 'Gratuit — sans carte bancaire. Filtres illimités avec l’accès à vie.', + registerCta: 'Créer un compte gratuit', chooseFilters: 'Cliquez sur Ajouter pour filtrer. Les petits boutons affichent les détails des données ou colorent la carte.', searchFeatures: 'Rechercher des critères...', diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index c35b4db..571014a 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -597,6 +597,8 @@ const hi: Translations = { resetPassword: 'पासवर्ड रीसेट करें', valueProp: 'खोजें सहेजें, संपत्तियों को बुकमार्क करें और अपनी जरूरतों से मेल खाने वाले क्षेत्रों की शॉर्टलिस्ट बनाएं.', + dashboardActionsValueProp: + 'अपनी खोजें सहेजने, साझा करने और export करने के लिए मुफ्त खाता बनाएं — मुफ्त, कार्ड की जरूरत नहीं.', continueWithGoogle: 'Google से जारी रखें', email: 'ईमेल', emailPlaceholder: 'you@example.com', @@ -619,7 +621,17 @@ const hi: Translations = { upgrade: { title: 'असीमित फ़िल्टर अनलॉक करें', description: - 'मुफ्त खाते एक बार में 3 फ़िल्टर तक जोड़ सकते हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.', + 'आपने अपने मुफ्त खाते के सभी 5 फ़िल्टर इस्तेमाल कर लिए हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.', + descriptionSharedUpgrade: + 'साझा खोज में और फ़िल्टर जोड़ने के लिए आजीवन पहुँच चाहिए — इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर अनलॉक करें.', + titleRegister: 'मुफ्त खाता बनाएं', + descriptionRegister: + 'मुफ्त खाते में 5 फ़िल्टर के साथ-साथ सहेजें, साझा करें और export करें — कार्ड की जरूरत नहीं. पूरे इंग्लैंड में असीमित फ़िल्टर चाहिए? आजीवन पहुँच लें.', + descriptionSharedRegister: + 'यह एक साझा खोज है. अपनी खुद की 5 फ़िल्टर तक की खोज बनाने और सहेजने, साझा करने व export करने के लिए मुफ्त खाता बनाएं — या असीमित के लिए आजीवन पहुँच लें.', + registerFree: 'मुफ्त खाता बनाएं', + lifetimeUpsell: 'असीमित फ़िल्टर चाहिए?', + goLifetime: 'आजीवन पहुँच पाएं — {{price}}', free: 'मुफ्त', freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.', oneTimePayment: 'एक बार भुगतान. आजीवन पहुँच.', @@ -628,7 +640,7 @@ const hi: Translations = { upgradeFor: '{{price}} में अपग्रेड करें', registerAndUpgrade: 'पंजीकरण करें और अपग्रेड करें', alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें', - continueFree: '3 फ़िल्टर के साथ जारी रखें', + continueFree: 'अभी के लिए खोज जारी रखें', checkoutFailed: 'चेकआउट विफल रहा', }, @@ -663,9 +675,13 @@ const hi: Translations = { findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है', addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फ़िल्टर जोड़ें', upgradePrompt: - 'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 40+ संयोजित फ़िल्टर से मेल खाने वाले पोस्टकोड खोजें.', + 'इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच लें.', oneTimeLifetime: 'एक बार भुगतान, आजीवन पहुँच.', upgradeToFullMap: 'पूरे मानचित्र पर अपग्रेड करें', + registerPrompt: + '5 फ़िल्टर तक जोड़ने और अपनी खोजें सहेजने, साझा करने व export करने के लिए मुफ्त खाता बनाएं.', + registerSubPrompt: 'मुफ्त — कार्ड की जरूरत नहीं. आजीवन पहुँच के साथ असीमित फ़िल्टर.', + registerCta: 'मुफ्त खाता बनाएं', chooseFilters: 'फ़िल्टर लगाने के लिए जोड़ें पर क्लिक करें. छोटे बटन डेटा विवरण दिखाते हैं या मानचित्र को रंगते हैं.', searchFeatures: 'सुविधाएं खोजें...', diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index 2b9aa73..6bca033 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -615,6 +615,8 @@ const hu: Translations = { resetPassword: 'Jelszó visszaállítása', valueProp: 'Mentsd el a kereséseidet, jelöld meg az ingatlanokat, és állíts össze egy listát a megfelelő területekből.', + dashboardActionsValueProp: + 'Hozz létre egy ingyenes fiókot a kereséseid mentéséhez, megosztásához és exportálásához – ingyenes, kártya nélkül.', continueWithGoogle: 'Folytatás Google-lel', email: 'E-mail', emailPlaceholder: 'te@pelda.hu', @@ -638,7 +640,17 @@ const hu: Translations = { upgrade: { title: 'Oldd fel a korlátlan szűrőket', description: - 'Az ingyenes fiókok egyszerre legfeljebb 3 szűrőt kombinálhatnak. Szerezz élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán és környékén. Egyetlen fizetés, örökre.', + 'Felhasználtad az ingyenes fiókod mind az 5 szűrőjét. Szerezz élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán és környékén. Egyetlen fizetés, örökre.', + descriptionSharedUpgrade: + 'Egy megosztott kereséshez több szűrőt hozzáadni élethosszig tartó hozzáférést igényel – oldd fel a korlátlan szűrőket Anglia minden irányítószámán.', + titleRegister: 'Ingyenes fiók létrehozása', + descriptionRegister: + 'Az ingyenes fiókok akár 5 szűrőt oldanak fel, ráadásul menthetsz, megoszthatsz és exportálhatsz – kártya nélkül. Korlátlan szűrőket szeretnél egész Angliában? Válaszd az élethosszig tartó hozzáférést.', + descriptionSharedRegister: + 'Ez egy megosztott keresés. Hozz létre egy ingyenes fiókot, hogy sajátot készíthess akár 5 szűrővel, és menthess, megoszthass és exportálhass – vagy válassz élethosszig tartó hozzáférést a korlátlan szűrőkért.', + registerFree: 'Ingyenes fiók létrehozása', + lifetimeUpsell: 'Korlátlan szűrőket szeretnél?', + goLifetime: 'Élethosszig tartó hozzáférés – {{price}}', free: 'Ingyenes', freeForEarly: 'Ingyenes a korai felhasználóknak. Bankkártya nélkül.', oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.', @@ -647,7 +659,7 @@ const hu: Translations = { upgradeFor: 'Teljes hozzáférés {{price}} áron', registerAndUpgrade: 'Regisztráció és teljes hozzáférés', alreadyHaveAccount: 'Már van fiókod? Jelentkezz be', - continueFree: 'Folytatás 3 szűrővel', + continueFree: 'Egyelőre tovább fedezem fel', checkoutFailed: 'A fizetés sikertelen', }, @@ -685,9 +697,13 @@ const hu: Translations = { findingPerfectPostcode: 'Tökéletes irányítószám keresése', addFiltersHint: 'Adj hozzá szűrőket a térkép szűkítéséhez a feltételeidnek megfelelően', upgradePrompt: - 'Találj megfelelő irányítószámokat bűnözés, iskolák, zaj, szélessáv, árak és több mint 40 kombinálható szűrő alapján egész Angliában.', + 'Válaszd az élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán.', oneTimeLifetime: 'Egyszeri fizetés, élethosszig tartó hozzáférés.', upgradeToFullMap: 'Teljes térkép megnyitása', + registerPrompt: + 'Hozz létre egy ingyenes fiókot, hogy akár 5 szűrőt kombinálhass, és menthess, megoszthass és exportálhass kereséseket.', + registerSubPrompt: 'Ingyenes – kártya nélkül. Korlátlan szűrők élethosszig tartó hozzáféréssel.', + registerCta: 'Ingyenes fiók létrehozása', chooseFilters: 'Kattints a Hozzáadásra a szűréshez. A kis gombok adatokat mutatnak vagy színezik a térképet.', searchFeatures: 'Szűrők keresése...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8eaed83..84f1ff3 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -563,6 +563,7 @@ const zh: Translations = { createAccount: '注册账户', resetPassword: '重置密码', valueProp: '保存搜索、收藏房产,并整理出符合您需求的候选区域。', + dashboardActionsValueProp: '注册免费账户,保存、分享并导出您的搜索 — 免费,无需信用卡。', continueWithGoogle: '使用 Google 账号继续', email: '邮箱', emailPlaceholder: 'you@example.com', @@ -586,7 +587,7 @@ const zh: Translations = { upgrade: { title: '解锁无限筛选', description: - '免费账户每次最多可同时组合 3 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。', + '您的免费账户已使用全部 5 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。', free: '免费', freeForEarly: '早期用户免费。无需信用卡。', oneTimePayment: '一次性付款。终身访问。', @@ -595,7 +596,17 @@ const zh: Translations = { upgradeFor: '以 {{price}} 升级', registerAndUpgrade: '注册并升级', alreadyHaveAccount: '已有账户?请登录', - continueFree: '继续使用 3 个筛选条件', + continueFree: '暂时继续浏览', + descriptionSharedUpgrade: + '向分享的搜索添加更多筛选条件需要终身访问权限 — 解锁英格兰每个邮编的无限筛选。', + titleRegister: '注册免费账户', + descriptionRegister: + '免费账户解锁最多 5 个筛选条件,并支持保存、分享和导出 — 无需信用卡。想要在整个英格兰使用无限筛选?选择终身访问。', + descriptionSharedRegister: + '这是一个分享的搜索。注册免费账户,可使用最多 5 个筛选条件,并保存、分享和导出 — 或选择终身访问以解锁无限筛选。', + registerFree: '注册免费账户', + lifetimeUpsell: '想要无限筛选?', + goLifetime: '获取终身访问权限 — {{price}}', checkoutFailed: '结账失败', }, @@ -632,8 +643,10 @@ const zh: Translations = { addFilter: '添加筛选条件', findingPerfectPostcode: '正在寻找理想邮编', addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域', - upgradePrompt: - '用治安、学校、噪音、宽带、价格等 40 多项联动筛选条件,在整个英格兰找到匹配的邮编。', + upgradePrompt: '选择终身访问,在英格兰每个邮编上叠加无限筛选条件。', + registerPrompt: '注册免费账户,可组合最多 5 个筛选条件,并保存、分享和导出您的搜索。', + registerSubPrompt: '免费 — 无需信用卡。终身访问可解锁无限筛选。', + registerCta: '注册免费账户', oneTimeLifetime: '一次性付款,终身访问。', upgradeToFullMap: '升级到完整地图', chooseFilters: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。', From 7bc591be2b57cdac080c32cd0e630f80f1ba418c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 26 Jun 2026 19:57:59 +0100 Subject: [PATCH 2/2] fmt --- frontend/src/components/map/MapPage.tsx | 5 +--- frontend/src/i18n/locales/en.ts | 3 +-- frontend/src/i18n/locales/hi.ts | 3 +-- frontend/src/i18n/locales/hu.ts | 3 ++- frontend/src/lib/url-state.test.ts | 5 ++-- server-rs/src/data/crime_records.rs | 33 +++++++++++++++++------ server-rs/src/data/postcode_population.rs | 5 +--- server-rs/src/data/spill.rs | 15 ++++++++--- server-rs/src/routes/crime_records.rs | 24 ++++++++++------- server-rs/src/routes/stats.rs | 19 ++++--------- 10 files changed, 64 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 2a014b3..ee9fd93 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -707,10 +707,7 @@ export default function MapPage({ // link adds are blocked regardless of count, so don't auto-clear there — the user // dismisses it themselves. Prevents a stale flag from resurfacing spuriously. useEffect(() => { - if ( - filtersUnlimited || - (!lockFilterAdds && Object.keys(filters).length < effectiveFilterCap) - ) { + if (filtersUnlimited || (!lockFilterAdds && Object.keys(filters).length < effectiveFilterCap)) { setFilterLimitHit(false); } }, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters]); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 8a27a76..a13b540 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -682,8 +682,7 @@ const en = { addFilter: 'Add filters', findingPerfectPostcode: 'Finding the Perfect Postcode', addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria', - upgradePrompt: - 'Go lifetime to stack unlimited filters across every postcode in England.', + upgradePrompt: 'Go lifetime to stack unlimited filters across every postcode in England.', oneTimeLifetime: 'One-time payment, lifetime access.', upgradeToFullMap: 'Upgrade to full map', registerPrompt: diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index 571014a..cbee11d 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -674,8 +674,7 @@ const hi: Translations = { addFilter: 'फ़िल्टर जोड़ें', findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है', addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फ़िल्टर जोड़ें', - upgradePrompt: - 'इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच लें.', + upgradePrompt: 'इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच लें.', oneTimeLifetime: 'एक बार भुगतान, आजीवन पहुँच.', upgradeToFullMap: 'पूरे मानचित्र पर अपग्रेड करें', registerPrompt: diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index 6bca033..ccc5abc 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -702,7 +702,8 @@ const hu: Translations = { upgradeToFullMap: 'Teljes térkép megnyitása', registerPrompt: 'Hozz létre egy ingyenes fiókot, hogy akár 5 szűrőt kombinálhass, és menthess, megoszthass és exportálhass kereséseket.', - registerSubPrompt: 'Ingyenes – kártya nélkül. Korlátlan szűrők élethosszig tartó hozzáféréssel.', + registerSubPrompt: + 'Ingyenes – kártya nélkül. Korlátlan szűrők élethosszig tartó hozzáféréssel.', registerCta: 'Ingyenes fiók létrehozása', chooseFilters: 'Kattints a Hozzáadásra a szűréshez. A kis gombok adatokat mutatnak vagy színezik a térképet.', diff --git a/frontend/src/lib/url-state.test.ts b/frontend/src/lib/url-state.test.ts index c59ee96..3645bd1 100644 --- a/frontend/src/lib/url-state.test.ts +++ b/frontend/src/lib/url-state.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import type { FeatureMeta } from '../types'; import { parseUrlState, stateToParams } from './url-state'; +import { DEFAULT_OVERLAY_IDS } from './overlays'; import { INITIAL_VIEW_STATE } from './consts'; import { createSchoolFilterKey } from './school-filter'; import { createSpecificCrimeFilterKey } from './crime-filter'; @@ -199,10 +200,10 @@ describe('url-state', () => { expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots'])); }); - it('enables crime + trees overlays by default on a fresh visit', () => { + it('enables the default overlays on a fresh visit', () => { const state = parseUrlState(); - expect(state.overlays).toEqual(new Set(['crime-hotspots', 'trees-outside-woodlands'])); + expect(state.overlays).toEqual(new Set(DEFAULT_OVERLAY_IDS)); }); it('round-trips an explicit "all overlays off" via the __none sentinel', () => { diff --git a/server-rs/src/data/crime_records.rs b/server-rs/src/data/crime_records.rs index 8402ffb..3aa99d3 100644 --- a/server-rs/src/data/crime_records.rs +++ b/server-rs/src/data/crime_records.rs @@ -158,7 +158,10 @@ impl CrimeRecords { ParquetReader::new(file) .get_metadata() .with_context(|| { - format!("Failed to read crime-records parquet metadata at {}", path.display()) + format!( + "Failed to read crime-records parquet metadata at {}", + path.display() + ) })? .clone() }; @@ -193,10 +196,18 @@ impl CrimeRecords { // the column builders' write order. let mut global_row: u32 = 0; - let columns: Vec = ["postcode", "month_index", "crime_type", "location", "outcome", "lat", "lon"] - .iter() - .map(|s| s.to_string()) - .collect(); + let columns: Vec = [ + "postcode", + "month_index", + "crime_type", + "location", + "outcome", + "lat", + "lon", + ] + .iter() + .map(|s| s.to_string()) + .collect(); let mut offset = 0usize; while offset < n { @@ -337,7 +348,10 @@ impl CrimeRecords { if let Some(prev) = cur_pc.take() { by_postcode.insert(prev, (cur_start, global_row - cur_start)); } - debug_assert_eq!(global_row as usize, n, "streamed fewer rows than the parquet declares"); + debug_assert_eq!( + global_row as usize, n, + "streamed fewer rows than the parquet declares" + ); let records = Self { month: month.finish()?, @@ -413,7 +427,7 @@ mod tests { .unwrap(); assert_eq!(robbery.outcome, Some("Court result")); assert_eq!(robbery.location, None); // null location → None - // Two records have a null outcome (an AA1 Burglary and the BB2 Drugs). + // Two records have a null outcome (an AA1 Burglary and the BB2 Drugs). let null_outcomes = all .iter() .map(|&i| recs.view(i)) @@ -518,7 +532,10 @@ mod tests { rss_after, ); - assert!(!recs.by_postcode.is_empty(), "expected at least one postcode"); + assert!( + !recs.by_postcode.is_empty(), + "expected at least one postcode" + ); assert!(total > 0, "expected at least one record"); // The old `.collect()` decoded all rows' string columns at once (tens of // GB). Streaming must keep the peak growth far below that; a generous 20GiB diff --git a/server-rs/src/data/postcode_population.rs b/server-rs/src/data/postcode_population.rs index 928e4cf..1913b3f 100644 --- a/server-rs/src/data/postcode_population.rs +++ b/server-rs/src/data/postcode_population.rs @@ -82,10 +82,7 @@ impl PostcodePopulation { bail!("population parquet at {} produced no rows", path.display()); } - info!( - postcodes = by_postcode.len(), - "Postcode population loaded" - ); + info!(postcodes = by_postcode.len(), "Postcode population loaded"); Ok(Self { by_postcode }) } diff --git a/server-rs/src/data/spill.rs b/server-rs/src/data/spill.rs index 5468d31..a655cda 100644 --- a/server-rs/src/data/spill.rs +++ b/server-rs/src/data/spill.rs @@ -152,7 +152,10 @@ pub struct SpillVecBuilder { } enum Builder { - Owned { values: Vec, len: usize }, + Owned { + values: Vec, + len: usize, + }, Mapped { map: MmapMut, len: usize, @@ -462,7 +465,9 @@ mod tests { #[test] fn builder_streams_identically_owned_and_mapped() { - let values: Vec = (0..50_000u32).map(|n| n.wrapping_mul(2_246_822_519)).collect(); + let values: Vec = (0..50_000u32) + .map(|n| n.wrapping_mul(2_246_822_519)) + .collect(); // Owned path (no spill dir): pushes accumulate into a heap Vec. let mut owned = SpillVecBuilder::::with_len(values.len(), None, "u32_owned").unwrap(); @@ -497,7 +502,8 @@ mod tests { let dir = TempDir::new("builder-spur"); let mut builder = - SpillVecBuilder::::with_len(keys.len(), Some(dir.path()), "spurs").unwrap(); + SpillVecBuilder::::with_len(keys.len(), Some(dir.path()), "spurs") + .unwrap(); for &k in &keys { builder.push(k); } @@ -536,7 +542,8 @@ mod tests { #[should_panic(expected = "overflow")] fn builder_overfill_panics() { let dir = TempDir::new("builder-overfill"); - let mut builder = SpillVecBuilder::::with_len(2, Some(dir.path()), "overfill").unwrap(); + let mut builder = + SpillVecBuilder::::with_len(2, Some(dir.path()), "overfill").unwrap(); builder.push(1); builder.push(2); builder.push(3); // one past the declared length diff --git a/server-rs/src/routes/crime_records.rs b/server-rs/src/routes/crime_records.rs index fd95cf7..3d698f3 100644 --- a/server-rs/src/routes/crime_records.rs +++ b/server-rs/src/routes/crime_records.rs @@ -99,7 +99,11 @@ pub async fn get_crime_records( .postcode_to_idx .get(&normalized) .ok_or_else(|| { - (StatusCode::NOT_FOUND, format!("Postcode not found: {normalized}")).into_response() + ( + StatusCode::NOT_FOUND, + format!("Postcode not found: {normalized}"), + ) + .into_response() })?; Selection::Postcode(normalized) } else { @@ -120,12 +124,9 @@ pub async fn get_crime_records( let mut h3_cache: FxHashMap = FxHashMap::default(); let mut seen: FxHashSet<&str> = FxHashSet::default(); let mut out: Vec = Vec::new(); - state.grid.for_each_in_bounds( - min_lat, - min_lon, - max_lat, - max_lon, - |row_idx| { + 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_cached( row, @@ -140,8 +141,7 @@ pub async fn get_crime_records( out.push(pc.to_string()); } } - }, - ); + }); out } }; @@ -177,6 +177,10 @@ pub async fn get_crime_records( .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())? .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?; - info!(total = result.total, returned = result.records.len(), "GET /api/crime-records"); + info!( + total = result.total, + returned = result.records.len(), + "GET /api/crime-records" + ); Ok(Json(result)) } diff --git a/server-rs/src/routes/stats.rs b/server-rs/src/routes/stats.rs index bf1d4cf..d1a210a 100644 --- a/server-rs/src/routes/stats.rs +++ b/server-rs/src/routes/stats.rs @@ -11,8 +11,8 @@ use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData}; use crate::utils::{postcode_outcode, postcode_sector}; use super::hexagon_stats::{ - CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats, - HistogramStats, NumericFeatureStats, PricePoint, + CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats, + NumericFeatureStats, PricePoint, }; /// Extract price history (year, price) pairs from matching rows, downsampled if needed. @@ -398,7 +398,6 @@ pub fn compute_crime_by_year( out } - /// Latest year present anywhere in the by-year crime dataset. The client /// compares each selection's last charted year against this to caption /// force-level publication gaps (e.g. Greater Manchester ends mid-2019) as @@ -615,18 +614,12 @@ mod tests { assert_eq!(sector.as_deref(), Some("E14 2")); assert_eq!(out.len(), 2); - let burglary = out - .iter() - .find(|c| c.name == "Burglary (/yr, 7y)") - .unwrap(); + let burglary = out.iter().find(|c| c.name == "Burglary (/yr, 7y)").unwrap(); assert_eq!(burglary.national, Some(8.0)); assert_eq!(burglary.outcode, Some(10.0)); assert_eq!(burglary.sector, Some(5.0)); - let robbery = out - .iter() - .find(|c| c.name == "Robbery (/yr, 7y)") - .unwrap(); + let robbery = out.iter().find(|c| c.name == "Robbery (/yr, 7y)").unwrap(); assert_eq!(robbery.national, Some(6.0)); // The outcode value was NaN — dropped to None; the sector value is finite. assert_eq!(robbery.outcode, None); @@ -637,9 +630,7 @@ mod tests { fn area_crime_averages_respect_fields_filter() { let avgs = sample_averages(); // Area averages are keyed by the full crime-feature name. - let fields: HashSet = ["Burglary (/yr, 7y)".to_string()] - .into_iter() - .collect(); + let fields: HashSet = ["Burglary (/yr, 7y)".to_string()].into_iter().collect(); let (_, _, out) = area_crime_averages_for(Some("E14 2DG"), &avgs, true, &fields); assert_eq!(out.len(), 1); assert_eq!(out[0].name, "Burglary (/yr, 7y)");