refactor: extract shared utility functions to eliminate duplication
This commit is contained in:
parent
b720013a08
commit
1037ff164d
10 changed files with 85 additions and 110 deletions
|
|
@ -22,6 +22,7 @@ import { getCached, setCached, invalidateAll as invalidateListingCache } from '@
|
||||||
import { setOnUnauthorized } from '@/services/apiClient';
|
import { setOnUnauthorized } from '@/services/apiClient';
|
||||||
import { clearPasskeyUser } from './auth/passkeyService';
|
import { clearPasskeyUser } from './auth/passkeyService';
|
||||||
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
||||||
|
import { isTerminalStatus } from '@/utils/taskUtils';
|
||||||
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
||||||
import { useDecisions } from '@/hooks/useDecisions';
|
import { useDecisions } from '@/hooks/useDecisions';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
|
@ -30,10 +31,6 @@ import { SwipeReviewMode } from './components/SwipeReviewMode';
|
||||||
import { FavoritesView } from './components/FavoritesView';
|
import { FavoritesView } from './components/FavoritesView';
|
||||||
import { ListingDetailSheet } from './components/ListingDetailSheet';
|
import { ListingDetailSheet } from './components/ListingDetailSheet';
|
||||||
|
|
||||||
function isTerminalStatus(status: string): boolean {
|
|
||||||
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,7 @@ import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock,
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { PhotoCarousel } from './PhotoCarousel';
|
import { PhotoCarousel } from './PhotoCarousel';
|
||||||
import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types';
|
import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types';
|
||||||
|
import { formatDate, formatDuration } from '@/utils/format';
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListingDetailProps {
|
interface ListingDetailProps {
|
||||||
detail: ListingDetailData;
|
detail: ListingDetailData;
|
||||||
|
|
@ -15,14 +10,6 @@ interface ListingDetailProps {
|
||||||
onClearDecision: () => void;
|
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 }) {
|
function TravelModeIcon({ mode }: { mode: string }) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'WALK': return <Footprints className="h-3 w-3" />;
|
case 'WALK': return <Footprints className="h-3 w-3" />;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { MapPin, PoundSterling } from 'lucide-react';
|
||||||
import { SwipeableCardRow } from './SwipeableCardRow';
|
import { SwipeableCardRow } from './SwipeableCardRow';
|
||||||
import { ListView } from './ListView';
|
import { ListView } from './ListView';
|
||||||
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
|
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
|
||||||
|
import { formatCurrency } from '@/utils/format';
|
||||||
|
|
||||||
interface MobileBottomSheetProps {
|
interface MobileBottomSheetProps {
|
||||||
listingData: GeoJSONFeatureCollection | null;
|
listingData: GeoJSONFeatureCollection | null;
|
||||||
|
|
@ -14,11 +15,6 @@ interface MobileBottomSheetProps {
|
||||||
onSnapChange?: (snap: string | number | null) => void;
|
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({
|
export function MobileBottomSheet({
|
||||||
listingData,
|
listingData,
|
||||||
onPropertyClick,
|
onPropertyClick,
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,7 @@ import useEmblaCarousel from 'embla-carousel-react';
|
||||||
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
|
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
||||||
|
import { formatDate, formatDuration } from '@/utils/format';
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TravelModeIcon({ mode }: { mode: string }) {
|
function TravelModeIcon({ mode }: { mode: string }) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart } from 'lucide-react';
|
import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types';
|
import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types';
|
||||||
|
import { formatCurrency } from '@/utils/format';
|
||||||
|
|
||||||
export type ViewMode = 'map' | 'list' | 'split' | 'saved';
|
export type ViewMode = 'map' | 'list' | 'split' | 'saved';
|
||||||
|
|
||||||
|
|
@ -53,13 +54,6 @@ function calculateStats(data: GeoJSONFeatureCollection | null): ListingStats {
|
||||||
return { count, avgPrice, avgPricePerSqm, avgSize };
|
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) {
|
export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount = 0 }: StatsBarProps) {
|
||||||
const stats = calculateStats(listingData);
|
const stats = calculateStats(listingData);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
|
import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
|
||||||
import { TaskProgressDrawer } from './TaskProgressDrawer';
|
import { TaskProgressDrawer } from './TaskProgressDrawer';
|
||||||
|
import { isTerminalStatus, taskStateToResult } from '@/utils/taskUtils';
|
||||||
|
|
||||||
interface TaskIndicatorProps {
|
interface TaskIndicatorProps {
|
||||||
tasks: Record<string, TaskState>;
|
tasks: Record<string, TaskState>;
|
||||||
|
|
@ -14,37 +15,6 @@ interface TaskIndicatorProps {
|
||||||
onTaskCompleted?: () => void;
|
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({
|
export function TaskIndicator({
|
||||||
tasks,
|
tasks,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Button } from './ui/button';
|
||||||
import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react';
|
import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react';
|
||||||
import { useEffect, useRef, useMemo } from 'react';
|
import { useEffect, useRef, useMemo } from 'react';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { isTerminalStatus, taskStateToResult } from '@/utils/taskUtils';
|
||||||
|
|
||||||
interface TaskProgressDrawerProps {
|
interface TaskProgressDrawerProps {
|
||||||
open: boolean;
|
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 {
|
function getPhaseIndex(phase: TaskPhase | undefined): number {
|
||||||
if (!phase) return -1;
|
if (!phase) return -1;
|
||||||
if (phase === 'splitting_complete') 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({
|
function TaskTabBar({
|
||||||
tasks,
|
tasks,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { AuthUser } from '@/auth/types';
|
||||||
import type { TaskState, TaskStatusResponse, WSMessage } from '@/types';
|
import type { TaskState, TaskStatusResponse, WSMessage } from '@/types';
|
||||||
import { WS_TASKS_PATH } from '@/constants';
|
import { WS_TASKS_PATH } from '@/constants';
|
||||||
import { fetchTasksForUser, fetchTaskStatus } from '@/services';
|
import { fetchTasksForUser, fetchTaskStatus } from '@/services';
|
||||||
|
import { isTerminalStatus } from '@/utils/taskUtils';
|
||||||
|
|
||||||
const KEEPALIVE_MS = 30_000;
|
const KEEPALIVE_MS = 30_000;
|
||||||
const MAX_RECONNECT_DELAY_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)}`;
|
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. */
|
/** Convert an HTTP TaskStatusResponse into the canonical TaskState shape. */
|
||||||
function httpResponseToTaskState(resp: TaskStatusResponse): TaskState {
|
function httpResponseToTaskState(resp: TaskStatusResponse): TaskState {
|
||||||
const state: TaskState = {
|
const state: TaskState = {
|
||||||
|
|
|
||||||
37
frontend/src/utils/format.ts
Normal file
37
frontend/src/utils/format.ts
Normal file
|
|
@ -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²`;
|
||||||
|
}
|
||||||
40
frontend/src/utils/taskUtils.ts
Normal file
40
frontend/src/utils/taskUtils.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue