houses: Leefbaarometer liveability per listing (PDOK geocode + LBM 2024 grid/buurt), min-score filter and sort; store every regional listing (no price caps at scrape time)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
This commit is contained in:
@@ -20,6 +20,7 @@ import { sendAlert } from '#lib/server/house-tracker.js';
|
||||
import { normalizeCity } from '#lib/house-tracker/alerts.js';
|
||||
import { listingStatuses, propertyTypes } from '#lib/house-tracker/types.js';
|
||||
import { dedupe } from '#lib/house-tracker/ingest.js';
|
||||
import { scoreListings } from '#lib/house-tracker/liveability.js';
|
||||
import {
|
||||
inferOffer,
|
||||
inferPropertyType,
|
||||
@@ -59,7 +60,9 @@ export const load: PageServerLoad = async (event) => {
|
||||
const showRemoved = params.get('removed') === '1';
|
||||
const status = params.get('status') ?? '';
|
||||
const q = (params.get('q') ?? '').trim().slice(0, 80);
|
||||
const sort = ['new', 'price', 'ppm2', 'area'].includes(params.get('sort') ?? '')
|
||||
// Leefbaarometer class 1-9; listings without a score are hidden while this is set.
|
||||
const minLbm = int(params.get('lbm'));
|
||||
const sort = ['new', 'price', 'ppm2', 'area', 'lbm'].includes(params.get('sort') ?? '')
|
||||
? (params.get('sort') as string)
|
||||
: 'new';
|
||||
|
||||
@@ -77,6 +80,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
if (source) where.push(eq(houseListings.source, source));
|
||||
if (min != null) where.push(gte(houseListings.priceEur, min));
|
||||
if (max != null) where.push(lte(houseListings.priceEur, max));
|
||||
if (minLbm != null && minLbm > 0) where.push(gte(houseListings.lbmClass, minLbm));
|
||||
if (!showRemoved) where.push(isNull(houseListings.removedAt));
|
||||
if (status === 'any') where.push(sql`${houseListings.status} IS NOT NULL`);
|
||||
else if ((listingStatuses as readonly string[]).includes(status))
|
||||
@@ -97,7 +101,12 @@ export const load: PageServerLoad = async (event) => {
|
||||
new: [desc(houseListings.firstSeenAt)],
|
||||
price: [asc(houseListings.priceEur), desc(houseListings.firstSeenAt)],
|
||||
ppm2: [sql`${ppm2} ASC NULLS LAST`, desc(houseListings.firstSeenAt)],
|
||||
area: [sql`${houseListings.areaM2} DESC NULLS LAST`, desc(houseListings.firstSeenAt)]
|
||||
area: [sql`${houseListings.areaM2} DESC NULLS LAST`, desc(houseListings.firstSeenAt)],
|
||||
lbm: [
|
||||
sql`${houseListings.lbmClass} DESC NULLS LAST`,
|
||||
sql`${houseListings.lbmScore} DESC NULLS LAST`,
|
||||
desc(houseListings.firstSeenAt)
|
||||
]
|
||||
}[sort]!;
|
||||
|
||||
const [listings, cities, stats, rules, runs, health] = await Promise.all([
|
||||
@@ -131,7 +140,14 @@ export const load: PageServerLoad = async (event) => {
|
||||
removedAt: houseListings.removedAt,
|
||||
alertedAt: houseListings.alertedAt,
|
||||
status: houseListings.status,
|
||||
notes: houseListings.notes
|
||||
notes: houseListings.notes,
|
||||
lbmClass: houseListings.lbmClass,
|
||||
lbmOnvClass: houseListings.lbmOnvClass,
|
||||
lbmLevel: houseListings.lbmLevel,
|
||||
lbmPrecision: houseListings.lbmPrecision,
|
||||
lbmBuurtName: houseListings.lbmBuurtName,
|
||||
lbmYear: houseListings.lbmYear,
|
||||
lbmDetail: houseListings.lbmDetail
|
||||
})
|
||||
.from(houseListings)
|
||||
.where(and(...where))
|
||||
@@ -194,7 +210,20 @@ export const load: PageServerLoad = async (event) => {
|
||||
}
|
||||
|
||||
return {
|
||||
filters: { offer, city, min, max, minArea, minRooms, source, showRemoved, status, q, sort },
|
||||
filters: {
|
||||
offer,
|
||||
city,
|
||||
min,
|
||||
max,
|
||||
minArea,
|
||||
minRooms,
|
||||
minLbm,
|
||||
source,
|
||||
showRemoved,
|
||||
status,
|
||||
q,
|
||||
sort
|
||||
},
|
||||
defaults: DEFAULTS,
|
||||
listings: listings.map((row) => ({ ...row, copies: copiesOf.get(row.id) ?? [] })),
|
||||
sources: sources as unknown as { source: string; active: number; last_seen: string | Date }[],
|
||||
@@ -283,6 +312,7 @@ export const actions: Actions = {
|
||||
const client = db.$client;
|
||||
const [existing] = await client`SELECT id FROM house.listings
|
||||
WHERE source = ${key.source} AND source_id = ${key.sourceId}`;
|
||||
let listingId: number = existing?.id;
|
||||
if (existing) {
|
||||
await client`UPDATE house.listings SET ${client(columns)}, removed_at = NULL WHERE id = ${existing.id}`;
|
||||
} else {
|
||||
@@ -297,10 +327,13 @@ export const actions: Actions = {
|
||||
detail_fetched_at: new Date(),
|
||||
...columns
|
||||
})} RETURNING id`;
|
||||
listingId = row.id;
|
||||
if (listing.priceEur != null)
|
||||
await client`INSERT INTO house.price_history (listing_id, price_eur) VALUES (${row.id}, ${listing.priceEur})`;
|
||||
}
|
||||
await dedupe(client);
|
||||
// Leefbaarometer score right away; on a network hiccup the nightly run fills it in.
|
||||
await scoreListings(client, { ids: [listingId] }).catch(() => undefined);
|
||||
return { message: existing ? 'Updated the existing listing' : 'Added' };
|
||||
},
|
||||
saveRule: async (event) => {
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
import * as Card from '#lib/components/ui/card/index.js';
|
||||
import ListingMap from '#lib/house-tracker/ListingMap.svelte';
|
||||
import { relativeTime } from '#lib/relative-time.js';
|
||||
import {
|
||||
LBM_DIMENSIONS,
|
||||
lbmClassLabel,
|
||||
lbmDimensionLabel,
|
||||
lbmMapUrl,
|
||||
type LbmDimension,
|
||||
type Liveability
|
||||
} from '#lib/house-tracker/liveability.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
@@ -60,6 +68,42 @@
|
||||
? 'healthy'
|
||||
: 'quiet'
|
||||
);
|
||||
// Leefbaarometer class 1-9 -> badge colour (red .. green, like their map).
|
||||
function lbmColour(k: number) {
|
||||
if (k <= 3) return 'bg-red-600 text-white';
|
||||
if (k === 4) return 'bg-orange-500 text-white';
|
||||
if (k === 5) return 'bg-yellow-400 text-black';
|
||||
if (k === 6) return 'bg-lime-400 text-black';
|
||||
if (k === 7) return 'bg-green-500 text-white';
|
||||
return 'bg-green-700 text-white';
|
||||
}
|
||||
function lbmTooltip(l: {
|
||||
lbmClass: number | null;
|
||||
lbmLevel: string | null;
|
||||
lbmPrecision: string | null;
|
||||
lbmBuurtName: string | null;
|
||||
lbmYear: number | null;
|
||||
lbmDetail: Liveability | null;
|
||||
}) {
|
||||
const main = l.lbmDetail ? (l.lbmDetail.grid ?? l.lbmDetail.buurt) : null;
|
||||
const lines = [
|
||||
`Leefbaarometer ${l.lbmYear}: ${l.lbmClass}/9 ${lbmClassLabel(l.lbmClass)}`,
|
||||
l.lbmLevel === 'grid'
|
||||
? `100 m grid cell (buurt ${l.lbmBuurtName}${l.lbmDetail?.buurt ? `: ${l.lbmDetail.buurt.kscore}/9 ${lbmClassLabel(l.lbmDetail.buurt.kscore)}` : ''})`
|
||||
: `buurt average: ${l.lbmBuurtName}`,
|
||||
l.lbmPrecision === 'address'
|
||||
? `location: exact address`
|
||||
: `location: approximate (${l.lbmPrecision} level)`,
|
||||
'',
|
||||
...(main
|
||||
? (Object.keys(LBM_DIMENSIONS) as LbmDimension[]).map(
|
||||
(d) =>
|
||||
`${LBM_DIMENSIONS[d]}: ${main.classes[d] ?? '?'}/9 ${lbmDimensionLabel(main.classes[d]) ?? ''}`
|
||||
)
|
||||
: [])
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
const mappable = $derived(data.listings.filter((row) => row.lat != null).length);
|
||||
</script>
|
||||
|
||||
@@ -129,6 +173,12 @@
|
||||
placeholder="min rooms"
|
||||
value={data.filters.minRooms ?? ''}
|
||||
/>
|
||||
<NativeSelect name="lbm" value={String(data.filters.minLbm ?? '')} aria-label="Min liveability">
|
||||
<option value="">any liveability</option>
|
||||
{#each [5, 6, 7, 8] as k (k)}
|
||||
<option value={String(k)}>≥ {k} {lbmClassLabel(k)}</option>
|
||||
{/each}
|
||||
</NativeSelect>
|
||||
<NativeSelect name="source" value={data.filters.source} aria-label="Source">
|
||||
<option value="">all sources</option>
|
||||
{#each data.sources as row (row.source)}
|
||||
@@ -141,6 +191,7 @@
|
||||
<option value="price">cheapest</option>
|
||||
<option value="ppm2">cheapest per m²</option>
|
||||
<option value="area">biggest</option>
|
||||
<option value="lbm">best neighbourhood</option>
|
||||
</NativeSelect>
|
||||
<NativeSelect name="status" value={data.filters.status} aria-label="My status">
|
||||
<option value="">any status</option>
|
||||
@@ -224,6 +275,32 @@
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</div>
|
||||
{#if listing.lbmClass != null}
|
||||
{@const detail = listing.lbmDetail}
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex cursor-help items-center gap-1 rounded px-1.5 py-0.5 font-medium {lbmColour(
|
||||
listing.lbmClass
|
||||
)}"
|
||||
title={lbmTooltip(listing)}
|
||||
>
|
||||
{listing.lbmClass}/9 {lbmClassLabel(listing.lbmClass)}
|
||||
</span>
|
||||
<span class="text-muted-foreground" title={lbmTooltip(listing)}>
|
||||
{listing.lbmBuurtName ?? ''}{listing.lbmOnvClass != null
|
||||
? ` · overlast ${listing.lbmOnvClass}/9`
|
||||
: ''}{listing.lbmPrecision !== 'address' ? ' · approx.' : ''}
|
||||
</span>
|
||||
{#if detail?.lat != null && detail?.lng != null}
|
||||
<a
|
||||
href={lbmMapUrl(detail.lat, detail.lng)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-muted-foreground underline-offset-2 hover:underline">map</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user