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>
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
// Fetches the daily market aggregate series for a given listing-type +
|
|
// bedroom band. Re-fetches when the inputs change. Returns the raw array
|
|
// of points plus a derived "now vs N days ago" delta the strip renders.
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import type { AuthUser } from '@/auth/types';
|
|
import type { MarketTrendPoint } from '@/types';
|
|
import { apiRequest } from '@/services/apiClient';
|
|
|
|
export interface MarketTrendDelta {
|
|
metric: 'median_total_price' | 'median_qmprice' | 'listing_count';
|
|
current: number | null;
|
|
previous: number | null;
|
|
changePct: number | null;
|
|
}
|
|
|
|
export interface UseMarketTrendResult {
|
|
series: MarketTrendPoint[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
// Convenience: today's value vs the oldest in-window value.
|
|
deltas: Record<MarketTrendDelta['metric'], MarketTrendDelta>;
|
|
}
|
|
|
|
function buildDelta(
|
|
metric: MarketTrendDelta['metric'],
|
|
series: MarketTrendPoint[],
|
|
): MarketTrendDelta {
|
|
if (series.length < 2) {
|
|
const only = series[0];
|
|
return {
|
|
metric,
|
|
current: only ? (only[metric] as number | null) : null,
|
|
previous: null,
|
|
changePct: null,
|
|
};
|
|
}
|
|
const current = series[series.length - 1][metric] as number | null;
|
|
const previous = series[0][metric] as number | null;
|
|
if (current === null || previous === null || previous === 0) {
|
|
return { metric, current, previous, changePct: null };
|
|
}
|
|
const changePct = Math.round(((current - previous) / previous) * 1000) / 10;
|
|
return { metric, current, previous, changePct };
|
|
}
|
|
|
|
export function useMarketTrend(
|
|
user: AuthUser | null,
|
|
listingType: 'RENT' | 'BUY',
|
|
minBedrooms: number,
|
|
maxBedrooms: number,
|
|
days: number = 30,
|
|
): UseMarketTrendResult {
|
|
const [series, setSeries] = useState<MarketTrendPoint[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
let cancelled = false;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const params = new URLSearchParams({
|
|
listing_type: listingType,
|
|
min_bedrooms: String(minBedrooms),
|
|
max_bedrooms: String(maxBedrooms),
|
|
days: String(days),
|
|
});
|
|
apiRequest<MarketTrendPoint[]>(user, `/api/market_trend?${params}`)
|
|
.then((data) => {
|
|
if (cancelled) return;
|
|
setSeries(data);
|
|
})
|
|
.catch((err: Error) => {
|
|
if (cancelled) return;
|
|
setError(err.message);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setIsLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [user, listingType, minBedrooms, maxBedrooms, days]);
|
|
|
|
const deltas: UseMarketTrendResult['deltas'] = {
|
|
median_total_price: buildDelta('median_total_price', series),
|
|
median_qmprice: buildDelta('median_qmprice', series),
|
|
listing_count: buildDelta('listing_count', series),
|
|
};
|
|
|
|
return { series, isLoading, error, deltas };
|
|
}
|