Improve frontend UI/UX: accessibility, discoverability, and cleanup

- Add prefers-reduced-motion support (global CSS + SwipeCard spring config)
- Add dismissible map click hint overlay with localStorage persistence
- Replace generic image alt text with descriptive property info across 4 components
- Rename filter buttons to clarify intent (Show Matching Listings / Scrape New from Rightmove)
- Fix mobile FAB overlap with bottom sheet via dynamic snap-aware positioning
- Add swipe review onboarding overlay with gesture explanations and button labels
- Delete unused components: AppSidebar, ActiveQuery, SavedView
This commit is contained in:
Viktor Barzin 2026-02-22 18:47:09 +00:00
parent 268f4fd272
commit 339e2cf2ab
No known key found for this signature in database
GPG key ID: 0EB088298288D958
13 changed files with 143 additions and 354 deletions

View file

@ -58,6 +58,7 @@ function App() {
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [showReviewMode, setShowReviewMode] = useState(false);
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
const [bottomSheetSnap, setBottomSheetSnap] = useState<string | number | null>("148px");
// Decision state (like/dislike)
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
@ -487,7 +488,12 @@ function App() {
</div>
{/* Filter & Review FABs */}
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
<div
className={`fixed right-4 z-50 flex flex-col gap-2 transition-all duration-200 ${
bottomSheetSnap === 0.85 ? 'hidden' : ''
}`}
style={{ bottom: bottomSheetSnap === '148px' ? '11rem' : '7rem' }}
>
<Button
size="lg"
variant="outline"
@ -541,6 +547,7 @@ function App() {
highlightedPropertyUrl={highlightedProperty}
onActiveListingChange={handleActiveListingChange}
poiMetricSelection={poiMetricSelection}
onSnapChange={setBottomSheetSnap}
/>
)}
</>

View file

@ -1,89 +0,0 @@
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
import * as React from "react"
const data = {
navMain: [
{
title: "Property Explorer",
url: "#",
items: [
{
title: "Map View",
url: "#",
isActive: true,
},
{
title: "List View",
url: "#",
},
],
},
{
title: "Data Management",
url: "#",
items: [
{
title: "Refresh Listings",
url: "#",
},
{
title: "Active Tasks",
url: "#",
},
],
},
{
title: "Settings",
url: "#",
items: [
{
title: "Preferences",
url: "#",
},
{
title: "Account",
url: "#",
},
],
},
],
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar {...props}>
<SidebarHeader>
</SidebarHeader>
<SidebarContent>
{data.navMain.map((item) => (
<SidebarGroup key={item.title}>
<SidebarGroupLabel>{item.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{item.items.map((subItem) => (
<SidebarMenuItem key={subItem.title}>
<SidebarMenuButton asChild isActive={subItem.isActive}>
<a href={subItem.url}>{subItem.title}</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}

View file

@ -1,159 +0,0 @@
import { getUser } from '@/auth/authService';
import { getStoredPasskeyUser } from '@/auth/passkeyService';
import { fromOidcUser, type AuthUser } from '@/auth/types';
import { POLLING_INTERVALS } from '@/constants';
import { fetchTaskStatus, cancelTask } from '@/services';
import { TaskStatus, type TaskResult } from '@/types';
import React, { useEffect, useState } from 'react';
import AlertError from './AlertError';
import { Spinner } from './Spinner';
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
import { Progress } from './ui/progress';
import { Button } from './ui/button';
import { X } from 'lucide-react';
interface ActiveQueryProps {
taskID: string | null;
onTaskCancelled?: () => void;
}
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
const passkeyUser = getStoredPasskeyUser();
if (passkeyUser) {
setUser(passkeyUser);
} else {
getUser().then((oidcUser) => {
if (oidcUser) setUser(fromOidcUser(oidcUser));
});
}
}, []);
const [progressPercentage, setProgressPercentage] = useState<number>(0);
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(TaskStatus.PENDING);
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
const [fetchStatusError, setFetchStatusError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const handleCancelTask = async () => {
if (!user || !taskID || isCancelling) return;
setIsCancelling(true);
try {
const result = await cancelTask(user, taskID);
if (result.success) {
setTaskStatus(TaskStatus.REVOKED);
onTaskCancelled?.();
} else {
setFetchStatusError(result.message);
setAlertDialogIsOpen(true);
}
} catch (error) {
setFetchStatusError(error instanceof Error ? error.message : 'Failed to cancel task');
setAlertDialogIsOpen(true);
} finally {
setIsCancelling(false);
}
};
const pollTaskStatus = async (interval: NodeJS.Timeout) => {
if (!user || !taskID) {
return;
}
try {
const data = await fetchTaskStatus(user, taskID);
setLastUpdateTime(new Date());
const status = data.status as TaskStatus;
setTaskStatus(status);
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
clearInterval(interval);
setFetchStatusError('Task failed with status: ' + status);
setAlertDialogIsOpen(true);
return;
}
if (status === TaskStatus.SUCCESS) {
clearInterval(interval);
setProgressPercentage(100);
return;
}
// Only parse result for in-progress tasks
if (data.result) {
try {
const parsedResult: TaskResult = JSON.parse(data.result);
setProgressPercentage(parsedResult.progress * 100);
} catch {
// Result parsing failed, but task is still running - ignore
}
}
} catch (error) {
clearInterval(interval);
setTaskStatus(TaskStatus.FAILURE);
setAlertDialogIsOpen(true);
if (error instanceof Error) {
setFetchStatusError(error.message);
} else {
setFetchStatusError('Failed to update task status: ' + String(error));
}
}
};
useEffect(() => {
const interval = setInterval(
() => pollTaskStatus(interval),
POLLING_INTERVALS.TASK_STATUS_MS
);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskID, user]);
if (!taskID) {
return null;
}
const isInProgress = taskStatus &&
taskStatus !== TaskStatus.SUCCESS &&
taskStatus !== TaskStatus.FAILURE &&
taskStatus !== TaskStatus.REVOKED;
return (
<>
<div className="flex items-center gap-2 p-2 border-t bg-muted/50">
<HoverCard>
<HoverCardTrigger className="flex-1">
<div className="flex items-center gap-2">
{taskStatus && <span className="text-sm">Task: {taskStatus}</span>}
{isInProgress && <Spinner />}
</div>
<Progress value={progressPercentage} className="mt-1" />
</HoverCardTrigger>
<HoverCardContent>
Task ID: {taskID}
<br />
Last updated: {lastUpdateTime.toLocaleString()}
</HoverCardContent>
</HoverCard>
{isInProgress && (
<Button
variant="ghost"
size="sm"
onClick={handleCancelTask}
disabled={isCancelling}
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<X className="h-4 w-4" />
<span className="ml-1 hidden sm:inline">Cancel</span>
</Button>
)}
</div>
<AlertError message={fetchStatusError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</>
);
};
export default ActiveQuery;

View file

@ -554,7 +554,7 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount,
) : (
<>
<Filter className="mr-2 h-4 w-4" />
Apply Filters
Show Matching Listings
</>
)}
</Button>
@ -565,8 +565,9 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount,
disabled={isLoading}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Data
Scrape New from Rightmove
</Button>
<p className="text-xs text-muted-foreground text-center">Triggers a live crawl may take several minutes</p>
</div>
</div>
);

View file

@ -1,7 +1,7 @@
import * as d3 from "d3";
import mapboxgl from "mapbox-gl";
import 'mapbox-gl/dist/mapbox-gl.css';
import { useEffect, useRef, useMemo, useCallback } from "react";
import { useEffect, useRef, useMemo, useCallback, useState } from "react";
import { Crosshair } from "lucide-react";
import { createRoot } from 'react-dom/client';
import "../assets/Map.css";
@ -32,6 +32,10 @@ interface MapProps {
export function Map(props: MapProps) {
const data = props.listingData;
const [showMapHint, setShowMapHint] = useState(() => {
return localStorage.getItem('map-hint-dismissed') !== 'true';
});
const mapRef = useRef<mapboxgl.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const heatmapRef = useRef<HexgridHeatmapClient | null>(null);
@ -421,6 +425,20 @@ export function Map(props: MapProps) {
</button>
</div>
)}
{!props.isPickingPOI && showMapHint && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-10 bg-primary text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-3 text-sm font-medium">
Click on colored areas to view properties
<button
onClick={() => {
setShowMapHint(false);
localStorage.setItem('map-hint-dismissed', 'true');
}}
className="ml-1 px-2 py-0.5 bg-primary-foreground/20 hover:bg-primary-foreground/30 rounded text-xs transition-colors"
>
Got it
</button>
</div>
)}
<div id="legend">
<svg id="svg"></svg>
</div>

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Drawer } from 'vaul';
import { MapPin, PoundSterling } from 'lucide-react';
import { SwipeableCardRow } from './SwipeableCardRow';
@ -11,6 +11,7 @@ interface MobileBottomSheetProps {
highlightedPropertyUrl?: string | null;
onActiveListingChange?: (feature: PropertyFeature | null) => void;
poiMetricSelection?: { poiId: number; travelMode: string } | null;
onSnapChange?: (snap: string | number | null) => void;
}
function formatCurrency(value: number): string {
@ -24,10 +25,16 @@ export function MobileBottomSheet({
highlightedPropertyUrl,
onActiveListingChange,
poiMetricSelection,
onSnapChange,
}: MobileBottomSheetProps) {
const [snap, setSnap] = useState<string | number | null>("148px");
const [activeCardIndex, setActiveCardIndex] = useState(0);
// Notify parent when snap changes
useEffect(() => {
onSnapChange?.(snap);
}, [snap, onSnapChange]);
const features = listingData?.features ?? [];
const stats = useMemo(() => {

View file

@ -86,7 +86,7 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist
);
}
function CardCarousel({ photos }: { photos: string[] }) {
function CardCarousel({ photos, altText }: { photos: string[]; altText?: string }) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
@ -105,7 +105,7 @@ function CardCarousel({ photos }: { photos: string[] }) {
return (
<img
src={photos[0]}
alt="Property"
alt={altText || "Property"}
className="w-full h-full object-cover"
loading="lazy"
/>
@ -120,7 +120,7 @@ function CardCarousel({ photos }: { photos: string[] }) {
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
<img
src={url}
alt={`Photo ${i + 1}`}
alt={`Property photo ${i + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
@ -187,7 +187,10 @@ export function PropertyCard({
{/* Photo carousel */}
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
{(property.photos?.length || property.photo_thumbnail) ? (
<CardCarousel photos={property.photos?.length ? property.photos : [property.photo_thumbnail]} />
<CardCarousel
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
/>
) : null}
</div>
@ -248,7 +251,7 @@ export function PropertyCard({
{property.photo_thumbnail && (
<img
src={property.photo_thumbnail}
alt="Property"
alt={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
className="w-full h-full object-cover"
/>
)}

View file

@ -37,7 +37,7 @@ export function PropertyCardCompact({
{property.photo_thumbnail && (
<img
src={property.photo_thumbnail}
alt="Property"
alt={`${property.rooms}-bed, £${property.total_price.toLocaleString()}`}
className="w-full h-full object-cover"
/>
)}

View file

@ -1,61 +0,0 @@
import { useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Heart } from 'lucide-react';
import { PropertyCard } from './PropertyCard';
import type { GeoJSONFeatureCollection, PropertyFeature, DecisionType } from '@/types';
interface SavedViewProps {
listingData: GeoJSONFeatureCollection;
getDecision: (listingId: number, listingType?: string) => DecisionType | undefined;
}
function getListingId(feature: PropertyFeature): number {
const parts = feature.properties.url.split('/');
return parseInt(parts[parts.length - 1], 10);
}
export function SavedView({ listingData, getDecision }: SavedViewProps) {
const savedFeatures = useMemo(() => {
return listingData.features.filter((f) => {
const id = getListingId(f);
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
return getDecision(id, type) === 'liked';
});
}, [listingData, getDecision]);
if (savedFeatures.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8">
<Heart className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-xl font-semibold mb-2">No saved properties yet</p>
<p className="text-muted-foreground">
Use the Review mode to swipe through properties and save ones you like.
</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background">
<div className="px-3 py-2 text-sm text-muted-foreground border-b">
{savedFeatures.length} saved {savedFeatures.length === 1 ? 'property' : 'properties'}
</div>
<Virtuoso
className="flex-1"
data={savedFeatures}
overscan={200}
itemContent={(_index, feature) => (
<div className="px-3 pb-2 first:pt-3">
<PropertyCard
key={feature.properties.url}
property={feature.properties}
variant="compact"
/>
</div>
)}
/>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useRef, useState, useCallback, useEffect } from 'react';
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import useEmblaCarousel from 'embla-carousel-react';
@ -20,6 +20,11 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
const p = feature.properties;
const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : [];
const prefersReducedMotion = useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[],
);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedPhoto, setSelectedPhoto] = useState(0);
@ -41,6 +46,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
scale: 1 - stackIndex * 0.05,
opacity: stackIndex <= 2 ? 1 : 0,
config: { tension: 300, friction: 25 },
immediate: prefersReducedMotion,
}));
const bind = useDrag(
@ -118,7 +124,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
<div className="flex h-full">
{photos.map((url, i) => (
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
<img src={url} alt={`Photo ${i + 1}`} className="w-full h-full object-cover" loading="lazy" />
<img src={url} alt={`Property photo ${i + 1}`} className="w-full h-full object-cover" loading="lazy" />
</div>
))}
</div>
@ -144,7 +150,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
</div>
</>
) : photos.length === 1 ? (
<img src={photos[0]} alt="Property" className="w-full h-full object-cover" />
<img src={photos[0]} alt={`${p.rooms}-bed, ${p.qm}m², £${p.total_price.toLocaleString()}`} className="w-full h-full object-cover" />
) : null}
<button
className="absolute top-3 right-3 bg-background/80 backdrop-blur-sm rounded-full p-2 z-20"

View file

@ -46,6 +46,9 @@ export function SwipeReviewMode({
const [currentIndex, setCurrentIndex] = useState(0);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [showOnboarding, setShowOnboarding] = useState(() => {
return localStorage.getItem('swipe-review-onboarded') !== 'true';
});
const handleSwipe = useCallback(
(direction: 'left' | 'right' | 'up') => {
@ -142,41 +145,85 @@ export function SwipeReviewMode({
{/* Action buttons */}
{!isFinished && (
<div className="flex items-center justify-center gap-6 py-6 px-4">
<Button
variant="outline"
size="lg"
className="rounded-full h-14 w-14 border-red-300 text-red-500 hover:bg-red-50"
onClick={() => handleSwipe('left')}
>
<X className="h-6 w-6" />
</Button>
<div className="flex flex-col items-center gap-1">
<Button
variant="outline"
size="lg"
className="rounded-full h-14 w-14 border-red-300 text-red-500 hover:bg-red-50"
onClick={() => handleSwipe('left')}
>
<X className="h-6 w-6" />
</Button>
<span className="text-xs text-muted-foreground">Dislike</span>
</div>
<Button
variant="outline"
size="sm"
className="rounded-full h-10 w-10"
onClick={handleUndo}
disabled={history.length === 0}
>
<Undo2 className="h-4 w-4" />
</Button>
<div className="flex flex-col items-center gap-1">
<Button
variant="outline"
size="sm"
className="rounded-full h-10 w-10"
onClick={handleUndo}
disabled={history.length === 0}
>
<Undo2 className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">Undo</span>
</div>
<Button
variant="outline"
size="sm"
className="rounded-full h-10 w-10"
onClick={() => handleSwipe('up')}
>
<ArrowUp className="h-4 w-4" />
</Button>
<div className="flex flex-col items-center gap-1">
<Button
variant="outline"
size="sm"
className="rounded-full h-10 w-10"
onClick={() => handleSwipe('up')}
>
<ArrowUp className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">Skip</span>
</div>
<div className="flex flex-col items-center gap-1">
<Button
variant="outline"
size="lg"
className="rounded-full h-14 w-14 border-green-300 text-green-500 hover:bg-green-50"
onClick={() => handleSwipe('right')}
>
<Heart className="h-6 w-6" />
</Button>
<span className="text-xs text-muted-foreground">Like</span>
</div>
</div>
)}
{/* Onboarding overlay */}
{showOnboarding && (
<div className="absolute inset-0 z-50 bg-black/60 flex flex-col items-center justify-center gap-8 p-8">
<div className="flex items-center gap-12 text-white">
<div className="flex flex-col items-center gap-2">
<X className="h-10 w-10 text-red-400" />
<span className="text-sm font-medium">Swipe left</span>
<span className="text-xs text-white/70">Dislike</span>
</div>
<div className="flex flex-col items-center gap-2">
<ArrowUp className="h-10 w-10 text-white/80" />
<span className="text-sm font-medium">Swipe up</span>
<span className="text-xs text-white/70">Skip</span>
</div>
<div className="flex flex-col items-center gap-2">
<Heart className="h-10 w-10 text-green-400" />
<span className="text-sm font-medium">Swipe right</span>
<span className="text-xs text-white/70">Like</span>
</div>
</div>
<Button
variant="outline"
size="lg"
className="rounded-full h-14 w-14 border-green-300 text-green-500 hover:bg-green-50"
onClick={() => handleSwipe('right')}
variant="secondary"
onClick={() => {
setShowOnboarding(false);
localStorage.setItem('swipe-review-onboarded', 'true');
}}
>
<Heart className="h-6 w-6" />
Got it
</Button>
</div>
)}

View file

@ -153,3 +153,12 @@
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/savedview.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}