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

@@ -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;

View File

@@ -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];

View File

@@ -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`)