house tracker: contact status per listing (whose turn, last message, viewing) and labelled filters
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-25 13:53:01 +00:00
parent df081b8058
commit 7d6cee26a8
8 changed files with 26860 additions and 94 deletions

View File

@@ -0,0 +1,17 @@
ALTER TABLE "house"."listings" ADD COLUMN "contact_status" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "contact_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "contact_by" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "contact_note" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "contact_ref" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "viewing_at" timestamp with time zone;--> statement-breakpoint
CREATE INDEX "listings_contact_idx" ON "house"."listings" ("contact_status") WHERE contact_status IS NOT NULL;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD CONSTRAINT "listings_contact_status_check" CHECK (contact_status IS NULL OR contact_status IN ('waiting_on_me', 'not_contacted', 'waiting_on_them', 'viewing', 'closed'));--> statement-breakpoint
ALTER TABLE "house"."listings" ADD CONSTRAINT "listings_contact_by_check" CHECK (contact_by IS NULL OR contact_by IN ('me', 'them'));--> statement-breakpoint
-- house_cli (scripts/house-db.sh) has table-level rights on house.listings, which cover new
-- columns; re-granted here so a fresh database gets the same. The role is created out of band.
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'house_cli') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON house.listings TO house_cli;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@
// bun run house add --url <link> --city Gouda --price 1422 --area 84 --bedrooms 2 // bun run house add --url <link> --city Gouda --price 1422 --area 84 --bedrooms 2
// bun run house add --json listing.json (or `--json -` for stdin; may be an array) // bun run house add --json listing.json (or `--json -` for stdin; may be an array)
// bun run house update <id|url> --status viewing --notes "Sat 14:00" // bun run house update <id|url> --status viewing --notes "Sat 14:00"
// bun run house list [--all] [--city gouda] [--offer buy] [--json] // bun run house update <id|url> --contact waiting_on_them --contact-at 2026-10-06 --contact-by me \
// --contact-note "Applied via form" --contact-ref "gmail thread …"
// bun run house list [--all] [--city gouda] [--offer buy] [--contact any|<s>] [--json]
// bun run house remove <id|url> [--hard] (soft remove = marked gone) // bun run house remove <id|url> [--hard] (soft remove = marked gone)
// bun run house restore <id|url> // bun run house restore <id|url>
// //
@@ -37,7 +39,8 @@ const usage = `usage: bun run house <add|update|list|remove|restore> [options]
add [--json <file|->] [field flags] insert, or update when the same link/address exists add [--json <file|->] [field flags] insert, or update when the same link/address exists
update <id|url> [field flags] change only the given fields (use "" to clear one) update <id|url> [field flags] change only the given fields (use "" to clear one)
list [--all] [--city <slug>] [--offer rent|buy] [--status <s>] [--limit n] [--json] list [--all] [--city <slug>] [--offer rent|buy] [--status <s>] [--contact any|<s>]
[--limit n] [--json]
remove <id|url> [--hard] mark gone (or delete with --hard) remove <id|url> [--hard] mark gone (or delete with --hard)
restore <id|url> undo a soft remove restore <id|url> undo a soft remove
@@ -47,6 +50,9 @@ field flags: --url --offer rent|buy --type apartment|house|room|studio|other --t
--contract-type --agent --image-url --description --notes --status --contract-type --agent --image-url --description --notes --status
interested|contacted|viewing|applied|rejected --lat --lng --offered-at interested|contacted|viewing|applied|rejected --lat --lng --offered-at
--first-seen (when it was shared) --source --source-id --first-seen (when it was shared) --source --source-id
contact: --contact waiting_on_me|not_contacted|waiting_on_them|viewing|closed
--contact-at <date> (last message) --contact-by me|them --contact-note (one line)
--contact-ref (mail thread / chat) --viewing-at <date time>
other: --dry-run (print, don't write)`; other: --dry-run (print, don't write)`;
const fieldFlags = [ const fieldFlags = [
@@ -80,7 +86,13 @@ const fieldFlags = [
'first-seen', 'first-seen',
'source', 'source',
'source-id', 'source-id',
'external-id' 'external-id',
'contact',
'contact-at',
'contact-by',
'contact-note',
'contact-ref',
'viewing-at'
] as const; ] as const;
const { values: flags, positionals } = parseArgs({ const { values: flags, positionals } = parseArgs({
@@ -145,7 +157,8 @@ const sql = postgres(dbUrl, { max: 1, onnotice: () => {} });
type Tx = postgres.TransactionSql; type Tx = postgres.TransactionSql;
const summary = sql`id, source, source_id, offer, property_type, title, city, city_slug, const summary = sql`id, source, source_id, offer, property_type, title, city, city_slug,
price_eur, area_m2, rooms, bedrooms, status, removed_at, url`; price_eur, area_m2, rooms, bedrooms, status, removed_at, url, contact_status, contact_at,
contact_by, contact_note, contact_ref, viewing_at`;
async function findId(ref: string | undefined): Promise<number> { async function findId(ref: string | undefined): Promise<number> {
if (!ref) throw new Error('give a listing id or url'); if (!ref) throw new Error('give a listing id or url');
@@ -336,12 +349,20 @@ async function list() {
const city = flag('city'); const city = flag('city');
const offer = flag('offer'); const offer = flag('offer');
const status = flag('status'); const status = flag('status');
const contact = flag('contact');
const rows = await sql`select ${summary}, first_seen_at, notes from house.listings const rows = await sql`select ${summary}, first_seen_at, notes from house.listings
where true where true
${flag('all') ? sql`` : sql`and removed_at is null`} ${flag('all') ? sql`` : sql`and removed_at is null`}
${typeof city === 'string' ? sql`and city_slug = ${city}` : sql``} ${typeof city === 'string' ? sql`and city_slug = ${city}` : sql``}
${typeof offer === 'string' ? sql`and offer = ${offer}` : sql``} ${typeof offer === 'string' ? sql`and offer = ${offer}` : sql``}
${typeof status === 'string' ? sql`and status = ${status}` : sql``} ${typeof status === 'string' ? sql`and status = ${status}` : sql``}
${
contact === 'any'
? sql`and contact_status is not null`
: typeof contact === 'string'
? sql`and contact_status = ${contact}`
: sql``
}
order by first_seen_at desc limit ${limit}`; order by first_seen_at desc limit ${limit}`;
if (flag('json')) console.log(JSON.stringify(rows, null, 2)); if (flag('json')) console.log(JSON.stringify(rows, null, 2));
else print(rows); else print(rows);
@@ -378,6 +399,17 @@ function print(rows: readonly Record<string, unknown>[]) {
console.log( console.log(
`${r.action ? `${r.action} ` : ''}#${r.id} [${r.source}] ${r.title ?? '(no title)'}, ${r.city ?? '?'} · ${bits.join(' · ')}\n ${r.url}` `${r.action ? `${r.action} ` : ''}#${r.id} [${r.source}] ${r.title ?? '(no title)'}, ${r.city ?? '?'} · ${bits.join(' · ')}\n ${r.url}`
); );
if (r.contact_status) {
const day = (d: unknown) => (d instanceof Date ? d.toISOString().slice(0, 16) : d);
const contact = [
r.contact_status,
r.viewing_at && `viewing ${day(r.viewing_at)}`,
r.contact_at && `last ${day(r.contact_at)}${r.contact_by ? ` by ${r.contact_by}` : ''}`,
r.contact_note,
r.contact_ref && `ref: ${r.contact_ref}`
].filter(Boolean);
console.log(` contact: ${contact.join(' · ')}`);
}
} }
} }

View File

@@ -6,6 +6,8 @@ import { siteKeyFromUrl } from './link-key';
import { listingRef, parseEuros, parseInteger } from './pararius'; import { listingRef, parseEuros, parseInteger } from './pararius';
import { import {
listingDetailSchema, listingDetailSchema,
contactParties,
contactStatuses,
listingStatuses, listingStatuses,
offers, offers,
propertyTypes, propertyTypes,
@@ -163,7 +165,13 @@ export const manualListingSchema = listingDetailSchema
offeredAt: lenientDate, offeredAt: lenientDate,
firstSeenAt: lenientDate, firstSeenAt: lenientDate,
status: z.enum(listingStatuses).nullish(), status: z.enum(listingStatuses).nullish(),
notes: z.string().trim().max(2000).nullish() notes: z.string().trim().max(2000).nullish(),
contactStatus: z.enum(contactStatuses).nullish(),
contactAt: lenientDate,
contactBy: z.enum(contactParties).nullish(),
contactNote: z.string().trim().max(500).nullish(),
contactRef: z.string().trim().max(500).nullish(),
viewingAt: lenientDate
}); });
export type ManualListing = z.infer<typeof manualListingSchema>; export type ManualListing = z.infer<typeof manualListingSchema>;
@@ -182,7 +190,8 @@ const fieldAliases: Record<string, string> = {
available: 'availableFrom', available: 'availableFrom',
seen: 'firstSeenAt', seen: 'firstSeenAt',
firstSeen: 'firstSeenAt', firstSeen: 'firstSeenAt',
offered: 'offeredAt' offered: 'offeredAt',
contact: 'contactStatus'
}; };
export function normalizeKeys(input: Record<string, unknown>): Record<string, unknown> { export function normalizeKeys(input: Record<string, unknown>): Record<string, unknown> {
@@ -242,19 +251,24 @@ export const columns = {
offeredAt: 'offered_at', offeredAt: 'offered_at',
firstSeenAt: 'first_seen_at', firstSeenAt: 'first_seen_at',
status: 'status', status: 'status',
notes: 'notes' notes: 'notes',
contactStatus: 'contact_status',
contactAt: 'contact_at',
contactBy: 'contact_by',
contactNote: 'contact_note',
contactRef: 'contact_ref',
viewingAt: 'viewing_at'
} as const satisfies Partial<Record<keyof ManualListing, string>>; } as const satisfies Partial<Record<keyof ManualListing, string>>;
const dateFields = new Set(['offeredAt', 'firstSeenAt', 'contactAt', 'viewingAt']);
/** Only the columns the user actually gave; `null` clears a column on update. */ /** Only the columns the user actually gave; `null` clears a column on update. */
export function toColumns(listing: ManualListing): Record<string, unknown> { export function toColumns(listing: ManualListing): Record<string, unknown> {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [field, column] of Object.entries(columns)) { for (const [field, column] of Object.entries(columns)) {
const value = listing[field as keyof ManualListing]; const value = listing[field as keyof ManualListing];
if (value === undefined) continue; if (value === undefined) continue;
out[column] = out[column] = value !== null && dateFields.has(field) ? new Date(value as string) : value;
value !== null && (field === 'offeredAt' || field === 'firstSeenAt')
? new Date(value as string)
: value;
} }
if (listing.city !== undefined) out.city_slug = citySlugFromName(listing.city); if (listing.city !== undefined) out.city_slug = citySlugFromName(listing.city);
return out; return out;

View File

@@ -61,6 +61,26 @@ export const listingStatuses = [
] as const; ] as const;
export type ListingStatus = (typeof listingStatuses)[number]; export type ListingStatus = (typeof listingStatuses)[number];
// Where the conversation with the agent or landlord stands: whose turn it is.
export const contactStatuses = [
'waiting_on_me',
'not_contacted',
'waiting_on_them',
'viewing',
'closed'
] as const;
export type ContactStatus = (typeof contactStatuses)[number];
export const contactStatusLabels: Record<ContactStatus, string> = {
waiting_on_me: 'my turn',
not_contacted: 'not contacted',
waiting_on_them: 'waiting on them',
viewing: 'viewing planned',
closed: 'closed'
};
// Who sent the last message.
export const contactParties = ['me', 'them'] as const;
export type ContactParty = (typeof contactParties)[number];
export const lanes = ['fast', 'slow'] as const; export const lanes = ['fast', 'slow'] as const;
export type Lane = (typeof lanes)[number]; export type Lane = (typeof lanes)[number];

View File

@@ -83,11 +83,25 @@ export const houseListings = houseSchema.table(
lbmYear: integer('lbm_year'), lbmYear: integer('lbm_year'),
lbmDetail: jsonb('lbm_detail').$type<Liveability>(), lbmDetail: jsonb('lbm_detail').$type<Liveability>(),
lbmQuery: text('lbm_query'), lbmQuery: text('lbm_query'),
lbmScoredAt: timestamptz('lbm_scored_at') lbmScoredAt: timestamptz('lbm_scored_at'),
// Contact with the agent/landlord (see contactStatuses in house-tracker/types.ts):
// whose turn it is, the last message (when, from whom, a one-line summary), where the
// conversation lives (mail thread, chat) and the viewing time once one is planned.
contactStatus: text('contact_status'),
contactAt: timestamptz('contact_at'),
contactBy: text('contact_by'),
contactNote: text('contact_note'),
contactRef: text('contact_ref'),
viewingAt: timestamptz('viewing_at')
}, },
(table) => [ (table) => [
check('listings_offer_check', sql`offer IN ('rent', 'buy')`), check('listings_offer_check', sql`offer IN ('rent', 'buy')`),
check('listings_price_eur_check', sql`price_eur IS NULL OR price_eur >= 0`), check('listings_price_eur_check', sql`price_eur IS NULL OR price_eur >= 0`),
check(
'listings_contact_status_check',
sql`contact_status IS NULL OR contact_status IN ('waiting_on_me', 'not_contacted', 'waiting_on_them', 'viewing', 'closed')`
),
check('listings_contact_by_check', sql`contact_by IS NULL OR contact_by IN ('me', 'them')`),
uniqueIndex('listings_source_idx').on(table.source, table.sourceId), uniqueIndex('listings_source_idx').on(table.source, table.sourceId),
index('listings_active_idx') index('listings_active_idx')
.on(table.offer, table.firstSeenAt.desc().nullsFirst()) .on(table.offer, table.firstSeenAt.desc().nullsFirst())
@@ -96,6 +110,9 @@ export const houseListings = houseSchema.table(
index('listings_details_idx') index('listings_details_idx')
.on(table.firstSeenAt.desc().nullsFirst()) .on(table.firstSeenAt.desc().nullsFirst())
.where(sql`detail_fetched_at IS NULL`), .where(sql`detail_fetched_at IS NULL`),
index('listings_contact_idx')
.on(table.contactStatus)
.where(sql`contact_status IS NOT NULL`),
index('listings_address_idx') index('listings_address_idx')
.on(table.addressKey) .on(table.addressKey)
.where(sql`address_key IS NOT NULL`) .where(sql`address_key IS NOT NULL`)

View File

@@ -18,7 +18,12 @@ import { houseAlertRules, houseListings, houseWorkerRuns } from '#lib/server/db/
import { requireOwner } from '#lib/server/owner.js'; import { requireOwner } from '#lib/server/owner.js';
import { sendAlert } from '#lib/server/house-tracker.js'; import { sendAlert } from '#lib/server/house-tracker.js';
import { normalizeCity } from '#lib/house-tracker/alerts.js'; import { normalizeCity } from '#lib/house-tracker/alerts.js';
import { listingStatuses, propertyTypes } from '#lib/house-tracker/types.js'; import {
contactParties,
contactStatuses,
listingStatuses,
propertyTypes
} from '#lib/house-tracker/types.js';
import { dedupe } from '#lib/house-tracker/ingest.js'; import { dedupe } from '#lib/house-tracker/ingest.js';
import { scoreListings } from '#lib/house-tracker/liveability.js'; import { scoreListings } from '#lib/house-tracker/liveability.js';
import { import {
@@ -59,6 +64,13 @@ export const load: PageServerLoad = async (event) => {
const source = (params.get('source') ?? '').replace(/[^a-z0-9-]/g, ''); const source = (params.get('source') ?? '').replace(/[^a-z0-9-]/g, '');
const showRemoved = params.get('removed') === '1'; const showRemoved = params.get('removed') === '1';
const status = params.get('status') ?? ''; const status = params.get('status') ?? '';
// Contact view: every listing with a contact status (or one status), whatever the other
// filters say, so a conversation never hides behind a price or size limit.
const contactParam = params.get('contact') ?? '';
const contact =
contactParam === 'any' || (contactStatuses as readonly string[]).includes(contactParam)
? contactParam
: '';
const q = (params.get('q') ?? '').trim().slice(0, 80); const q = (params.get('q') ?? '').trim().slice(0, 80);
// Leefbaarometer class 1-9; listings without a score are hidden while this is set. // Leefbaarometer class 1-9; listings without a score are hidden while this is set.
const minLbm = int(params.get('lbm')); const minLbm = int(params.get('lbm'));
@@ -66,11 +78,15 @@ export const load: PageServerLoad = async (event) => {
? (params.get('sort') as string) ? (params.get('sort') as string)
: 'new'; : 'new';
const where: (SQL | undefined)[] = [ const where: (SQL | undefined)[] = [];
if (contact === 'any') where.push(sql`${houseListings.contactStatus} IS NOT NULL`);
else if (contact) where.push(eq(houseListings.contactStatus, contact));
else {
where.push(
eq(houseListings.offer, offer), eq(houseListings.offer, offer),
// Copies of the same home on another source are listed under the original. // Copies of the same home on another source are listed under the original.
isNull(houseListings.duplicateOf) isNull(houseListings.duplicateOf)
]; );
if (city === 'region') where.push(inArray(houseListings.citySlug, [...REGION])); if (city === 'region') where.push(inArray(houseListings.citySlug, [...REGION]));
else if (city) where.push(eq(houseListings.citySlug, city)); else if (city) where.push(eq(houseListings.citySlug, city));
if (minArea != null) if (minArea != null)
@@ -82,6 +98,7 @@ export const load: PageServerLoad = async (event) => {
if (max != null) where.push(lte(houseListings.priceEur, max)); if (max != null) where.push(lte(houseListings.priceEur, max));
if (minLbm != null && minLbm > 0) where.push(gte(houseListings.lbmClass, minLbm)); if (minLbm != null && minLbm > 0) where.push(gte(houseListings.lbmClass, minLbm));
if (!showRemoved) where.push(isNull(houseListings.removedAt)); if (!showRemoved) where.push(isNull(houseListings.removedAt));
}
if (status === 'any') where.push(sql`${houseListings.status} IS NOT NULL`); if (status === 'any') where.push(sql`${houseListings.status} IS NOT NULL`);
else if ((listingStatuses as readonly string[]).includes(status)) else if ((listingStatuses as readonly string[]).includes(status))
where.push(eq(houseListings.status, status)); where.push(eq(houseListings.status, status));
@@ -108,8 +125,14 @@ export const load: PageServerLoad = async (event) => {
desc(houseListings.firstSeenAt) desc(houseListings.firstSeenAt)
] ]
}[sort]!; }[sort]!;
// Contact view: my turn first, then by status, newest message first.
const contactOrder = [
sql`array_position(${sql.raw(`ARRAY[${contactStatuses.map((s) => `'${s}'`).join(', ')}]`)}::text[], ${houseListings.contactStatus})`,
sql`${houseListings.viewingAt} ASC NULLS LAST`,
sql`${houseListings.contactAt} DESC NULLS LAST`
];
const [listings, cities, stats, rules, runs, health] = await Promise.all([ const [listings, cities, stats, rules, runs, health, contactCounts] = await Promise.all([
db db
.select({ .select({
id: houseListings.id, id: houseListings.id,
@@ -147,11 +170,18 @@ export const load: PageServerLoad = async (event) => {
lbmPrecision: houseListings.lbmPrecision, lbmPrecision: houseListings.lbmPrecision,
lbmBuurtName: houseListings.lbmBuurtName, lbmBuurtName: houseListings.lbmBuurtName,
lbmYear: houseListings.lbmYear, lbmYear: houseListings.lbmYear,
lbmDetail: houseListings.lbmDetail lbmDetail: houseListings.lbmDetail,
offer: houseListings.offer,
contactStatus: houseListings.contactStatus,
contactAt: houseListings.contactAt,
contactBy: houseListings.contactBy,
contactNote: houseListings.contactNote,
contactRef: houseListings.contactRef,
viewingAt: houseListings.viewingAt
}) })
.from(houseListings) .from(houseListings)
.where(and(...where)) .where(and(...where))
.orderBy(...order) .orderBy(...(contact ? contactOrder : order))
.limit(400), .limit(400),
db.execute(sql`SELECT city_slug AS slug, min(city) AS name, db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
count(*) FILTER (WHERE removed_at IS NULL)::int AS active count(*) FILTER (WHERE removed_at IS NULL)::int AS active
@@ -181,7 +211,9 @@ export const load: PageServerLoad = async (event) => {
FROM house.listings WHERE first_seen_lane = 'fast' AND offered_at IS NOT NULL 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 >= offered_at AND first_seen_at - offered_at < interval '3 days'
AND first_seen_at > now() - interval '30 days') AND first_seen_at > now() - interval '30 days')
AS median_detect_minutes`) AS median_detect_minutes`),
db.execute(sql`SELECT contact_status AS status, count(*)::int AS count
FROM house.listings WHERE contact_status IS NOT NULL GROUP BY contact_status`)
]); ]);
const ids = listings.map((row) => row.id); const ids = listings.map((row) => row.id);
@@ -221,6 +253,7 @@ export const load: PageServerLoad = async (event) => {
source, source,
showRemoved, showRemoved,
status, status,
contact,
q, q,
sort sort
}, },
@@ -246,6 +279,13 @@ export const load: PageServerLoad = async (event) => {
median_detect_minutes: number | null; median_detect_minutes: number | null;
}, },
statuses: listingStatuses, statuses: listingStatuses,
contactStatuses,
contactCounts: Object.fromEntries(
(contactCounts as unknown as { status: string; count: number }[]).map((row) => [
row.status,
row.count
])
) as Partial<Record<(typeof contactStatuses)[number], number>>,
propertyTypes propertyTypes
}; };
}; };
@@ -257,6 +297,30 @@ const nullableInt = (value: FormDataEntryValue | null) => {
return Number.isInteger(n) && n >= 0 && n < 100_000_000 ? n : undefined; return Number.isInteger(n) && n >= 0 && n < 100_000_000 ? n : undefined;
}; };
/** "2026-10-25" or "2026-10-25T14:00" (a date/datetime-local input) read as Amsterdam time. */
function amsterdamTime(value: string | null): Date | null | undefined {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(value);
if (!match) return undefined;
const [, y, mo, d, h = '12', mi = '00'] = match;
const asUtc = Date.UTC(+y, +mo - 1, +d, +h, +mi);
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Amsterdam',
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.formatToParts(new Date(asUtc))
.map((p) => [p.type, p.value])
);
const shown = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute);
return new Date(asUtc - (shown - asUtc));
}
export const actions: Actions = { export const actions: Actions = {
status: async (event) => { status: async (event) => {
requireOwner(event); requireOwner(event);
@@ -275,6 +339,38 @@ export const actions: Actions = {
.where(eq(houseListings.id, id)); .where(eq(houseListings.id, id));
return { ok: true }; return { ok: true };
}, },
contact: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const id = Number(form.get('id'));
const text = (name: string, max: number) =>
String(form.get(name) ?? '')
.trim()
.slice(0, max) || null;
const contactStatus = text('contactStatus', 40);
const contactBy = text('contactBy', 10);
if (!Number.isInteger(id) || id <= 0) return fail(400, { message: 'Invalid listing' });
if (contactStatus && !(contactStatuses as readonly string[]).includes(contactStatus))
return fail(400, { message: 'Invalid contact status' });
if (contactBy && !(contactParties as readonly string[]).includes(contactBy))
return fail(400, { message: 'Invalid sender' });
const contactAt = amsterdamTime(text('contactAt', 30));
const viewingAt = amsterdamTime(text('viewingAt', 30));
if (contactAt === undefined || viewingAt === undefined)
return fail(400, { message: 'Invalid date' });
await db
.update(houseListings)
.set({
contactStatus,
contactAt,
contactBy,
contactNote: text('contactNote', 500),
contactRef: text('contactRef', 500),
viewingAt
})
.where(eq(houseListings.id, id));
return { ok: true };
},
addManual: async (event) => { addManual: async (event) => {
requireOwner(event); requireOwner(event);
const form = await event.request.formData(); const form = await event.request.formData();

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import ExternalLink from 'lucide-svelte/icons/external-link'; import ExternalLink from 'lucide-svelte/icons/external-link';
import BellRing from 'lucide-svelte/icons/bell-ring'; import BellRing from 'lucide-svelte/icons/bell-ring';
@@ -18,6 +19,11 @@
type LbmDimension, type LbmDimension,
type Liveability type Liveability
} from '#lib/house-tracker/liveability.js'; } from '#lib/house-tracker/liveability.js';
import {
contactParties,
contactStatusLabels,
type ContactStatus
} from '#lib/house-tracker/types.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -43,9 +49,42 @@
minute: '2-digit' minute: '2-digit'
}); });
const suffix = $derived(data.filters.offer === 'rent' ? '/mo' : ''); const suffix = $derived(data.filters.offer === 'rent' ? '/mo' : '');
const priceWord = $derived(data.filters.offer === 'rent' ? 'Rent' : 'Price');
const field = 'grid gap-0.5 text-[11px] text-muted-foreground';
const day = new Intl.DateTimeFormat('nl-NL', {
timeZone: 'Europe/Amsterdam',
day: 'numeric',
month: 'short'
});
const contactColour: Record<ContactStatus, string> = {
waiting_on_me: 'bg-red-600 text-white',
not_contacted: 'bg-amber-400 text-black',
waiting_on_them: 'bg-sky-600 text-white',
viewing: 'bg-green-600 text-white',
closed: 'bg-muted text-muted-foreground'
};
// Value for a date / datetime-local input, in Amsterdam time.
function inputValue(value: Date | string | null, withTime: boolean) {
if (!value) return '';
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Amsterdam',
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.formatToParts(new Date(value))
.map((p) => [p.type, p.value])
);
const date = `${parts.year}-${parts.month}-${parts.day}`;
return withTime ? `${date}T${parts.hour}:${parts.minute}` : date;
}
function price(value: number | null) { function price(value: number | null, offer: string = data.filters.offer) {
return value == null ? 'on request' : `${euro.format(value)}${suffix}`; return value == null ? 'on request' : `${euro.format(value)}${offer === 'rent' ? '/mo' : ''}`;
} }
function minutes(from: Date | string | null, to: Date | string | null) { function minutes(from: Date | string | null, to: Date | string | null) {
if (!from || !to) return null; if (!from || !to) return null;
@@ -145,66 +184,140 @@
</div> </div>
</div> </div>
<form method="GET" class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8"> <form method="GET" class="mb-4 grid grid-cols-2 items-end gap-2 sm:grid-cols-4 lg:grid-cols-8">
<NativeSelect name="offer" value={data.filters.offer} aria-label="Rent or buy"> <label class={field}>
<span>Rent or buy</span>
<NativeSelect name="offer" value={data.filters.offer}>
<option value="rent">rent</option> <option value="rent">rent</option>
<option value="buy">buy</option> <option value="buy">buy</option>
</NativeSelect> </NativeSelect>
<NativeSelect name="city" value={data.filters.city} aria-label="City"> </label>
<label class={field}>
<span>City</span>
<NativeSelect name="city" value={data.filters.city}>
<option value="region">Gouda region</option> <option value="region">Gouda region</option>
<option value="">all cities</option> <option value="">all cities</option>
{#each data.cities as city (city.slug)} {#each data.cities as city (city.slug)}
<option value={city.slug}>{city.name ?? city.slug} ({city.active})</option> <option value={city.slug}>{city.name ?? city.slug} ({city.active})</option>
{/each} {/each}
</NativeSelect> </NativeSelect>
<Input name="min" type="number" min="0" placeholder="min €" value={data.filters.min ?? ''} /> </label>
<Input name="max" type="number" min="0" placeholder="max €" value={data.filters.max ?? ''} /> <label class={field}>
<span>{priceWord} from €{suffix}</span>
<Input name="min" type="number" min="0" placeholder="no min" value={data.filters.min ?? ''} />
</label>
<label class={field}>
<span>{priceWord} up to €{suffix}</span>
<Input name="max" type="number" min="0" placeholder="no max" value={data.filters.max ?? ''} />
</label>
<label class={field} title="Living area. Listings without a size are kept.">
<span>Min living area m²</span>
<Input <Input
name="area" name="area"
type="number" type="number"
min="0" min="0"
placeholder="min m²" placeholder="any"
value={data.filters.minArea ?? ''} value={data.filters.minArea ?? ''}
/> />
</label>
<label
class={field}
title="Total rooms (living room included), not bedrooms. Listings without a room count are kept."
>
<span>Min rooms (total)</span>
<Input <Input
name="rooms" name="rooms"
type="number" type="number"
min="0" min="0"
placeholder="min rooms" placeholder="any"
value={data.filters.minRooms ?? ''} value={data.filters.minRooms ?? ''}
/> />
<NativeSelect name="lbm" value={String(data.filters.minLbm ?? '')} aria-label="Min liveability"> </label>
<option value="">any liveability</option> <label class={field}>
<span>Liveability</span>
<NativeSelect name="lbm" value={String(data.filters.minLbm ?? '')}>
<option value="">any</option>
{#each [5, 6, 7, 8] as k (k)} {#each [5, 6, 7, 8] as k (k)}
<option value={String(k)}>≥ {k} {lbmClassLabel(k)}</option> <option value={String(k)}>≥ {k} {lbmClassLabel(k)}</option>
{/each} {/each}
</NativeSelect> </NativeSelect>
<NativeSelect name="source" value={data.filters.source} aria-label="Source"> </label>
<label class={field}>
<span>Source</span>
<NativeSelect name="source" value={data.filters.source}>
<option value="">all sources</option> <option value="">all sources</option>
{#each data.sources as row (row.source)} {#each data.sources as row (row.source)}
<option value={row.source}>{row.source} ({row.active})</option> <option value={row.source}>{row.source} ({row.active})</option>
{/each} {/each}
</NativeSelect> </NativeSelect>
<Input name="q" placeholder="street, area, agent" value={data.filters.q} class="col-span-2" /> </label>
<NativeSelect name="sort" value={data.filters.sort} aria-label="Sort"> <label class={[field, 'col-span-2']}>
<span>Search</span>
<Input name="q" placeholder="street, area, agent" value={data.filters.q} />
</label>
<label class={field}>
<span>Sort</span>
<NativeSelect name="sort" value={data.filters.sort}>
<option value="new">newest</option> <option value="new">newest</option>
<option value="price">cheapest</option> <option value="price">cheapest</option>
<option value="ppm2">cheapest per m²</option> <option value="ppm2">cheapest per m²</option>
<option value="area">biggest</option> <option value="area">biggest</option>
<option value="lbm">best neighbourhood</option> <option value="lbm">best neighbourhood</option>
</NativeSelect> </NativeSelect>
<NativeSelect name="status" value={data.filters.status} aria-label="My status"> </label>
<option value="">any status</option> <label class={field}>
<span>My status</span>
<NativeSelect name="status" value={data.filters.status}>
<option value="">any</option>
<option value="any">tracked only</option> <option value="any">tracked only</option>
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each} {#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
</NativeSelect> </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> </label>
<Button type="submit" class="col-span-2 sm:col-span-1">filter</Button> <label
class={field}
title="Set: shows every listing with that contact status and ignores the other filters"
>
<span>Contact status</span>
<NativeSelect name="contact" value={data.filters.contact}>
<option value="">any</option>
<option value="any">in contact (all)</option>
{#each data.contactStatuses as status (status)}
<option value={status}>{contactStatusLabels[status]}</option>
{/each}
</NativeSelect>
</label>
<label class="flex h-9 items-center gap-2 text-xs">
<input type="checkbox" name="removed" value="1" checked={data.filters.showRemoved} />
include gone
</label>
<Button type="submit">filter</Button>
</form> </form>
<nav class="mb-4 flex flex-wrap items-center gap-2 text-xs" aria-label="Contact status">
<span class="text-muted-foreground">contact:</span>
{#each data.contactStatuses as status (status)}
<a
href={`${resolve('/trackers/houses')}?contact=${status}`}
class={[
'rounded px-2 py-0.5 font-medium',
contactColour[status],
data.filters.contact === status && 'ring-2 ring-foreground'
]}>{contactStatusLabels[status]} · {data.contactCounts[status] ?? 0}</a
>
{/each}
<a
href={`${resolve('/trackers/houses')}?contact=any`}
class="text-muted-foreground underline-offset-2 hover:underline">all contacts</a
>
{#if data.filters.contact}
<a
href={resolve('/trackers/houses')}
class="text-muted-foreground underline-offset-2 hover:underline">back to all listings</a
>
<span class="text-muted-foreground">(other filters are ignored in this view)</span>
{/if}
</nav>
<ListingMap <ListingMap
listings={data.listings} listings={data.listings}
offer={data.filters.offer} offer={data.filters.offer}
@@ -249,7 +362,7 @@
<ExternalLink class="size-3.5 text-muted-foreground" /> <ExternalLink class="size-3.5 text-muted-foreground" />
</a> </a>
<span class="font-semibold tabular-nums"> <span class="font-semibold tabular-nums">
{price(listing.priceEur)} {price(listing.priceEur, listing.offer)}
{#if listing.totalPriceEur && listing.totalPriceEur !== listing.priceEur} {#if listing.totalPriceEur && listing.totalPriceEur !== listing.priceEur}
<span class="text-xs font-normal text-muted-foreground" <span class="text-xs font-normal text-muted-foreground"
>({euro.format(listing.totalPriceEur)} total)</span >({euro.format(listing.totalPriceEur)} total)</span
@@ -325,11 +438,108 @@
{#each listing.copies as copy (copy.id)} {#each listing.copies as copy (copy.id)}
<a href={copy.url} target="_blank" rel="noreferrer" class="underline" <a href={copy.url} target="_blank" rel="noreferrer" class="underline"
>also on {copy.source}{copy.priceEur != null && copy.priceEur !== listing.priceEur >also on {copy.source}{copy.priceEur != null && copy.priceEur !== listing.priceEur
? ` (${price(copy.priceEur)})` ? ` (${price(copy.priceEur, listing.offer)})`
: ''}{copy.removedAt ? ', gone' : ''}</a : ''}{copy.removedAt ? ', gone' : ''}</a
> >
{/each} {/each}
</div> </div>
{#if listing.contactStatus}
{@const status = listing.contactStatus as ContactStatus}
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span
class={['rounded px-1.5 py-0.5 font-medium', contactColour[status]]}
title="contact status"
>
{contactStatusLabels[status] ?? status}{status === 'viewing' && listing.viewingAt
? ` ${clock.format(new Date(listing.viewingAt))}`
: ''}
</span>
{#if listing.contactAt}
<span class="text-muted-foreground"
>last {day.format(new Date(listing.contactAt))}{listing.contactBy
? listing.contactBy === 'me'
? ' by me'
: ' by them'
: ''}</span
>
{/if}
{#if listing.contactNote}<span>{listing.contactNote}</span>{/if}
{#if listing.contactRef}
<span class="text-muted-foreground" title="where the conversation is"
>↳ {listing.contactRef}</span
>
{/if}
</div>
{/if}
<details class="mt-1 text-xs">
<summary class="cursor-pointer text-muted-foreground">
{listing.contactStatus ? 'edit contact' : 'add contact'}
</summary>
<form
method="POST"
action="?/contact"
use:enhance={() =>
async ({ update }) =>
update({ reset: false, refreshAll: false })}
class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
<input type="hidden" name="id" value={listing.id} />
<label class={field}>
<span>Contact status</span>
<NativeSelect name="contactStatus" value={listing.contactStatus ?? ''} class="h-8">
<option value="">none</option>
{#each data.contactStatuses as status (status)}
<option value={status}>{contactStatusLabels[status]}</option>
{/each}
</NativeSelect>
</label>
<label class={field}>
<span>Last message</span>
<Input
name="contactAt"
type="date"
class="h-8"
value={inputValue(listing.contactAt, false)}
/>
</label>
<label class={field}>
<span>Sent by</span>
<NativeSelect name="contactBy" value={listing.contactBy ?? ''} class="h-8">
<option value="">?</option>
{#each contactParties as party (party)}<option value={party}>{party}</option
>{/each}
</NativeSelect>
</label>
<label class={field}>
<span>Viewing</span>
<Input
name="viewingAt"
type="datetime-local"
class="h-8"
value={inputValue(listing.viewingAt, true)}
/>
</label>
<label class={[field, 'col-span-2']}>
<span>One-line note</span>
<Input
name="contactNote"
maxlength={500}
class="h-8"
value={listing.contactNote ?? ''}
/>
</label>
<label class={field}>
<span>Where (thread, chat)</span>
<Input
name="contactRef"
maxlength={500}
class="h-8"
value={listing.contactRef ?? ''}
/>
</label>
<Button type="submit" size="sm" variant="outline" class="self-end">save</Button>
</form>
</details>
<details class="mt-1 text-xs" open={Boolean(listing.notes)}> <details class="mt-1 text-xs" open={Boolean(listing.notes)}>
<summary class="cursor-pointer text-muted-foreground"> <summary class="cursor-pointer text-muted-foreground">
{listing.notes ? 'notes' : 'add notes'} {listing.notes ? 'notes' : 'add notes'}