house tracker: contact status per listing (whose turn, last message, viewing) and labelled filters
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
17
drizzle/20261025135327_house_contact/migration.sql
Normal file
17
drizzle/20261025135327_house_contact/migration.sql
Normal 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 $$;
|
||||
26360
drizzle/20261025135327_house_contact/snapshot.json
Normal file
26360
drizzle/20261025135327_house_contact/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
||||
// 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 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 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
|
||||
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)
|
||||
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
|
||||
interested|contacted|viewing|applied|rejected --lat --lng --offered-at
|
||||
--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)`;
|
||||
|
||||
const fieldFlags = [
|
||||
@@ -80,7 +86,13 @@ const fieldFlags = [
|
||||
'first-seen',
|
||||
'source',
|
||||
'source-id',
|
||||
'external-id'
|
||||
'external-id',
|
||||
'contact',
|
||||
'contact-at',
|
||||
'contact-by',
|
||||
'contact-note',
|
||||
'contact-ref',
|
||||
'viewing-at'
|
||||
] as const;
|
||||
|
||||
const { values: flags, positionals } = parseArgs({
|
||||
@@ -145,7 +157,8 @@ const sql = postgres(dbUrl, { max: 1, onnotice: () => {} });
|
||||
type Tx = postgres.TransactionSql;
|
||||
|
||||
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> {
|
||||
if (!ref) throw new Error('give a listing id or url');
|
||||
@@ -336,12 +349,20 @@ async function list() {
|
||||
const city = flag('city');
|
||||
const offer = flag('offer');
|
||||
const status = flag('status');
|
||||
const contact = flag('contact');
|
||||
const rows = await sql`select ${summary}, first_seen_at, notes from house.listings
|
||||
where true
|
||||
${flag('all') ? sql`` : sql`and removed_at is null`}
|
||||
${typeof city === 'string' ? sql`and city_slug = ${city}` : sql``}
|
||||
${typeof offer === 'string' ? sql`and offer = ${offer}` : 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}`;
|
||||
if (flag('json')) console.log(JSON.stringify(rows, null, 2));
|
||||
else print(rows);
|
||||
@@ -378,6 +399,17 @@ function print(rows: readonly Record<string, unknown>[]) {
|
||||
console.log(
|
||||
`${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(' · ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { siteKeyFromUrl } from './link-key';
|
||||
import { listingRef, parseEuros, parseInteger } from './pararius';
|
||||
import {
|
||||
listingDetailSchema,
|
||||
contactParties,
|
||||
contactStatuses,
|
||||
listingStatuses,
|
||||
offers,
|
||||
propertyTypes,
|
||||
@@ -163,7 +165,13 @@ export const manualListingSchema = listingDetailSchema
|
||||
offeredAt: lenientDate,
|
||||
firstSeenAt: lenientDate,
|
||||
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>;
|
||||
|
||||
@@ -182,7 +190,8 @@ const fieldAliases: Record<string, string> = {
|
||||
available: 'availableFrom',
|
||||
seen: 'firstSeenAt',
|
||||
firstSeen: 'firstSeenAt',
|
||||
offered: 'offeredAt'
|
||||
offered: 'offeredAt',
|
||||
contact: 'contactStatus'
|
||||
};
|
||||
|
||||
export function normalizeKeys(input: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -242,19 +251,24 @@ export const columns = {
|
||||
offeredAt: 'offered_at',
|
||||
firstSeenAt: 'first_seen_at',
|
||||
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>>;
|
||||
|
||||
const dateFields = new Set(['offeredAt', 'firstSeenAt', 'contactAt', 'viewingAt']);
|
||||
|
||||
/** Only the columns the user actually gave; `null` clears a column on update. */
|
||||
export function toColumns(listing: ManualListing): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [field, column] of Object.entries(columns)) {
|
||||
const value = listing[field as keyof ManualListing];
|
||||
if (value === undefined) continue;
|
||||
out[column] =
|
||||
value !== null && (field === 'offeredAt' || field === 'firstSeenAt')
|
||||
? new Date(value as string)
|
||||
: value;
|
||||
out[column] = value !== null && dateFields.has(field) ? new Date(value as string) : value;
|
||||
}
|
||||
if (listing.city !== undefined) out.city_slug = citySlugFromName(listing.city);
|
||||
return out;
|
||||
|
||||
@@ -61,6 +61,26 @@ export const listingStatuses = [
|
||||
] as const;
|
||||
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 type Lane = (typeof lanes)[number];
|
||||
|
||||
|
||||
@@ -83,11 +83,25 @@ export const houseListings = houseSchema.table(
|
||||
lbmYear: integer('lbm_year'),
|
||||
lbmDetail: jsonb('lbm_detail').$type<Liveability>(),
|
||||
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) => [
|
||||
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_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),
|
||||
index('listings_active_idx')
|
||||
.on(table.offer, table.firstSeenAt.desc().nullsFirst())
|
||||
@@ -96,6 +110,9 @@ export const houseListings = houseSchema.table(
|
||||
index('listings_details_idx')
|
||||
.on(table.firstSeenAt.desc().nullsFirst())
|
||||
.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')
|
||||
.on(table.addressKey)
|
||||
.where(sql`address_key IS NOT NULL`)
|
||||
|
||||
@@ -18,7 +18,12 @@ import { houseAlertRules, houseListings, houseWorkerRuns } from '#lib/server/db/
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
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 {
|
||||
contactParties,
|
||||
contactStatuses,
|
||||
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 {
|
||||
@@ -59,6 +64,13 @@ export const load: PageServerLoad = async (event) => {
|
||||
const source = (params.get('source') ?? '').replace(/[^a-z0-9-]/g, '');
|
||||
const showRemoved = params.get('removed') === '1';
|
||||
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);
|
||||
// Leefbaarometer class 1-9; listings without a score are hidden while this is set.
|
||||
const minLbm = int(params.get('lbm'));
|
||||
@@ -66,22 +78,27 @@ export const load: PageServerLoad = async (event) => {
|
||||
? (params.get('sort') as string)
|
||||
: 'new';
|
||||
|
||||
const where: (SQL | undefined)[] = [
|
||||
eq(houseListings.offer, offer),
|
||||
// Copies of the same home on another source are listed under the original.
|
||||
isNull(houseListings.duplicateOf)
|
||||
];
|
||||
if (city === 'region') where.push(inArray(houseListings.citySlug, [...REGION]));
|
||||
else if (city) where.push(eq(houseListings.citySlug, city));
|
||||
if (minArea != null)
|
||||
where.push(or(isNull(houseListings.areaM2), gte(houseListings.areaM2, minArea)));
|
||||
if (minRooms != null)
|
||||
where.push(or(isNull(houseListings.rooms), gte(houseListings.rooms, minRooms)));
|
||||
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));
|
||||
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),
|
||||
// Copies of the same home on another source are listed under the original.
|
||||
isNull(houseListings.duplicateOf)
|
||||
);
|
||||
if (city === 'region') where.push(inArray(houseListings.citySlug, [...REGION]));
|
||||
else if (city) where.push(eq(houseListings.citySlug, city));
|
||||
if (minArea != null)
|
||||
where.push(or(isNull(houseListings.areaM2), gte(houseListings.areaM2, minArea)));
|
||||
if (minRooms != null)
|
||||
where.push(or(isNull(houseListings.rooms), gte(houseListings.rooms, minRooms)));
|
||||
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))
|
||||
where.push(eq(houseListings.status, status));
|
||||
@@ -108,8 +125,14 @@ export const load: PageServerLoad = async (event) => {
|
||||
desc(houseListings.firstSeenAt)
|
||||
]
|
||||
}[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
|
||||
.select({
|
||||
id: houseListings.id,
|
||||
@@ -147,11 +170,18 @@ export const load: PageServerLoad = async (event) => {
|
||||
lbmPrecision: houseListings.lbmPrecision,
|
||||
lbmBuurtName: houseListings.lbmBuurtName,
|
||||
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)
|
||||
.where(and(...where))
|
||||
.orderBy(...order)
|
||||
.orderBy(...(contact ? contactOrder : 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
|
||||
@@ -181,7 +211,9 @@ export const load: PageServerLoad = async (event) => {
|
||||
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`)
|
||||
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);
|
||||
@@ -221,6 +253,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
source,
|
||||
showRemoved,
|
||||
status,
|
||||
contact,
|
||||
q,
|
||||
sort
|
||||
},
|
||||
@@ -246,6 +279,13 @@ export const load: PageServerLoad = async (event) => {
|
||||
median_detect_minutes: number | null;
|
||||
},
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -257,6 +297,30 @@ const nullableInt = (value: FormDataEntryValue | null) => {
|
||||
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 = {
|
||||
status: async (event) => {
|
||||
requireOwner(event);
|
||||
@@ -275,6 +339,38 @@ export const actions: Actions = {
|
||||
.where(eq(houseListings.id, id));
|
||||
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) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import BellRing from 'lucide-svelte/icons/bell-ring';
|
||||
@@ -18,6 +19,11 @@
|
||||
type LbmDimension,
|
||||
type Liveability
|
||||
} from '#lib/house-tracker/liveability.js';
|
||||
import {
|
||||
contactParties,
|
||||
contactStatusLabels,
|
||||
type ContactStatus
|
||||
} from '#lib/house-tracker/types.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
@@ -43,9 +49,42 @@
|
||||
minute: '2-digit'
|
||||
});
|
||||
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) {
|
||||
return value == null ? 'on request' : `${euro.format(value)}${suffix}`;
|
||||
function price(value: number | null, offer: string = data.filters.offer) {
|
||||
return value == null ? 'on request' : `${euro.format(value)}${offer === 'rent' ? '/mo' : ''}`;
|
||||
}
|
||||
function minutes(from: Date | string | null, to: Date | string | null) {
|
||||
if (!from || !to) return null;
|
||||
@@ -145,66 +184,140 @@
|
||||
</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="region">Gouda region</option>
|
||||
<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="area"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="min m²"
|
||||
value={data.filters.minArea ?? ''}
|
||||
/>
|
||||
<Input
|
||||
name="rooms"
|
||||
type="number"
|
||||
min="0"
|
||||
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)}
|
||||
<option value={row.source}>{row.source} ({row.active})</option>
|
||||
{/each}
|
||||
</NativeSelect>
|
||||
<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>
|
||||
<option value="lbm">best neighbourhood</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
|
||||
<form method="GET" class="mb-4 grid grid-cols-2 items-end gap-2 sm:grid-cols-4 lg:grid-cols-8">
|
||||
<label class={field}>
|
||||
<span>Rent or buy</span>
|
||||
<NativeSelect name="offer" value={data.filters.offer}>
|
||||
<option value="rent">rent</option>
|
||||
<option value="buy">buy</option>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<Button type="submit" class="col-span-2 sm:col-span-1">filter</Button>
|
||||
<label class={field}>
|
||||
<span>City</span>
|
||||
<NativeSelect name="city" value={data.filters.city}>
|
||||
<option value="region">Gouda region</option>
|
||||
<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>
|
||||
</label>
|
||||
<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
|
||||
name="area"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="any"
|
||||
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
|
||||
name="rooms"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="any"
|
||||
value={data.filters.minRooms ?? ''}
|
||||
/>
|
||||
</label>
|
||||
<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)}
|
||||
<option value={String(k)}>≥ {k} {lbmClassLabel(k)}</option>
|
||||
{/each}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label class={field}>
|
||||
<span>Source</span>
|
||||
<NativeSelect name="source" value={data.filters.source}>
|
||||
<option value="">all sources</option>
|
||||
{#each data.sources as row (row.source)}
|
||||
<option value={row.source}>{row.source} ({row.active})</option>
|
||||
{/each}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<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="price">cheapest</option>
|
||||
<option value="ppm2">cheapest per m²</option>
|
||||
<option value="area">biggest</option>
|
||||
<option value="lbm">best neighbourhood</option>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label class={field}>
|
||||
<span>My status</span>
|
||||
<NativeSelect name="status" value={data.filters.status}>
|
||||
<option value="">any</option>
|
||||
<option value="any">tracked only</option>
|
||||
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<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
|
||||
listings={data.listings}
|
||||
offer={data.filters.offer}
|
||||
@@ -249,7 +362,7 @@
|
||||
<ExternalLink class="size-3.5 text-muted-foreground" />
|
||||
</a>
|
||||
<span class="font-semibold tabular-nums">
|
||||
{price(listing.priceEur)}
|
||||
{price(listing.priceEur, listing.offer)}
|
||||
{#if listing.totalPriceEur && listing.totalPriceEur !== listing.priceEur}
|
||||
<span class="text-xs font-normal text-muted-foreground"
|
||||
>({euro.format(listing.totalPriceEur)} total)</span
|
||||
@@ -325,11 +438,108 @@
|
||||
{#each listing.copies as copy (copy.id)}
|
||||
<a href={copy.url} target="_blank" rel="noreferrer" class="underline"
|
||||
>also on {copy.source}{copy.priceEur != null && copy.priceEur !== listing.priceEur
|
||||
? ` (${price(copy.priceEur)})`
|
||||
? ` (${price(copy.priceEur, listing.offer)})`
|
||||
: ''}{copy.removedAt ? ', gone' : ''}</a
|
||||
>
|
||||
{/each}
|
||||
</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)}>
|
||||
<summary class="cursor-pointer text-muted-foreground">
|
||||
{listing.notes ? 'notes' : 'add notes'}
|
||||
|
||||
Reference in New Issue
Block a user