feat: house tracker with home worker, alerts and listing map
Collects Pararius rent/buy listings into Postgres via a worker that runs on a residential connection, sends Discord/ntfy alerts for new listings matching rules, and shows an admin-only map, market stats and worker log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HfF5tnChi8tkCx9svJP6D
This commit is contained in:
235
src/routes/projects/house-tracker/+page.server.ts
Normal file
235
src/routes/projects/house-tracker/+page.server.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { and, asc, desc, eq, gte, ilike, isNull, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { houseAlertRules, houseListings, houseWorkerRuns } from '$lib/server/db/schema';
|
||||
import { requireAdmin } from '$lib/server/authorization';
|
||||
import { sendAlert } from '$lib/server/house-tracker';
|
||||
import { normalizeCity } from '$lib/house-tracker/alerts';
|
||||
import { listingStatuses, propertyTypes } from '$lib/house-tracker/types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const int = (value: string | null) => {
|
||||
const n = Number(value);
|
||||
return value && Number.isInteger(n) && n >= 0 && n < 100_000_000 ? n : null;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user) redirect(303, '/login?returnTo=/projects/house-tracker');
|
||||
requireAdmin(event);
|
||||
const params = event.url.searchParams;
|
||||
const offer = params.get('offer') === 'buy' ? 'buy' : 'rent';
|
||||
const city = (params.get('city') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
const min = int(params.get('min'));
|
||||
const max = int(params.get('max'));
|
||||
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') ?? '')
|
||||
? (params.get('sort') as string)
|
||||
: 'new';
|
||||
|
||||
const where: (SQL | undefined)[] = [eq(houseListings.offer, offer)];
|
||||
if (city) where.push(eq(houseListings.citySlug, city));
|
||||
if (min != null) where.push(gte(houseListings.priceEur, min));
|
||||
if (max != null) where.push(lte(houseListings.priceEur, max));
|
||||
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))
|
||||
where.push(eq(houseListings.status, status));
|
||||
if (q) {
|
||||
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
where.push(
|
||||
or(
|
||||
ilike(houseListings.title, pattern),
|
||||
ilike(houseListings.neighbourhood, pattern),
|
||||
ilike(houseListings.postalCode, pattern),
|
||||
ilike(houseListings.agent, pattern)
|
||||
)
|
||||
);
|
||||
}
|
||||
const ppm2 = sql<number>`${houseListings.priceEur}::float / nullif(${houseListings.areaM2}, 0)`;
|
||||
const order = {
|
||||
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)]
|
||||
}[sort]!;
|
||||
|
||||
const [listings, cities, stats, rules, runs, health] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: houseListings.id,
|
||||
source: houseListings.source,
|
||||
url: houseListings.url,
|
||||
propertyType: houseListings.propertyType,
|
||||
title: houseListings.title,
|
||||
city: houseListings.city,
|
||||
neighbourhood: houseListings.neighbourhood,
|
||||
postalCode: houseListings.postalCode,
|
||||
priceEur: houseListings.priceEur,
|
||||
areaM2: houseListings.areaM2,
|
||||
rooms: houseListings.rooms,
|
||||
bedrooms: houseListings.bedrooms,
|
||||
energyLabel: houseListings.energyLabel,
|
||||
interior: houseListings.interior,
|
||||
availableFrom: houseListings.availableFrom,
|
||||
agent: houseListings.agent,
|
||||
imageUrl: houseListings.imageUrl,
|
||||
lat: houseListings.lat,
|
||||
lng: houseListings.lng,
|
||||
offeredAt: houseListings.offeredAt,
|
||||
firstSeenAt: houseListings.firstSeenAt,
|
||||
firstSeenLane: houseListings.firstSeenLane,
|
||||
removedAt: houseListings.removedAt,
|
||||
alertedAt: houseListings.alertedAt,
|
||||
status: houseListings.status,
|
||||
notes: houseListings.notes
|
||||
})
|
||||
.from(houseListings)
|
||||
.where(and(...where))
|
||||
.orderBy(...order)
|
||||
.limit(400),
|
||||
db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
|
||||
count(*) FILTER (WHERE removed_at IS NULL)::int AS active
|
||||
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
|
||||
GROUP BY city_slug ORDER BY active DESC, slug LIMIT 200`),
|
||||
db.execute(sql`SELECT coalesce(min(city), city_slug) AS city,
|
||||
count(*) FILTER (WHERE removed_at IS NULL)::int AS active,
|
||||
count(*) FILTER (WHERE first_seen_at > now() - interval '7 days')::int AS new_week,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_eur)
|
||||
FILTER (WHERE removed_at IS NULL AND price_eur > 0) AS median_price,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_eur::float / area_m2)
|
||||
FILTER (WHERE removed_at IS NULL AND price_eur > 0 AND area_m2 > 0) AS median_ppm2,
|
||||
percentile_cont(0.5) WITHIN GROUP (
|
||||
ORDER BY extract(epoch FROM removed_at - coalesce(offered_at, first_seen_at)) / 86400)
|
||||
FILTER (WHERE removed_at > now() - interval '90 days') AS median_days_listed
|
||||
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
|
||||
GROUP BY city_slug ORDER BY active DESC LIMIT 30`),
|
||||
db.select().from(houseAlertRules).orderBy(asc(houseAlertRules.id)),
|
||||
db.select().from(houseWorkerRuns).orderBy(desc(houseWorkerRuns.createdAt)).limit(25),
|
||||
db.execute(sql`SELECT
|
||||
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'ok') AS last_ok,
|
||||
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'challenged') AS last_challenge,
|
||||
(SELECT count(*)::int FROM house_listings) AS total,
|
||||
(SELECT count(*)::int FROM house_listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
|
||||
(SELECT percentile_cont(0.5) WITHIN GROUP (
|
||||
ORDER BY extract(epoch FROM first_seen_at - offered_at) / 60)
|
||||
FROM house_listings WHERE first_seen_lane = 'fast' AND offered_at IS NOT NULL
|
||||
AND first_seen_at >= offered_at AND first_seen_at - offered_at < interval '3 days'
|
||||
AND first_seen_at > now() - interval '30 days')
|
||||
AS median_detect_minutes`)
|
||||
]);
|
||||
|
||||
return {
|
||||
filters: { offer, city, min, max, showRemoved, status, q, sort },
|
||||
listings,
|
||||
cities: cities as unknown as { slug: string; name: string | null; active: number }[],
|
||||
stats: stats as unknown as {
|
||||
city: string;
|
||||
active: number;
|
||||
new_week: number;
|
||||
median_price: number | null;
|
||||
median_ppm2: number | null;
|
||||
median_days_listed: number | null;
|
||||
}[],
|
||||
rules,
|
||||
runs,
|
||||
health: health[0] as {
|
||||
last_ok: Date | string | null;
|
||||
last_challenge: Date | string | null;
|
||||
total: number;
|
||||
alerts_week: number;
|
||||
median_detect_minutes: number | null;
|
||||
},
|
||||
statuses: listingStatuses,
|
||||
propertyTypes
|
||||
};
|
||||
};
|
||||
|
||||
const nullableInt = (value: FormDataEntryValue | null) => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return null;
|
||||
const n = Number(text);
|
||||
return Number.isInteger(n) && n >= 0 && n < 100_000_000 ? n : undefined;
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
status: async (event) => {
|
||||
requireAdmin(event);
|
||||
const form = await event.request.formData();
|
||||
const id = Number(form.get('id'));
|
||||
const status = String(form.get('status') ?? '');
|
||||
const notes = String(form.get('notes') ?? '')
|
||||
.trim()
|
||||
.slice(0, 2000);
|
||||
if (!Number.isInteger(id) || id <= 0) return fail(400, { message: 'Invalid listing' });
|
||||
if (status && !(listingStatuses as readonly string[]).includes(status))
|
||||
return fail(400, { message: 'Invalid status' });
|
||||
await db
|
||||
.update(houseListings)
|
||||
.set({ status: status || null, ...(form.has('notes') ? { notes: notes || null } : {}) })
|
||||
.where(eq(houseListings.id, id));
|
||||
return { ok: true };
|
||||
},
|
||||
saveRule: async (event) => {
|
||||
requireAdmin(event);
|
||||
const form = await event.request.formData();
|
||||
const id = Number(form.get('id') || 0);
|
||||
const name = String(form.get('name') ?? '')
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
const minPrice = nullableInt(form.get('minPrice'));
|
||||
const maxPrice = nullableInt(form.get('maxPrice'));
|
||||
const minArea = nullableInt(form.get('minArea'));
|
||||
const minBedrooms = nullableInt(form.get('minBedrooms'));
|
||||
if (!name) return fail(400, { message: 'Give the rule a name' });
|
||||
if (
|
||||
minPrice === undefined ||
|
||||
maxPrice === undefined ||
|
||||
minArea === undefined ||
|
||||
minBedrooms === undefined
|
||||
)
|
||||
return fail(400, { message: 'Numbers must be whole and positive' });
|
||||
const values = {
|
||||
name,
|
||||
enabled: form.get('enabled') === 'on',
|
||||
offer: form.get('offer') === 'buy' ? 'buy' : 'rent',
|
||||
minPrice,
|
||||
maxPrice,
|
||||
minArea,
|
||||
minBedrooms,
|
||||
cities: String(form.get('cities') ?? '')
|
||||
.split(',')
|
||||
.map(normalizeCity)
|
||||
.filter(Boolean)
|
||||
.slice(0, 60),
|
||||
propertyTypes: form
|
||||
.getAll('propertyTypes')
|
||||
.map(String)
|
||||
.filter((type) => (propertyTypes as readonly string[]).includes(type))
|
||||
};
|
||||
if (id > 0) await db.update(houseAlertRules).set(values).where(eq(houseAlertRules.id, id));
|
||||
else await db.insert(houseAlertRules).values(values);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteRule: async (event) => {
|
||||
requireAdmin(event);
|
||||
const id = Number((await event.request.formData()).get('id'));
|
||||
if (!Number.isInteger(id) || id <= 0) return fail(400, { message: 'Invalid rule' });
|
||||
await db.delete(houseAlertRules).where(eq(houseAlertRules.id, id));
|
||||
return { ok: true };
|
||||
},
|
||||
testAlert: async (event) => {
|
||||
requireAdmin(event);
|
||||
const sent = await sendAlert({
|
||||
title: '🏠 House tracker test',
|
||||
body: 'If you can read this, alerts work.',
|
||||
url: `${event.url.origin}/projects/house-tracker`
|
||||
});
|
||||
return sent
|
||||
? { message: 'Test alert sent' }
|
||||
: fail(400, {
|
||||
message: 'No alert channel accepted it. Set HOUSE_DISCORD_WEBHOOK_URL or HOUSE_NTFY_URL.'
|
||||
});
|
||||
}
|
||||
};
|
||||
412
src/routes/projects/house-tracker/+page.svelte
Normal file
412
src/routes/projects/house-tracker/+page.svelte
Normal file
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount } from 'svelte';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import BellRing from 'lucide-svelte/icons/bell-ring';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import ListingMap from '$lib/house-tracker/ListingMap.svelte';
|
||||
import { relativeTime } from '$lib/relative-time';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => (now = Date.now()), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
let highlighted = $state<number | null>(null);
|
||||
const euro = new Intl.NumberFormat('nl-NL', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
const clock = new Intl.DateTimeFormat('nl-NL', {
|
||||
timeZone: 'Europe/Amsterdam',
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const suffix = $derived(data.filters.offer === 'rent' ? '/mo' : '');
|
||||
|
||||
function price(value: number | null) {
|
||||
return value == null ? 'on request' : `${euro.format(value)}${suffix}`;
|
||||
}
|
||||
function minutes(from: Date | string | null, to: Date | string | null) {
|
||||
if (!from || !to) return null;
|
||||
return Math.round((new Date(to).getTime() - new Date(from).getTime()) / 60_000);
|
||||
}
|
||||
function delayLabel(value: number | null) {
|
||||
if (value == null || value < 0) return null;
|
||||
if (value < 60) return `+${value} min`;
|
||||
if (value < 48 * 60) return `+${Math.round(value / 60)} h`;
|
||||
return `+${Math.round(value / 1440)} d`;
|
||||
}
|
||||
const workerAge = $derived(
|
||||
data.health.last_ok ? now - new Date(data.health.last_ok).getTime() : Infinity
|
||||
);
|
||||
const workerState = $derived(
|
||||
data.health.last_challenge &&
|
||||
(!data.health.last_ok || new Date(data.health.last_challenge) > new Date(data.health.last_ok))
|
||||
? 'challenged'
|
||||
: workerAge < 75 * 60_000
|
||||
? 'healthy'
|
||||
: 'quiet'
|
||||
);
|
||||
const mappable = $derived(data.listings.filter((row) => row.lat != null).length);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>house tracker</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||||
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight">house tracker</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{data.health.total} listings collected · {data.health.alerts_week}
|
||||
{data.health.alerts_week === 1 ? 'alert' : 'alerts'} this week
|
||||
{#if data.health.median_detect_minutes != null}
|
||||
· the fast lane spots new listings {delayLabel(
|
||||
Math.round(data.health.median_detect_minutes)
|
||||
)?.slice(1)} after they go up (median)
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class={[
|
||||
'inline-block size-2.5 rounded-full',
|
||||
workerState === 'healthy' && 'bg-green-600',
|
||||
workerState === 'quiet' && 'bg-amber-500',
|
||||
workerState === 'challenged' && 'bg-red-600'
|
||||
]}
|
||||
></span>
|
||||
{#if workerState === 'challenged'}
|
||||
worker was challenged {relativeTime(data.health.last_challenge!, now)}, backing off
|
||||
{:else if data.health.last_ok}
|
||||
worker last synced {relativeTime(data.health.last_ok, now)}
|
||||
{:else}
|
||||
worker has not synced yet
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
|
||||
<NativeSelect name="offer" value={data.filters.offer} aria-label="Rent or buy">
|
||||
<option value="rent">rent</option>
|
||||
<option value="buy">buy</option>
|
||||
</NativeSelect>
|
||||
<NativeSelect name="city" value={data.filters.city} aria-label="City">
|
||||
<option value="">all cities</option>
|
||||
{#each data.cities as city (city.slug)}
|
||||
<option value={city.slug}>{city.name ?? city.slug} ({city.active})</option>
|
||||
{/each}
|
||||
</NativeSelect>
|
||||
<Input name="min" type="number" min="0" placeholder="min €" value={data.filters.min ?? ''} />
|
||||
<Input name="max" type="number" min="0" placeholder="max €" value={data.filters.max ?? ''} />
|
||||
<Input name="q" placeholder="street, area, agent" value={data.filters.q} class="col-span-2" />
|
||||
<NativeSelect name="sort" value={data.filters.sort} aria-label="Sort">
|
||||
<option value="new">newest</option>
|
||||
<option value="price">cheapest</option>
|
||||
<option value="ppm2">cheapest per m²</option>
|
||||
<option value="area">biggest</option>
|
||||
</NativeSelect>
|
||||
<NativeSelect name="status" value={data.filters.status} aria-label="My status">
|
||||
<option value="">any status</option>
|
||||
<option value="any">tracked only</option>
|
||||
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
|
||||
</NativeSelect>
|
||||
<label class="col-span-2 flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="removed" value="1" checked={data.filters.showRemoved} />
|
||||
include listings that are gone
|
||||
</label>
|
||||
<Button type="submit" class="col-span-2 sm:col-span-1">filter</Button>
|
||||
</form>
|
||||
|
||||
<ListingMap
|
||||
listings={data.listings}
|
||||
offer={data.filters.offer}
|
||||
onselect={(id) => (highlighted = id)}
|
||||
/>
|
||||
<p class="mt-1 mb-6 text-xs text-muted-foreground">
|
||||
{mappable} of {data.listings.length} shown listings have coordinates (they arrive with the detail
|
||||
page fetch).
|
||||
</p>
|
||||
|
||||
<ul class="divide-y divide-border rounded-lg border border-border">
|
||||
{#each data.listings as listing (listing.id)}
|
||||
{@const delay = minutes(listing.offeredAt, listing.firstSeenAt)}
|
||||
<li
|
||||
id="listing-{listing.id}"
|
||||
class={[
|
||||
'flex gap-3 p-3 sm:gap-4',
|
||||
highlighted === listing.id && 'bg-muted',
|
||||
listing.removedAt && 'opacity-60'
|
||||
]}
|
||||
>
|
||||
{#if listing.imageUrl}
|
||||
<img
|
||||
src={listing.imageUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
class="hidden size-24 shrink-0 rounded-md object-cover sm:block"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<!-- External listing site. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={listing.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex items-center gap-1 font-medium hover:underline"
|
||||
>
|
||||
{listing.title ?? 'Listing'}
|
||||
<ExternalLink class="size-3.5 text-muted-foreground" />
|
||||
</a>
|
||||
<span class="font-semibold tabular-nums">{price(listing.priceEur)}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-sm text-muted-foreground">
|
||||
{[
|
||||
listing.postalCode,
|
||||
[listing.city, listing.neighbourhood].filter(Boolean).join(', '),
|
||||
listing.areaM2 != null && `${listing.areaM2} m²`,
|
||||
listing.priceEur &&
|
||||
listing.areaM2 &&
|
||||
`${euro.format(listing.priceEur / listing.areaM2)}/m²`,
|
||||
listing.rooms != null && `${listing.rooms} rooms`,
|
||||
listing.energyLabel && `label ${listing.energyLabel}`,
|
||||
listing.interior,
|
||||
listing.availableFrom && `available ${listing.availableFrom.toLowerCase()}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{#if listing.offeredAt}
|
||||
<span title="published on the site"
|
||||
>posted {clock.format(new Date(listing.offeredAt))}</span
|
||||
>
|
||||
{/if}
|
||||
<span title="first seen by the worker">
|
||||
seen {relativeTime(listing.firstSeenAt, now)}
|
||||
{#if listing.firstSeenLane !== 'seed' && delayLabel(delay)}({delayLabel(delay)}, {listing.firstSeenLane}
|
||||
lane){/if}
|
||||
</span>
|
||||
{#if listing.alertedAt}<Badge variant="secondary"
|
||||
><BellRing class="mr-1 size-3" />alerted</Badge
|
||||
>{/if}
|
||||
{#if listing.removedAt}<Badge variant="outline"
|
||||
>gone {relativeTime(listing.removedAt, now)}</Badge
|
||||
>{/if}
|
||||
{#if listing.agent}<span>{listing.agent}</span>{/if}
|
||||
<span>{listing.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/status"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update({ reset: false, invalidateAll: false })}
|
||||
class="shrink-0"
|
||||
>
|
||||
<input type="hidden" name="id" value={listing.id} />
|
||||
<NativeSelect
|
||||
name="status"
|
||||
value={listing.status ?? ''}
|
||||
aria-label="My status"
|
||||
class="h-8 text-xs"
|
||||
onchange={(event) => (event.currentTarget as HTMLSelectElement).form?.requestSubmit()}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
|
||||
</NativeSelect>
|
||||
<noscript><Button type="submit" size="sm" variant="outline">save</Button></noscript>
|
||||
</form>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="p-6 text-center text-sm text-muted-foreground">
|
||||
No listings yet. Start the home worker (see docs/house-tracker.md).
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if data.listings.length === 400}
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Showing the first 400. Narrow the filters to see more.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<h2 class="mt-12 mb-3 text-2xl font-semibold">market ({data.filters.offer})</h2>
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-2">city</th>
|
||||
<th class="px-3 py-2 text-right">active</th>
|
||||
<th class="px-3 py-2 text-right">new (7d)</th>
|
||||
<th class="px-3 py-2 text-right">median price</th>
|
||||
<th class="px-3 py-2 text-right">median €/m²</th>
|
||||
<th class="px-3 py-2 text-right">median days listed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.stats as row (row.city)}
|
||||
<tr class="border-t border-border tabular-nums">
|
||||
<td class="px-3 py-2">{row.city}</td>
|
||||
<td class="px-3 py-2 text-right">{row.active}</td>
|
||||
<td class="px-3 py-2 text-right">{row.new_week}</td>
|
||||
<td class="px-3 py-2 text-right"
|
||||
>{row.median_price != null ? price(Math.round(row.median_price)) : '—'}</td
|
||||
>
|
||||
<td class="px-3 py-2 text-right"
|
||||
>{row.median_ppm2 != null ? euro.format(row.median_ppm2) : '—'}</td
|
||||
>
|
||||
<td class="px-3 py-2 text-right"
|
||||
>{row.median_days_listed != null ? Math.round(row.median_days_listed) : '—'}</td
|
||||
>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr
|
||||
><td colspan="6" class="px-3 py-4 text-center text-muted-foreground">no data yet</td
|
||||
></tr
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
"Days listed" needs a few full sweeps before listings can be seen disappearing.
|
||||
</p>
|
||||
|
||||
<div class="mt-12 mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-2xl font-semibold">alerts</h2>
|
||||
<form method="POST" action="?/testAlert" use:enhance>
|
||||
<Button type="submit" variant="outline" size="sm"
|
||||
><BellRing class="mr-1 size-4" />send test alert</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
{#if form?.message}<p class="mb-3 text-sm">{form.message}</p>{/if}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each [...data.rules, null] as rule (rule?.id ?? 'new')}
|
||||
<Card.Root class="py-0">
|
||||
<Card.Content class="p-4">
|
||||
<form method="POST" action="?/saveRule" use:enhance class="grid gap-2 text-sm">
|
||||
<input type="hidden" name="id" value={rule?.id ?? ''} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Input name="name" value={rule?.name ?? ''} placeholder="new rule name" required />
|
||||
<label class="flex items-center gap-1 whitespace-nowrap">
|
||||
<input type="checkbox" name="enabled" checked={rule?.enabled ?? true} /> on
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<NativeSelect name="offer" value={rule?.offer ?? 'rent'} aria-label="Offer">
|
||||
<option value="rent">rent</option>
|
||||
<option value="buy">buy</option>
|
||||
</NativeSelect>
|
||||
<Input
|
||||
name="minPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="min €"
|
||||
value={rule?.minPrice ?? ''}
|
||||
/>
|
||||
<Input
|
||||
name="maxPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="max €"
|
||||
value={rule?.maxPrice ?? ''}
|
||||
/>
|
||||
<Input
|
||||
name="minArea"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="min m²"
|
||||
value={rule?.minArea ?? ''}
|
||||
/>
|
||||
<Input
|
||||
name="minBedrooms"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="min bedrooms"
|
||||
value={rule?.minBedrooms ?? ''}
|
||||
class="col-span-2"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
name="cities"
|
||||
placeholder="cities, comma separated (empty = anywhere)"
|
||||
value={rule?.cities.join(', ') ?? ''}
|
||||
/>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each data.propertyTypes as type (type)}
|
||||
<label class="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="propertyTypes"
|
||||
value={type}
|
||||
checked={rule?.propertyTypes.includes(type) ?? false}
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" size="sm">{rule ? 'save' : 'add rule'}</Button>
|
||||
{#if rule}
|
||||
<Button type="submit" size="sm" variant="ghost" formaction="?/deleteRule"
|
||||
>delete</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Unchecked property types means all types. Listings without a size pass size limits, so you never
|
||||
miss one because a search card left it out.
|
||||
</p>
|
||||
|
||||
<h2 class="mt-12 mb-3 text-2xl font-semibold">worker log</h2>
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<table class="w-full text-xs">
|
||||
<tbody>
|
||||
{#each data.runs as run (run.id)}
|
||||
<tr class="border-t border-border first:border-t-0">
|
||||
<td class="px-3 py-1.5 whitespace-nowrap text-muted-foreground"
|
||||
>{relativeTime(run.createdAt, now)}</td
|
||||
>
|
||||
<td class="px-3 py-1.5">{run.lane}</td>
|
||||
<td class={['px-3 py-1.5', run.status !== 'ok' && 'font-medium text-red-600']}>
|
||||
{run.status}{run.httpStatus ? ` ${run.httpStatus}` : ''}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 tabular-nums">{run.found ?? ''}</td>
|
||||
<td class="max-w-md truncate px-3 py-1.5 text-muted-foreground" title={run.url}>
|
||||
{run.message ?? run.url.replace(/^https:\/\/www\./, '')}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td class="px-3 py-4 text-center text-muted-foreground">nothing yet</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { detailsRequestSchema } from '$lib/house-tracker/types';
|
||||
import { requireWorker, saveDetails } from '$lib/server/house-tracker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
requireWorker(request);
|
||||
const parsed = detailsRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return json({ error: parsed.error.issues.slice(0, 5) }, { status: 400 });
|
||||
return json(await saveDetails(parsed.data));
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { reportRequestSchema } from '$lib/house-tracker/types';
|
||||
import { recordReport, requireWorker } from '$lib/server/house-tracker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
requireWorker(request);
|
||||
const parsed = reportRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return json({ error: parsed.error.issues.slice(0, 5) }, { status: 400 });
|
||||
await recordReport(parsed.data);
|
||||
return json({ ok: true });
|
||||
};
|
||||
11
src/routes/projects/house-tracker/api/worker/sync/+server.ts
Normal file
11
src/routes/projects/house-tracker/api/worker/sync/+server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { syncRequestSchema } from '$lib/house-tracker/types';
|
||||
import { requireWorker, syncCards } from '$lib/server/house-tracker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
requireWorker(request);
|
||||
const parsed = syncRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return json({ error: parsed.error.issues.slice(0, 5) }, { status: 400 });
|
||||
return json(await syncCards(parsed.data));
|
||||
};
|
||||
Reference in New Issue
Block a user