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 --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(' · ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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,22 +78,27 @@ 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)[] = [];
|
||||||
eq(houseListings.offer, offer),
|
if (contact === 'any') where.push(sql`${houseListings.contactStatus} IS NOT NULL`);
|
||||||
// Copies of the same home on another source are listed under the original.
|
else if (contact) where.push(eq(houseListings.contactStatus, contact));
|
||||||
isNull(houseListings.duplicateOf)
|
else {
|
||||||
];
|
where.push(
|
||||||
if (city === 'region') where.push(inArray(houseListings.citySlug, [...REGION]));
|
eq(houseListings.offer, offer),
|
||||||
else if (city) where.push(eq(houseListings.citySlug, city));
|
// Copies of the same home on another source are listed under the original.
|
||||||
if (minArea != null)
|
isNull(houseListings.duplicateOf)
|
||||||
where.push(or(isNull(houseListings.areaM2), gte(houseListings.areaM2, minArea)));
|
);
|
||||||
if (minRooms != null)
|
if (city === 'region') where.push(inArray(houseListings.citySlug, [...REGION]));
|
||||||
where.push(or(isNull(houseListings.rooms), gte(houseListings.rooms, minRooms)));
|
else if (city) where.push(eq(houseListings.citySlug, city));
|
||||||
if (source) where.push(eq(houseListings.source, source));
|
if (minArea != null)
|
||||||
if (min != null) where.push(gte(houseListings.priceEur, min));
|
where.push(or(isNull(houseListings.areaM2), gte(houseListings.areaM2, minArea)));
|
||||||
if (max != null) where.push(lte(houseListings.priceEur, max));
|
if (minRooms != null)
|
||||||
if (minLbm != null && minLbm > 0) where.push(gte(houseListings.lbmClass, minLbm));
|
where.push(or(isNull(houseListings.rooms), gte(houseListings.rooms, minRooms)));
|
||||||
if (!showRemoved) where.push(isNull(houseListings.removedAt));
|
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`);
|
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();
|
||||||
|
|||||||
@@ -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}>
|
||||||
<option value="rent">rent</option>
|
<span>Rent or buy</span>
|
||||||
<option value="buy">buy</option>
|
<NativeSelect name="offer" value={data.filters.offer}>
|
||||||
</NativeSelect>
|
<option value="rent">rent</option>
|
||||||
<NativeSelect name="city" value={data.filters.city} aria-label="City">
|
<option value="buy">buy</option>
|
||||||
<option value="region">Gouda region</option>
|
</NativeSelect>
|
||||||
<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
|
|
||||||
</label>
|
</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>
|
</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'}
|
||||||
|
|||||||
Reference in New Issue
Block a user