Commit graph

82 commits

Author SHA1 Message Date
Viktor Barzin
49e3514780 wrongmove: daily price-trend monitoring (per-listing badge + macro strip)
Two surfaces wired up so the user can "get a vibe of the market":

**Per-listing** — each PropertyCard now shows a small pill next to the
price when the listing's total_price moved >=1% over a 14-day lookback
(e.g. "↓ £200 (-4%) in 14d"). Drops render green, rises render red.
Computed from `price_history_json` by the daily aggregator and
denormalised onto the listing row so the streaming endpoint just
passes it through.

**Macro** — new always-visible inline strip above the chip strip
showing today's median total price, median £/m², and listing count
for the current filter's bedroom band, each with a 30-day % delta:
"Rent · 1-2 bed · 30d: Median £2,500 ↓ -4% · £/m² £50 ↓ -2% · Listings 4,200 ↑ +5%".

Both data sources are populated daily at 04:00 UTC by a new Celery
beat task that fires 1h after the 03:00 RENT scrape and feeds two
sinks: a per-listing update pass and an upsert to a new
`dailylistingaggregate` table keyed on
(snapshot_date, listing_type, min_bedrooms, max_bedrooms).

## Backend
- `models/listing.py`: Listing parent gains `price_14d_ago` + `price_
  change_pct_14d` nullable floats (inherited by RentListing/BuyListing).
  New `DailyListingAggregate` table model with unique constraint on
  (date, type, min_bed, max_bed).
- Alembic `a8b9c0d1e2f3`: adds the two columns to both listing tables
  and creates the aggregate table + date index.
- `services/market_aggregator.py` (new): `compute_trend_for_listing`,
  `update_per_listing_trend` (batched, idempotent), `_stats` (median
  + mean filtered to positive finite values), `compute_aggregate_
  snapshot` (dialect-aware MySQL / SQLite upsert), `fetch_trend_
  series` (range query for the API).
- `tasks/market_tasks.py` (new): `compute_daily_market_aggregates_task`
  Celery task wrapping both stages.
- `tasks/listing_tasks.py:setup_periodic_tasks`: registers the daily
  task at 04:00 UTC alongside the existing scrape schedules.
- `celery_app.py`: includes the new tasks module.
- `api/app.py`: new `GET /api/market_trend?listing_type=&min_bedrooms=&
  max_bedrooms=&days=` endpoint returning the daily series.
- `ui_exporter.py`: GeoJSON feature properties now carry
  `price_14d_ago` and `price_change_pct_14d` so the frontend can
  render the badge without an extra round-trip.

## Frontend
- `types/index.ts`: new `MarketTrendPoint`; `PropertyProperties` gains
  the two optional trend fields.
- `components/PropertyCard.tsx`: derived `trendBadge` (>=1% threshold,
  null-safe) rendered as a small pill on both card variants.
- `hooks/useMarketTrend.ts` (new): fetches the trend series, derives
  current-vs-oldest deltas per metric (% change rounded to 1dp).
- `components/MarketTrendStrip.tsx` (new): compact inline strip with
  three metric cells. Hidden when the aggregator hasn't produced any
  rows yet (graceful start during the first week post-launch).
- `App.tsx`: renders the strip above the chip strip whenever the
  active queryParameters are known.

## Tests
- pytest: 10 new (trend math edge cases including null history,
  malformed JSON, only-recent entries, drops, rises, zero current
  price; _stats empty / nonpositive filtering; upsert idempotency on
  an in-memory SQLite seed). 34 decision + aggregator tests pass.
- vitest: 8 new (useMarketTrend fetch URL, two-point delta,
  single-point null delta, empty series; PropertyCard trend badge
  arrow direction + sign for drops/rises, noise threshold, null
  guard). 229 tests pass total, tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:02:25 +00:00
Viktor Barzin
c2e08fe46e wrongmove: add "seen" soft-hide decision with price-aware resurfacing
Opening a listing's detail sheet for >3s now passively marks it `seen`
and snapshots its current `total_price`. Seen listings are hidden from
the main list by default but automatically resurface when the price
changes (any direction). Distinct from `disliked` — explicit
like/dislike always overrides the passive seen state.

New "Hidden (N)" toggle on the FilterBar appears whenever at least one
listing is currently being hidden by the seen filter. Toggling it
reveals those rows in-place (without unmarking them) so the user can
review or explicitly clear via the existing Like/Dislike/Clear flow.

## Backend
- Alembic f7a8b9c0d1e2: `ALTER TABLE listingdecision ADD COLUMN
  price_at_decision FLOAT NULL`.
- `models/decision.py`: ListingDecision gains nullable
  `price_at_decision: float | None`.
- `services/decision_service.py`: adds `seen` to VALID_DECISIONS;
  set_decision accepts an optional `price_at_decision`; it's only
  forwarded to the repo for decision='seen' (other types null-out the
  column to keep semantics clean).
- `repositories/decision_repository.py`: upsert_decision now carries
  price_at_decision through the MySQL + SQLite upsert paths.
- `api/decision_routes.py`: SetDecisionRequest + DecisionResponse
  expose the new field.

## Frontend
- `types/index.ts`: DecisionType = 'liked' | 'disliked' | 'seen';
  ListingDecision gains `price_at_decision?: number | null`.
- `services/decisionService.ts`: setDecision sends the price only for
  decision='seen' (and only when it's a finite number).
- `hooks/useDecisions.ts`: rewritten to store `Map<key, DecisionEntry>`
  (decision + price snapshot). New `markSeen(id, price, type)` short-
  circuits on existing liked/disliked. New `getDecisionEntry`,
  `seenCount`.
- `App.tsx`: 3s `setTimeout` dwell timer fires markSeen when the
  detail sheet stays open. Filter logic in `processedListingData`
  hides `seen` rows whose `total_price === price_at_decision`, with
  `showHidden` bypass. Computes `hiddenCount` to drive the toggle.
- `components/FilterBar.tsx`: new conditional "Hidden (N)" / "Showing
  hidden (N)" toggle button (Eye / EyeOff lucide icons), surfaces only
  when hiddenCount > 0.

## Tests
- pytest: 2 new (test_set_seen_carries_price, test_liked_drops_price_
  even_if_supplied) + 1 updated to assert the new 5-arg repo
  signature. 24 passed.
- vitest: 6 new for useDecisions (markSeen liked/disliked skip, price
  snapshot, re-mark, null price, seenCount) + 5 new for decisionService
  payload shape. 221 total passed, tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:07:44 +00:00
Viktor Barzin
9a5ad7878c wrongmove: preserve heatmap color on multi-property popup + arrow nav on card carousel
When the user clicks a heatmap hex that contains multiple properties,
each property card in the resulting popup now renders a left-edge
color stripe matching the heatmap gradient for that property's
individual value of the active metric (Price/m², Total Price, Size,
Bedrooms, …). The "color code" carries from the map into the popup
instead of dying at the hex boundary.

Plumbing:
- `colorSchemes.ts` gains `interpolateMetricColor(value, min, max, stops)`
  that walks the color-stop ramp and returns `rgb(R, G, B)`.
- `Map.tsx` stashes the latest `{min, max}` from `computeColorScale` in
  a ref so `getListingDialog` can compute per-property colors without
  re-running the worker.
- `PropertyCard` accepts an optional `metricColor` prop and applies it
  as a 4px `border-left`. Compact variant unchanged (no stripe).

Also resolves the Round-3 Fix-4 follow-up: `CardCarousel` (inside
PropertyCard.tsx) now has clickable prev/next chevron buttons in
addition to drag + keyboard navigation. Buttons fade in on hover
(group-hover) and are always focus-visible for keyboard users; clicks
stop propagation so the parent card click handler doesn't fire.

Tests: 9 new (4 covering interpolateMetricColor edge cases —
null/NaN/clamp — and 4 covering metricColor stripe + carousel
buttons present/absent). Full suite 210/210.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:10:20 +00:00
Viktor Barzin
9bb5320e2b wrongmove: write VITE_MAPBOX_TOKEN to .env.production in CI (replaces broken build_args)
The previous attempt passed the Mapbox token via `--build-arg`, but
the docker-buildx plugin's KEY=VALUE list-parser mangled the value
(the rendered command was `--build-arg *=VITE_MAPBOX_TOKEN=********`,
key got lost). Inspecting `viktorbarzin/immoweb:45` confirmed
`pk.eyJ...` was nowhere in the bundle.

Switching to the idiomatic Vite path: a new `prepare-frontend-env`
commands step writes `frontend/.env.production` from the
`wrongmove-mapbox-token` Woodpecker secret. `COPY . .` in the
Dockerfile pulls the file into the build context, and Vite
auto-loads `.env.production` during `npx vite build`.

Net diff:
- `.woodpecker/frontend.yml`: new prepare step, build step now
  depends on it, dropped the build_args line.
- `frontend/Dockerfile`: dropped the ARG/ENV lines (no longer needed,
  also silences `SecretsUsedInArgOrEnv` linter warning).
- `frontend/.gitignore`: ignore `.env.production` / `.env.local` so
  the CI-written file never gets accidentally committed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:10:25 +00:00
Viktor Barzin
25458fd2a2 wrongmove: bake VITE_MAPBOX_TOKEN into the frontend build
Adds a build-arg path so the Mapbox public token is injected at
`vite build` time instead of being hardcoded in the bundle:

- `frontend/Dockerfile` declares `ARG VITE_MAPBOX_TOKEN` in the
  builder stage and re-exports it via `ENV` so Vite picks it up.
- `.woodpecker/frontend.yml` maps the global `wrongmove-mapbox-token`
  Woodpecker secret into a step-level `VITE_MAPBOX_TOKEN` env var,
  then forwards it via `build_args_from_env`.

Token is a domain-restricted `pk.*` public token (Mapbox), so bundle
exposure is the intended threat model. Vault-stored at
`secret/ci/global/wrongmove-mapbox-token`; synced to Woodpecker by
the existing vault-woodpecker-sync CronJob every 6h.

Replaces the post-Fix-4 "Map unavailable — set VITE_MAPBOX_TOKEN"
banner with a working basemap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:42:40 +00:00
Viktor Barzin
a42944a756 wrongmove: round-3 fix sweep — scrape pipeline, BUY tab, filter URL state, render hygiene, map polish
Coordinated fix across 31 bugs found in a parallel QA pass. Findings docs at /tmp/wrongmove-bugs/qa-round-3/qa{1,2,3,4}-*.md.

## Backend / scrape (Fix-1) — 8 bugs

- B1 [P0] Scrape totally broken on prod: pod UID 100 vs NFS dir 1000:1000 mode 775 → PermissionError on every never-seen listing. Switched Dockerfile to explicit `useradd --uid 1000 --gid 1000`; added securityContext + chown initContainer to k8s/{api,celery-beat}-deployment.yaml. Celery worker manifest lives outside this repo — Dockerfile UID change is the load-bearing fix.
- B4 [P1] Celery broker reaped every ~30s by Redis HAProxy idle timeout. Added `broker_transport_options` / `result_backend_transport_options` with `socket_keepalive=True, health_check_interval=25` in celery_app.py + same kwargs on every redis.from_url/Redis call across services/, utils/redis_lock.py, redis_repository.py.
- B5 [P1] dump_listings_task never published terminal FAILURE to the task_progress pub/sub channel — UI polled forever. Wrap body in try/except that publishes FAILURE before re-raising.
- B6 [P1] _process_worker had no per-listing exception handler — one bad listing killed the whole scrape via asyncio.gather. Wrap loop body in try/except Exception (re-raises CancelledError).
- B20 [P2] dump_listings_task gained time_limit=3600, soft_time_limit=3500, acks_late=True.
- B21 [P2] RedisRepository moved off shared db0 (was alongside paperless-ngx) to db3 via REDIS_USER_DB env var; keys prefixed `wrongmove:user:`.
- B32 [P3] redis_lock now uses uuid4() owner token + Lua compare-and-delete.
- B33 [P3] Slack notify in refresh_listings → asyncio.create_task (fire-and-forget).

## Frontend filter system (Fix-2) — 7 bugs

- B2 [P0] BUY tab click triggered "Maximum update depth exceeded" → ErrorBoundary. Replaced the three mutually-triggering useEffects in FilterBar with a single one-way controlled-value flow (URL → parent state → form), guarded by previousListingTypeRef so price-defaults fires once per real transition.
- B3 [P0] Filter values never reached the URL. Wired useFilterParams.setFilterValues into FilterBar/FilterPanel onSubmit + handleRemoveChip + new handleResetAllFilters; fed parsed filterValues into both forms' defaultValues; added URL→form sync via form.reset on browser back/forward.
- B8 [P1] Chip removal now resets form state via new FilterBar onFormReady callback — More badge no longer sticks.
- B12 [P2] Desktop swipe-review FAB added next to header (mobile FAB unchanged).
- B17 [P2] "Reset all" affordance on chip strip.
- B22 [P2] formatPrice precision: 1500 → £1.5k, 2500 → £2.5k (no longer collides with £2k/£3k defaults).
- B30 [P3] last_seen_days input gained min={0}.

## Frontend render hygiene + data integrity (Fix-3) — 8 bugs

- B7 [P1] streamingService bails on first non-NDJSON chunk (HTML response = backend down) and throws StreamParseError so the existing AlertError dialog surfaces a single user-visible error instead of 18× console.error spam.
- B9 [P1] formatDuration widened to (null|undefined|number): returns "—" for non-finite or negative, caps implausibly large values.
- B10 [P1] PropertyCard / PropertyCardCompact / SwipeCard JSX leaves render "—" for null total_price/qm/qmprice (was "£0/0 m²/£0/m²" — looked like free listings).
- B13 [P2] hexgrid worker reduceAverage uses Number.isFinite filter instead of !isNaN (which incorrectly accepted null → 0, biasing per-hex averages low).
- B14 [P2] ListingDetail Overview wraps agency in "Listed by" labelled block so it can't collapse to a bare agency name.
- B15 [P2] Compact POIDistanceBadges iterates all three travel modes with "—" for missing, matching the detail-sheet Travel table.
- B24 [P3] Drawer.Description (sr-only) added to ListingDetailSheet + MobileBottomSheet to silence Radix a11y warning.
- B25 [P3] lastSeenDays clamped to ≥0 so future timestamps don't render as "-7d ago".

## Frontend map / carousel / tasks polish (Fix-4) — 8 bugs

- B11 [P2] HexgridHeatmapClient destroy race: Map.tsx adds .catch() + ref guard so post-destroy promise rejections are silent no-ops. Verified by browser smoke (24 rapid Map↔List toggles → 0 pageErrors).
- B16 [P2] PhotoCarousel + inner CardCarousel gained keyboard nav (Arrow keys).
- B18 [P2] Default map center moved from Czech Republic to London (zoom 10).
- B19+B29 [P2/P3] Mapbox token: no longer hard-coded fallback; reads env-only and shows a clear "Map unavailable — set VITE_MAPBOX_TOKEN" banner when missing.
- B23 [P3] PhotoCarousel suppresses "1/1" counter for single-photo listings; added onError fallback for broken URLs.
- B26 [P3] PhotoCarousel only enables loop when photos.length > 1.
- B27 [P3] TaskIndicator cancel/clear-all buttons gained aria-label + data-testid.
- B28 [P3] useTaskProgress strips terminal-local task IDs from the polling union — no more forever-poll on completed tasks.

## Tests

74 new vitest tests + 18 new pytest tests. Local: tsc clean, 201 vitest tests pass, 633 pytest tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:27:29 +00:00
Viktor Barzin
0b5308200e wrongmove: guard property cards against null backend fields (fix BUY crash)
BUY listings can come back from the API with null `total_price`, `qm`,
`qmprice`, `rooms`, and `last_seen` even though the TypeScript types
declare them non-nullable. The cards called `.toLocaleString()` and
`.split('T')` on those values, throwing TypeError and tripping the
ErrorBoundary — which is the "BUY part of the page crashes" symptom.

Coerce numeric fields via a `safeNum` helper (0 fallback), gate the
"Nd ago" line on a finite last_seen, and apply the same guards in
PropertyCard, PropertyCardCompact, SwipeCard, and the price-history
section of ListingDetail. Added regression tests asserting both card
variants render with all-null backend fields without throwing.
2026-05-10 21:17:41 +00:00
Viktor Barzin
fad834c20b ui: harden a11y, plurals, NaN guards, map viewport persistence
Surgical UX/a11y fixes layered on top of the team's UI redesign,
keeping the teal palette and FilterBar architecture intact.

- TaskIndicator: NaN-safe progressPercentage (Number.isFinite +
  clamp 0..100). No more "NaN%" when a task posts undefined progress.
- StreamingProgressBar: same NaN guard on the inline width calc.
- StatsBar: pluralize listings ("1 listing" / "30 listings"), drop
  the duplicated "Avg:" label (now "Avg price" / "<n>/m²" / "Size"),
  tabular-nums on every numeric.
- FilterPanel furnishing pills: aria-pressed + data-state for
  screen readers and tests.
- ListView sort buttons: aria-pressed + aria-sort
  (ascending/descending/none); listing count pluralizes
  ("1 property" / "N properties") with tabular-nums.
- Map: only fitBounds the FIRST time data loads (hasFittedOnceRef).
  The previous lastDataLengthRef-based gate refit when results
  went N → 0 → M, blowing away the user's pan/zoom.

Verified: tsc --noEmit clean, 125/125 vitest specs pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:52:49 +00:00
Viktor Barzin
3016cbb047
fix: use real Rightmove listings for dev mock data
Replace picsum.photos placeholders with actual Rightmove listing data
fetched from the API - real property photos, prices, addresses, and
coordinates. Skip auto-load API call in dev bypass mode.
2026-02-28 16:47:52 +00:00
Viktor Barzin
69ce458308
feat: add dev auth bypass for UI testing without backend
Guarded by VITE_DEV_BYPASS_AUTH env var + import.meta.env.DEV check.
Vite tree-shakes the DEV branch in production builds.
The .env.development.local file is gitignored (**.env pattern).
Includes mock listing data to preview property cards without API.
2026-02-28 16:37:33 +00:00
Viktor Barzin
dea930dbc4
fix: remove unused imports flagged by TypeScript 2026-02-28 16:23:36 +00:00
Viktor Barzin
ab02fb120c
feat: redesign listing detail with tabbed sections and larger drawer 2026-02-28 16:21:20 +00:00
Viktor Barzin
812bfece4a
style: redesign PropertyCard with better visual hierarchy 2026-02-28 16:21:17 +00:00
Viktor Barzin
be2f0ef282
feat: add error boundary to prevent white-screen crashes
Wraps the entire app in an ErrorBoundary that shows a friendly
error message with reload button instead of crashing to white.
2026-02-28 16:18:10 +00:00
Viktor Barzin
8f112f30e3
feat: integrate FilterBar into layout, remove sidebar
Replace the fixed w-80 sidebar with a horizontal FilterBar below the
header, giving the map full viewport width. Key changes:

- App.tsx: Remove sidebar layout, add FilterBar + FilterChips + inline
  StreamingProgressBar between header and main content area
- Header.tsx: Add Rent/Buy listing type toggle (compact Tabs) after logo
- StatsBar.tsx: Add "Color by" metric selector (moved from
  VisualizationCard) as a compact Select alongside view mode toggles
- Mobile: Replace Sheet-based filter panel with full-screen Dialog
2026-02-28 16:16:03 +00:00
Viktor Barzin
4053c0c759
feat: create FilterBar and FilterChips components
Add a horizontal FilterBar component with popover-based dropdowns for
Price, Beds, Size, and a "More Filters" panel with advanced options
(price/m2, furnishing, district, date, POI travel filters). Action
buttons (Show Listings / Scrape New) are aligned to the right.

Add FilterChips component that renders active (non-default) filter
values as removable pills below the filter bar.
2026-02-28 16:12:09 +00:00
Viktor Barzin
de47e2cca8
feat: add React Router with URL-based filter state and deep linking
- Wrap App in BrowserRouter in main.tsx
- Create useFilterParams hook that syncs filter state with URL search params
  and derives viewMode from the URL pathname
- Replace window.location.pathname callback check with React Router Routes
- Split App into AppContent (main UI) and App (route definitions)
2026-02-28 16:07:14 +00:00
Viktor Barzin
676fad520c
style: update color palette from neutral to teal-accented property theme
- Primary/accent changed from achromatic black to teal (oklch 0.55 0.14 175)
- Background/foreground given subtle cool slate tint
- Added --deal-good (emerald) and --deal-above (amber) custom properties
- Ring color updated to teal for focus states
- Dark mode updated to match teal theme
2026-02-28 16:03:56 +00:00
Viktor Barzin
1037ff164d
refactor: extract shared utility functions to eliminate duplication 2026-02-28 16:02:06 +00:00
Viktor Barzin
b720013a08
chore: add react-router-dom dependency for URL-based navigation 2026-02-28 15:58:39 +00:00
Viktor Barzin
35f1987ac1
Add navigation & usage metrics for end-user experience visibility
Instrument DB query timing (11 operations across 3 repositories),
streaming lifecycle (TTFB, duration, feature count), cache operation
latency, listing detail step breakdown, and frontend page load /
time-to-first-listing / stream download / detail load metrics.

Adds 16 new OTel instruments, extends the perf ingestion endpoint
with 4 new frontend metrics, and adds ~20 Grafana dashboard panels
across 4 new rows (DB Query Performance, Streaming Performance,
Listing Detail Breakdown, Cache Performance, Frontend Navigation).
2026-02-23 20:28:42 +00:00
Viktor Barzin
1ae00b7cbf
Add multi-layer caching: 24h Redis TTL, stale-while-revalidate, frontend LRU cache
- Increase Redis cache TTL from 30 minutes to 24 hours
- Add stale-while-revalidate: serve stale cache (>4h) immediately while
  repopulating in background with SETNX lock to prevent concurrent rebuilds
- Add in-memory frontend LRU cache (5 entries) so repeat filter visits
  are instant without network requests
- Invalidate frontend cache on listing refresh and task completion
- Add unit tests for get_cache_age, is_cache_stale, acquire_repopulation_lock
2026-02-23 20:09:36 +00:00
Viktor Barzin
04bda8c127
Fix FilterPanel test: button text changed to 'Show Matching Listings'
The FilterPanel component's submit button was renamed from 'Apply Filters'
to 'Show Matching Listings' but the test was not updated to match.
2026-02-23 19:40:06 +00:00
Viktor Barzin
339e2cf2ab
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
2026-02-22 18:47:09 +00:00
Viktor Barzin
d90fa38776
Add frontend performance metrics pipeline to Prometheus
Collect browser-side worker round-trips, computation times, main-thread
operations, and feature counts, batch them client-side, and expose as
Prometheus histograms via a new POST /api/perf endpoint.
2026-02-22 17:30:29 +00:00
Viktor Barzin
81ff9d9e41
Move hexgrid heatmap computation to Web Worker
R-tree building, hex grid generation, and percentile sorting now run
off the main thread, eliminating 20s+ UI freezes on large datasets.
The old bundled HexgridHeatmap.js is replaced by a typed worker +
main-thread client with dual R-trees (worker for grid gen, main
thread for synchronous click queries).
2026-02-22 15:04:37 +00:00
Viktor Barzin
8ef6868881
Eliminate frontend POI waterfall for faster initial load
Listing stream fires immediately on auth without waiting for POI
fetch. POI distances are not needed for initial rendering and are
only computed when user selects POI metric or sets travel filters.
This saves ~200-500ms on initial load and keeps the stream on the
cached Redis path.
2026-02-22 13:31:10 +00:00
Viktor Barzin
3885fd52fe
Add bulk POI distances endpoint for decoupled loading
New GET /api/poi/distances/bulk returns all POI distances keyed by
listing ID, allowing the frontend to fetch distances separately
from the listing stream and keep the stream on the cached path.
2026-02-22 13:29:35 +00:00
Viktor Barzin
9dc011754b
Add cancel button to streaming progress bar
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.
2026-02-22 02:04:56 +00:00
Viktor Barzin
301579c255
Separate photo swipe from card swipe gestures
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.
2026-02-22 02:02:10 +00:00
Viktor Barzin
e2c22f025f
Expand swipe card to 50/50 photo/details split with all info
- 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)
2026-02-22 00:49:32 +00:00
Viktor Barzin
611449d328
Add photo carousel to swipe cards
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.
2026-02-21 23:57:26 +00:00
Viktor Barzin
eacdf24621
Add tap-to-detail on swipe cards and fix color overlay alignment
- 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
2026-02-21 21:13:32 +00:00
Viktor Barzin
9c954c0e43
Add prev/next arrow buttons to photo carousel 2026-02-21 21:07:17 +00:00
Viktor Barzin
15dbcfa332
Parallelize frontend tests in Drone pipeline with 4 shards
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.
2026-02-21 21:02:22 +00:00
Viktor Barzin
a36c0dfdf7
Enable BuildKit in Drone API pipeline 2026-02-21 20:51:30 +00:00
Viktor Barzin
aea4b3c008
Expand frontend .dockerignore to exclude build artifacts 2026-02-21 19:51:04 +00:00
Viktor Barzin
231552c5e0
Remove unused frontend deps, move @types to devDependencies 2026-02-21 19:50:54 +00:00
Viktor Barzin
c762d5a0a6
Optimize frontend Dockerfile: npm cache mount, non-root nginx, healthcheck 2026-02-21 19:49:06 +00:00
Viktor Barzin
4deed9911c
Add photo carousel to listing cards and fix tap-to-detail
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.
2026-02-21 19:19:32 +00:00
Viktor Barzin
a153f64af4
Format available_from dates as human-readable on listing cards
Display dates like "22 Jun 2025" instead of raw ISO timestamps.
2026-02-21 18:02:14 +00:00
Viktor Barzin
a2745c1478
Add tappable cards, detail bottom sheet, swipe gestures, and favorites view
- Decision types, services (decisionService, listingDetailService), and index exports
- useDecisions hook with optimistic updates and Map-based state
- useListingDetail hook with session-level caching
- PhotoCarousel component using embla-carousel-react
- ListingDetail component with full property info, like/dislike buttons
- ListingDetailSheet using vaul Drawer (slide-up bottom sheet)
- SwipeablePropertyCard with @use-gesture/react and @react-spring/web
- SwipeReviewMode for mobile full-screen swipe review
- FavoritesView with virtualized liked listings and remove button
- App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers
- ListView conditionally renders SwipeablePropertyCard when handlers provided
- StatsBar adds 'saved' view mode with heart icon
- Header adds liked count indicator
- New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web
2026-02-21 15:49:15 +00:00
Viktor Barzin
b1be4d4170
perf: optimize CI pipeline — eliminate double dependency installs, use local registry cache
- 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
2026-02-21 15:10:55 +00:00
Viktor Barzin
49280d9679
fix: QA fixes for property decisions feature
- 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
2026-02-21 14:04:34 +00:00
Viktor Barzin
43084ef19a
feat: add SavedView, integrate decisions into App with review mode and client-side filtering 2026-02-21 13:55:05 +00:00
Viktor Barzin
d350b806ba
feat: add SwipeCard and SwipeReviewMode components with gesture support 2026-02-21 13:53:06 +00:00
Viktor Barzin
2fdeebbae1
feat: add useDecisions hook with optimistic updates 2026-02-21 13:51:50 +00:00
Viktor Barzin
813f048e46
feat: add frontend decision service and types 2026-02-21 13:51:12 +00:00
Viktor Barzin
e7eb8523cb
feat: add react-spring and use-gesture dependencies 2026-02-21 13:50:38 +00:00
Viktor Barzin
13637ec881
fix: update tests for mobile responsive changes
- Add matchMedia polyfill to test setup (jsdom doesn't implement it)
- Remove Header tests for filter toggle (replaced by mobile FAB)
2026-02-21 11:45:50 +00:00