The X button aborts the in-flight fetch via AbortController,
which was already wired up but had no UI trigger. Works for
both desktop and mobile views.
Move drag handler from outer card to details section only.
Swiping on photos now navigates the carousel, swiping on
the details area below triggers like/dislike/skip actions.
- Card now fills available height with photo carousel in top half
and property details in bottom half
- Details section shows: price, beds/sqm/price-per-sqm, agency,
available date, all POI distances, and price history summary
- Fix DialogTitle accessibility warning in ListingDetailSheet and
MobileBottomSheet (add sr-only Drawer.Title)
SwipeCard now shows all available photos (up to 5) using
embla-carousel instead of just the thumbnail. Includes prev/next
arrow buttons and dot indicators. The photo area uses touch-action:
pan-x so carousel swipes don't trigger card swipes.
- Add onTap callback to SwipeCard using useDrag's tap detection
- Wire through SwipeReviewMode to open ListingDetailSheet on tap
- Fix color overlay misalignment: add relative to card container so
the absolute overlay positions within the rounded card, not the
full-width outer wrapper
Split the monolithic "build and test" kaniko step into a DAG:
tests run in 4 parallel shards (vitest --shard) alongside the
Docker image build, gated by a shared npm ci step. The kaniko
build now targets the named 'production' stage to skip the
in-Dockerfile test stage.
Backend: include first 5 photo URLs from additional_info in GeoJSON
streaming response, with fallback to photo_thumbnail.
Frontend: replace single thumbnail with swipeable embla-carousel on
compact cards. Remove window.open on card tap so clicking opens the
detail bottom sheet instead of navigating to Rightmove.
- Frontend Dockerfile: split into deps/test/builder/nginx stages so npm ci
runs once (cached when package-lock.json unchanged), tests run in build
- Backend Dockerfile: add test stage that runs pytest inside the build,
eliminating separate test image build
- .drone.yml: remove separate test steps (now inside Dockerfile builds),
point cache_from/cache_repo at local registry (10.0.20.10:5000) instead
of Docker Hub for faster layer cache pulls
- Replace deprecated datetime.utcnow() with datetime.now(UTC) in model
and repository
- Add listing_type validation to decision_service (RENT/BUY only)
- Fix decision filtering tests failing due to rate limiting by patching
_match_endpoint
- Add SwipeCard component test suite (11 tests covering rendering,
interactions, and POI distances)
- Add test for invalid listing_type validation
Add mobile-responsive design with full feature parity:
- Bottom sheet (vaul) with 3 snap points for map+list coexistence
- Swipeable property cards with horizontal scroll-snap
- Hamburger menu with health, tasks, user info
- Full-screen map with repositioned legend (top-left on mobile)
- Filter FAB opening Sheet drawer
- TaskProgressDrawer from bottom on mobile
- All changes gated behind useIsMobile() hook (768px breakpoint)
- Desktop layout completely untouched
New components: MobileBottomSheet, SwipeableCardRow,
PropertyCardCompact, MobileMenu
Also fixes: idempotent longitude migration, React hooks order
- Set memory limit to 2048MiB for the "Run frontend tests" step
(node:24-alpine was OOM killed at the default 1Gi)
- Set memory limit to 2048MiB for the "Build frontend image" kaniko
step (also OOM killed at 1Gi)
- Increase vitest testTimeout to 30s (CI runner is ~75x slower than
local dev; tests were timing out at the default 5s)
Add last_updated timestamp to /api/status endpoint by querying
MAX(last_seen) across both listing tables. Display it in the
HealthIndicator as relative time (e.g. "2h ago") with full
date/time in the tooltip on hover.
When a session token expires, API calls return 401 but nothing caught
it — errors were shown as generic dialogs or swallowed. Now both
apiClient and streamingService detect 401 responses and clear auth
state, which causes App.tsx to render the login modal automatically.
- WebSocket: verify task ownership before allowing subscribe (security)
- POI routes: replace assert with HTTPException for production safety
- cancel_task: return HTTP 404 instead of 200 for missing tasks
- routing_config: add descriptive ValueError for invalid env vars
- POIManager: show error feedback instead of silently swallowing failures
- VisualizationCard: reset POI/travel mode state on metric switch
- Map: clean up heatmap layers/sources on unmount to prevent memory leak
- Update test to expect 404 from cancel_task ownership check
npm ci can also OOM during dependency installation. Move the heap
limit before npm ci so it applies to all Node processes. Bump Drone
pod limits to 4Gi (requests 2Gi) to cover Docker-in-Docker overhead.
- Replace `npm run build` (tsc -b && vite build) with `npx vite build`
in Dockerfile since Vite transpiles via SWC independently of tsc.
Type-checking is already done in the test step.
- Set Node heap to 1024MB (was 384MB which OOMed even for Vite)
- Bump Drone pod memory: requests 1.5Gi, limits 3Gi to cover
plugins/docker overhead
- Set NODE_OPTIONS=--max-old-space-size=512 in Dockerfile to cap tsc
heap usage within constrained CI pods
- Add resource requests (1Gi) and limits (2Gi) to frontend Docker
build steps in Drone pipeline
Test files use Vitest globals (vi, describe, it, expect) which aren't
available to tsc during production builds. Exclude __tests__ dirs and
*.test.* / *.spec.* files from tsconfig.app.json so tsc -b succeeds.
Replace WebSocket-only useTaskWebSocket with useTaskProgress that
provides a unified task state interface. TaskIndicator no longer
manages its own polling or auth — it receives task state from the
parent via props. Rename wsTasks prop to tasks throughout.
With 8+ active tasks, polling every 5s generates ~96 task_status
requests/min, exceeding the 60/60s rate limit. Two fixes:
- Adaptive polling: 30s when WebSocket is connected (safety net),
5s only when WebSocket is down (primary source)
- Raise task_status rate limit to 200/60s and tasks_for_user to
60/60s to handle burst scenarios (page reloads, WS reconnects)
Three interconnected bugs prevented progress updates from reaching the frontend:
1. _forward_pubsub could exit silently while _handle_client_messages kept
the WebSocket alive (responding to pings), so the client never detected
the broken forwarding path. Replace asyncio.gather with asyncio.wait
(FIRST_COMPLETED) so both coroutines are cancelled together.
2. Polling was stopped on WS connect with no fallback if forwarding broke.
Now polling runs always alongside WebSocket as a safety net.
3. Redis publish failures in task_progress_publisher were logged at DEBUG
and the broken client was reused forever. Log at WARNING and reset the
client so the next call reconnects.
Polling was disabled when wsConnected was true, but if the WS connected
while workers hadn't been redeployed (no pub/sub messages flowing), the
UI received no updates at all. Polling now always runs at 5s as the
baseline. WebSocket provides faster real-time updates on top when
available — the two coexist, last writer wins.
Replace 5s HTTP polling with WebSocket-based real-time updates for task
progress. Celery workers publish progress to Redis pub/sub channels;
a FastAPI WebSocket endpoint subscribes and forwards to the browser.
Polling is kept as a 30s fallback when WebSocket is unavailable.
The task progress drawer now supports multiple concurrent jobs with a
tab bar for switching between scrape and POI distance tasks.
Backend:
- Add services/task_progress_publisher.py (Redis pub/sub bridge)
- Add api/ws_routes.py (WebSocket endpoint with JWT auth)
- Publish progress from listing_tasks and poi_tasks
- Publish REVOKED via pub/sub on cancel/clear to fix stuck UI
Frontend:
- Add useTaskWebSocket hook with reconnection and keepalive
- Add TaskState and WS message types
- TaskIndicator: WS-driven updates with polling fallback
- TaskProgressDrawer: multi-job tabs, POI phase timeline
- Guard against WS overwriting local cancel state
Three-pronged fix for duplicate listings appearing in the UI:
1. Backend: Replace direct rpush cache writes with staged population
(write to temp key, then atomic RENAME to live key). Skip cache
writes entirely for POI-enriched requests. Clean staging keys on
invalidation.
2. Frontend: Add AbortController to cancel in-flight streaming requests
when loadListings is called again, preventing data mixing.
3. Frontend: Deduplicate features by URL during stream accumulation as
a safety net against any remaining server-side duplicates.
index.html is served with Cache-Control: no-cache so the browser always
fetches the latest version with updated asset hashes. Hashed assets under
/assets/ are cached indefinitely since their filenames change on rebuild.
This prevents browsers from serving old cached JS bundles (including the
broken obfuscated build) after a new deployment.
vite-plugin-obfuscator processes ALL output chunks including vendor
libraries, corrupting Mapbox GL's WebGL shader string literals via
base64 encoding and string splitting. This caused the map to render
as a blank screen in production.
Vite's built-in esbuild minification already mangles identifiers and
removes whitespace, providing sufficient code protection.
Adds regression tests to prevent re-introducing obfuscation plugins.
- Disable source maps in production builds (vite.config.ts: sourcemap: false)
- Add vite-plugin-obfuscator for JS obfuscation (hex identifiers, base64 string encoding)
- Move OIDC config behind VITE_* env vars with dev fallbacks (auth/config.ts)
- Add server_tokens off to nginx.conf to stop advertising nginx version
- Add type declaration for vite-plugin-obfuscator
- POI popup: use DOM API with textContent (auto-escapes) instead of template literal in setHTML
- Listing popup: replace renderToString + setHTML with createRoot + setDOMContent for proper React lifecycle
Simplify the filter UI to show only essential filters (type toggle, price/bedroom
range sliders, min size) by default, with advanced filters collapsed. Extract
visualization controls (color-by metric, POI travel mode) into a separate
VisualizationCard component. Wire up previously ignored backend filters: max_sqm,
min/max_price_per_sqm, and district_names now work end-to-end.
Math.round(values.length * 0.95) produces an out-of-bounds index when
the dataset has fewer than ~20 features (e.g. after tight travel time
filtering). values[outOfBounds] returns undefined, cascading to NaN
color stops which crash Mapbox's expression evaluator. Clamp both
min and max indices to values.length - 1.
Replace the single global max travel time filter with per-POI filters.
Each POI gets its own travel mode selector and max minutes input in the
filter panel. Listings must satisfy ALL active filters (AND logic).
Fix Mapbox "Input is not a number" error by ensuring color stops are
always strictly monotonic (guard min === max) and always set (even when
no valid metric values exist). Also filter Infinity values from the
color scale computation. Widen the filter panel from w-64 to w-80.
Thread onTaskCompleted callback from TaskIndicator through Header to App.tsx
so listings auto-refresh when a background task (e.g. POI distance calculation)
completes. Add AllPOIDistances component to PropertyCard that shows all user
POIs with travel times or — placeholder for missing modes.