diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd440a3..6177d8e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { getCached, setCached, invalidateAll as invalidateListingCache } from '@ import { setOnUnauthorized } from '@/services/apiClient'; import { clearPasskeyUser } from './auth/passkeyService'; import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils'; +import { isTerminalStatus } from '@/utils/taskUtils'; import { useTaskProgress } from '@/hooks/useTaskProgress'; import { useDecisions } from '@/hooks/useDecisions'; import { useIsMobile } from '@/hooks/use-mobile'; @@ -30,10 +31,6 @@ import { SwipeReviewMode } from './components/SwipeReviewMode'; import { FavoritesView } from './components/FavoritesView'; import { ListingDetailSheet } from './components/ListingDetailSheet'; -function isTerminalStatus(status: string): boolean { - return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; -} - function App() { const [listingData, setListingData] = useState(null); const [user, setUser] = useState(null); diff --git a/frontend/src/components/ListingDetail.tsx b/frontend/src/components/ListingDetail.tsx index ff86995..3f04435 100644 --- a/frontend/src/components/ListingDetail.tsx +++ b/frontend/src/components/ListingDetail.tsx @@ -2,12 +2,7 @@ import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, import { Button } from './ui/button'; import { PhotoCarousel } from './PhotoCarousel'; import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types'; - -function formatDate(value: string): string { - const date = new Date(value); - if (isNaN(date.getTime())) return value; - return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); -} +import { formatDate, formatDuration } from '@/utils/format'; interface ListingDetailProps { detail: ListingDetailData; @@ -15,14 +10,6 @@ interface ListingDetailProps { onClearDecision: () => void; } -function formatDuration(seconds: number): string { - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; -} - function TravelModeIcon({ mode }: { mode: string }) { switch (mode) { case 'WALK': return ; diff --git a/frontend/src/components/MobileBottomSheet.tsx b/frontend/src/components/MobileBottomSheet.tsx index 3337fe6..79a0fb4 100644 --- a/frontend/src/components/MobileBottomSheet.tsx +++ b/frontend/src/components/MobileBottomSheet.tsx @@ -4,6 +4,7 @@ import { MapPin, PoundSterling } from 'lucide-react'; import { SwipeableCardRow } from './SwipeableCardRow'; import { ListView } from './ListView'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types'; +import { formatCurrency } from '@/utils/format'; interface MobileBottomSheetProps { listingData: GeoJSONFeatureCollection | null; @@ -14,11 +15,6 @@ interface MobileBottomSheetProps { onSnapChange?: (snap: string | number | null) => void; } -function formatCurrency(value: number): string { - if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`; - return `£${Math.round(value)}`; -} - export function MobileBottomSheet({ listingData, onPropertyClick, diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index e4c1767..db49492 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -3,20 +3,7 @@ import useEmblaCarousel from 'embla-carousel-react'; import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react'; import { Button } from './ui/button'; import type { PropertyProperties, POIDistanceInfo, POI } from '@/types'; - -function formatDate(value: string): string { - const date = new Date(value); - if (isNaN(date.getTime())) return value; - return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); -} - -function formatDuration(seconds: number): string { - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; -} +import { formatDate, formatDuration } from '@/utils/format'; function TravelModeIcon({ mode }: { mode: string }) { switch (mode) { diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 00b4ef6..308a346 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -1,6 +1,7 @@ import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart } from 'lucide-react'; import { Button } from './ui/button'; import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types'; +import { formatCurrency } from '@/utils/format'; export type ViewMode = 'map' | 'list' | 'split' | 'saved'; @@ -53,13 +54,6 @@ function calculateStats(data: GeoJSONFeatureCollection | null): ListingStats { return { count, avgPrice, avgPricePerSqm, avgSize }; } -function formatCurrency(value: number): string { - if (value >= 1000) { - return `£${(value / 1000).toFixed(1)}k`; - } - return `£${Math.round(value)}`; -} - export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount = 0 }: StatsBarProps) { const stats = calculateStats(listingData); diff --git a/frontend/src/components/TaskIndicator.tsx b/frontend/src/components/TaskIndicator.tsx index 2672489..542bc2e 100644 --- a/frontend/src/components/TaskIndicator.tsx +++ b/frontend/src/components/TaskIndicator.tsx @@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t import { Button } from './ui/button'; import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react'; import { TaskProgressDrawer } from './TaskProgressDrawer'; +import { isTerminalStatus, taskStateToResult } from '@/utils/taskUtils'; interface TaskIndicatorProps { tasks: Record; @@ -14,37 +15,6 @@ interface TaskIndicatorProps { onTaskCompleted?: () => void; } -/** Convert a TaskState into a TaskResult (for the drawer). */ -function taskStateToResult(ts: TaskState): TaskResult { - return { - progress: ts.progress ?? 0, - processed: ts.processed, - total: ts.total, - phase: ts.phase, - message: ts.message, - subqueries_probed: ts.subqueries_probed, - subqueries_initial: ts.subqueries_initial, - estimated_results: ts.estimated_results, - subqueries_total: ts.subqueries_total, - subqueries_completed: ts.subqueries_completed, - ids_collected: ts.ids_collected, - pages_fetched: ts.pages_fetched, - fetching_done: ts.fetching_done, - details_fetched: ts.details_fetched, - images_downloaded: ts.images_downloaded, - ocr_completed: ts.ocr_completed, - failed: ts.failed, - elapsed_seconds: ts.elapsed_seconds, - rate_per_second: ts.rate_per_second, - eta_seconds: ts.eta_seconds, - logs: ts.logs, - }; -} - -function isTerminalStatus(status: string): boolean { - return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; -} - export function TaskIndicator({ tasks, activeTaskId, diff --git a/frontend/src/components/TaskProgressDrawer.tsx b/frontend/src/components/TaskProgressDrawer.tsx index ca90139..64552ae 100644 --- a/frontend/src/components/TaskProgressDrawer.tsx +++ b/frontend/src/components/TaskProgressDrawer.tsx @@ -11,6 +11,7 @@ import { Button } from './ui/button'; import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react'; import { useEffect, useRef, useMemo } from 'react'; import { useIsMobile } from '@/hooks/use-mobile'; +import { isTerminalStatus, taskStateToResult } from '@/utils/taskUtils'; interface TaskProgressDrawerProps { open: boolean; @@ -62,10 +63,6 @@ function taskTypeIcon(type: 'scrape' | 'poi' | 'task') { } } -function isTerminalStatus(status: string): boolean { - return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; -} - function getPhaseIndex(phase: TaskPhase | undefined): number { if (!phase) return -1; if (phase === 'splitting_complete') return 1; @@ -324,33 +321,6 @@ function LogViewer({ logs }: { logs: string[] }) { ); } -/** Convert TaskState → TaskResult for existing phase detail components. */ -function taskStateToResult(ts: TaskState): TaskResult { - return { - progress: ts.progress ?? 0, - processed: ts.processed, - total: ts.total, - phase: ts.phase, - message: ts.message, - subqueries_probed: ts.subqueries_probed, - subqueries_initial: ts.subqueries_initial, - estimated_results: ts.estimated_results, - subqueries_total: ts.subqueries_total, - subqueries_completed: ts.subqueries_completed, - ids_collected: ts.ids_collected, - pages_fetched: ts.pages_fetched, - fetching_done: ts.fetching_done, - details_fetched: ts.details_fetched, - images_downloaded: ts.images_downloaded, - ocr_completed: ts.ocr_completed, - failed: ts.failed, - elapsed_seconds: ts.elapsed_seconds, - rate_per_second: ts.rate_per_second, - eta_seconds: ts.eta_seconds, - logs: ts.logs, - }; -} - function TaskTabBar({ tasks, selectedTaskId, diff --git a/frontend/src/hooks/useTaskProgress.ts b/frontend/src/hooks/useTaskProgress.ts index 6606c8b..574226b 100644 --- a/frontend/src/hooks/useTaskProgress.ts +++ b/frontend/src/hooks/useTaskProgress.ts @@ -3,6 +3,7 @@ import type { AuthUser } from '@/auth/types'; import type { TaskState, TaskStatusResponse, WSMessage } from '@/types'; import { WS_TASKS_PATH } from '@/constants'; import { fetchTasksForUser, fetchTaskStatus } from '@/services'; +import { isTerminalStatus } from '@/utils/taskUtils'; const KEEPALIVE_MS = 30_000; const MAX_RECONNECT_DELAY_MS = 30_000; @@ -14,10 +15,6 @@ function wsUrl(token: string): string { return `${proto}://${window.location.host}${WS_TASKS_PATH}?token=${encodeURIComponent(token)}`; } -function isTerminalStatus(status: string): boolean { - return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; -} - /** Convert an HTTP TaskStatusResponse into the canonical TaskState shape. */ function httpResponseToTaskState(resp: TaskStatusResponse): TaskState { const state: TaskState = { diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..7bf2ed2 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,37 @@ +/** + * Shared formatting utility functions. + * + * Consolidates duplicated formatters that were previously defined inline in + * PropertyCard, ListingDetail, MobileBottomSheet, and StatsBar. + */ + +/** Format a number as a compact GBP string, e.g. "£1.2k" or "£950". */ +export function formatCurrency(value: number): string { + if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`; + return `£${Math.round(value)}`; +} + +/** Format a duration in seconds as a human-readable string, e.g. "12m" or "1h30m". */ +export function formatDuration(seconds: number): string { + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; +} + +/** Format an ISO date string as a localised short date (e.g. "3 Jan 2025"). */ +export function formatDate(value: string): string { + const date = new Date(value); + if (isNaN(date.getTime())) return value; + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +/** + * Compute the price per square metre as a formatted string, e.g. "£4,500/m²". + * Returns null when square metres data is unavailable. + */ +export function formatPricePerSqm(price: number, sqm: number | null | undefined): string | null { + if (sqm == null || sqm <= 0) return null; + return `£${Math.round(price / sqm).toLocaleString()}/m²`; +} diff --git a/frontend/src/utils/taskUtils.ts b/frontend/src/utils/taskUtils.ts new file mode 100644 index 0000000..c27697c --- /dev/null +++ b/frontend/src/utils/taskUtils.ts @@ -0,0 +1,40 @@ +/** + * Shared task-related utility functions. + * + * Consolidates helpers that were duplicated across App.tsx, TaskIndicator.tsx, + * TaskProgressDrawer.tsx, and useTaskProgress.ts. + */ + +import type { TaskState, TaskResult } from '@/types'; + +/** Returns true when the status represents a terminal (finished) task state. */ +export function isTerminalStatus(status: string): boolean { + return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; +} + +/** Convert a TaskState into a TaskResult (for the drawer). */ +export function taskStateToResult(ts: TaskState): TaskResult { + return { + progress: ts.progress ?? 0, + processed: ts.processed, + total: ts.total, + phase: ts.phase, + message: ts.message, + subqueries_probed: ts.subqueries_probed, + subqueries_initial: ts.subqueries_initial, + estimated_results: ts.estimated_results, + subqueries_total: ts.subqueries_total, + subqueries_completed: ts.subqueries_completed, + ids_collected: ts.ids_collected, + pages_fetched: ts.pages_fetched, + fetching_done: ts.fetching_done, + details_fetched: ts.details_fetched, + images_downloaded: ts.images_downloaded, + ocr_completed: ts.ocr_completed, + failed: ts.failed, + elapsed_seconds: ts.elapsed_seconds, + rate_per_second: ts.rate_per_second, + eta_seconds: ts.eta_seconds, + logs: ts.logs, + }; +}