storing the date when a status was set

This commit is contained in:
orangecoding
2026-06-02 21:09:35 +02:00
parent d2978c14db
commit bbebc2a1a2
8 changed files with 133 additions and 24 deletions

View File

@@ -127,7 +127,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`; md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`;
md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`; md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`;
for (const l of listings) { for (const l of listings) {
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status)} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`; md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
} }
md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`; md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
} else { } else {
@@ -156,7 +156,10 @@ export function normalizeGetListing(listing) {
md += `- **Link:** ${listing.link || ''}\n`; md += `- **Link:** ${listing.link || ''}\n`;
md += `- **Image:** ${listing.image_url || ''}\n`; md += `- **Image:** ${listing.image_url || ''}\n`;
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`; md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
md += `- **Status:** ${listing.status || ''}\n`; md += `- **Status:** ${listing.status?.status || ''}\n`;
if (listing.status?.setAt) {
md += `- **Status set at:** ${formatDate(listing.status.setAt)}\n`;
}
md += `- **Created:** ${formatDate(listing.created_at)}\n`; md += `- **Created:** ${formatDate(listing.created_at)}\n`;
md += `- **Job:** ${listing.job_name || ''}\n`; md += `- **Job:** ${listing.job_name || ''}\n`;
if (listing.latitude != null && listing.longitude != null) { if (listing.latitude != null && listing.longitude != null) {

View File

@@ -3,10 +3,27 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { nullOrEmpty } from '../../utils.js'; import { nullOrEmpty, fromJson } from '../../utils.js';
import SqliteConnection from './SqliteConnection.js'; import SqliteConnection from './SqliteConnection.js';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
/**
* Parse the JSON `status` column of a listing row in place.
*
* The DB stores status as a JSON payload `{ status, setAt }` (or NULL).
* Consumers expect an object/null, so we normalize before returning.
*
* @param {Object|null|undefined} row - A raw row from the listings table.
* @returns {Object|null|undefined} The same row with `status` parsed.
*/
const parseListingStatus = (row) => {
if (row == null) return row;
if (typeof row.status === 'string') {
row.status = fromJson(row.status, null);
}
return row;
};
/** /**
* Return a list of known listing hashes for a given job and provider. * Return a list of known listing hashes for a given job and provider.
* Useful to de-duplicate before inserting new listings. * Useful to de-duplicate before inserting new listings.
@@ -319,7 +336,9 @@ export const queryListings = ({
} else if (watchListFilter === false) { } else if (watchListFilter === false) {
whereParts.push('(wl.id IS NULL)'); whereParts.push('(wl.id IS NULL)');
} }
// statusFilter: 'applied'|'rejected'|'accepted' -> equality; 'none' -> NULL // statusFilter: 'applied'|'rejected'|'accepted' -> equality on JSON status field; 'none' -> NULL.
// The status column is a JSON payload `{ status, setAt }`, so we extract the inner
// status string for comparison instead of matching the raw text.
if (statusFilter === 'none') { if (statusFilter === 'none') {
whereParts.push('(l.status IS NULL)'); whereParts.push('(l.status IS NULL)');
} else if ( } else if (
@@ -327,7 +346,7 @@ export const queryListings = ({
['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase()) ['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase())
) { ) {
params.statusValue = statusFilter.toLowerCase(); params.statusValue = statusFilter.toLowerCase();
whereParts.push('(l.status = @statusValue)'); whereParts.push(`(json_extract(l.status, '$.status') = @statusValue)`);
} }
// Time range filters (unix timestamps in milliseconds) // Time range filters (unix timestamps in milliseconds)
if (Number.isFinite(createdAfter) && createdAfter > 0) { if (Number.isFinite(createdAfter) && createdAfter > 0) {
@@ -403,7 +422,7 @@ export const queryListings = ({
params, params,
); );
return { totalNumber, page: safePage, result: rows }; return { totalNumber, page: safePage, result: rows.map(parseListingStatus) };
}; };
/** /**
@@ -638,7 +657,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
if (!isAdmin) { if (!isAdmin) {
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`; whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
} }
return ( return parseListingStatus(
SqliteConnection.query( SqliteConnection.query(
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched `SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l FROM listings l
@@ -646,7 +665,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`, WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
params, params,
)[0] || null )[0] || null,
); );
}; };
@@ -674,6 +693,10 @@ export const setListingNotes = (id, notes) => {
/** /**
* Set or clear the status of a single listing. * Set or clear the status of a single listing.
* *
* The status column stores a JSON payload `{ status, setAt }` so consumers
* can show both the user's decision and when it was made. Passing `null`
* clears the column.
*
* @param {string} id - The listing ID. * @param {string} id - The listing ID.
* @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear. * @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear.
* @returns {number} Number of rows affected (0 if listing not found). * @returns {number} Number of rows affected (0 if listing not found).
@@ -685,9 +708,10 @@ export const setListingStatus = (id, status) => {
if (normalized != null && !allowed.includes(normalized)) { if (normalized != null && !allowed.includes(normalized)) {
throw new Error(`Invalid listing status: ${status}`); throw new Error(`Invalid listing status: ${status}`);
} }
const payload = normalized == null ? null : JSON.stringify({ status: normalized, setAt: Date.now() });
const res = SqliteConnection.execute(`UPDATE listings SET status = @status WHERE id = @id`, { const res = SqliteConnection.execute(`UPDATE listings SET status = @status WHERE id = @id`, {
id, id,
status: normalized, status: payload,
}); });
return res?.changes ?? 0; return res?.changes ?? 0;
}; };

View File

@@ -5,7 +5,7 @@
export function up(db) { export function up(db) {
db.exec(` db.exec(`
ALTER TABLE listings ADD COLUMN status TEXT; ALTER TABLE listings ADD COLUMN status JSON;
CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (status); CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (json_extract(status, '$.status'));
`); `);
} }

View File

@@ -42,15 +42,21 @@ describe('listingsStorage.setListingStatus', () => {
listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
}); });
it('runs an UPDATE with the normalized status and listing id', () => { it('runs an UPDATE storing a JSON payload with status and setAt', () => {
const before = Date.now();
const changes = listingsStorage.setListingStatus('listing-1', 'Applied'); const changes = listingsStorage.setListingStatus('listing-1', 'Applied');
const after = Date.now();
expect(changes).toBe(1); expect(changes).toBe(1);
expect(calls.execute).toHaveLength(1); expect(calls.execute).toHaveLength(1);
expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/); expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/);
expect(calls.execute[0].params).toEqual({ id: 'listing-1', status: 'applied' }); expect(calls.execute[0].params.id).toBe('listing-1');
const parsed = JSON.parse(calls.execute[0].params.status);
expect(parsed.status).toBe('applied');
expect(parsed.setAt).toBeGreaterThanOrEqual(before);
expect(parsed.setAt).toBeLessThanOrEqual(after);
}); });
it('accepts null to clear the status', () => { it('accepts null to clear the status (no JSON wrapping)', () => {
listingsStorage.setListingStatus('listing-2', null); listingsStorage.setListingStatus('listing-2', null);
expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null }); expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null });
}); });
@@ -87,10 +93,10 @@ describe('listingsStorage.queryListings statusFilter', () => {
expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/); expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/);
}); });
it("adds 'l.status = @statusValue' for a concrete status", () => { it('extracts the inner status field via json_extract for a concrete status', () => {
listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true }); listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql)); const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.status = @statusValue\)/); expect(pageQuery.sql).toMatch(/json_extract\(l\.status, '\$\.status'\) = @statusValue/);
expect(pageQuery.params.statusValue).toBe('applied'); expect(pageQuery.params.statusValue).toBe('applied');
}); });
@@ -99,6 +105,49 @@ describe('listingsStorage.queryListings statusFilter', () => {
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql)); const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).not.toMatch(/status/i); expect(pageQuery.sql).not.toMatch(/status/i);
}); });
it('parses the JSON status payload of returned rows into an object', () => {
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 2 }];
return [
{ id: 'a', status: JSON.stringify({ status: 'applied', setAt: 1700000000000 }) },
{ id: 'b', status: null },
];
};
const result = listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
expect(result.result[0].status).toEqual({ status: 'applied', setAt: 1700000000000 });
expect(result.result[1].status).toBeNull();
});
});
describe('listingsStorage.getListingById', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('parses the JSON status payload of the returned row', () => {
sqliteMock.__queryHandler = () => [
{ id: 'a', status: JSON.stringify({ status: 'rejected', setAt: 1700000000001 }) },
];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toEqual({ status: 'rejected', setAt: 1700000000001 });
});
it('returns null status untouched', () => {
sqliteMock.__queryHandler = () => [{ id: 'a', status: null }];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toBeNull();
});
it('returns null when no row is found', () => {
sqliteMock.__queryHandler = () => [];
const row = listingsStorage.getListingById('missing', 'u1', true);
expect(row).toBeNull();
});
}); });
describe('watchListStorage.ensureWatch', () => { describe('watchListStorage.ensureWatch', () => {

View File

@@ -87,7 +87,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}> <div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
<StatusControl <StatusControl
status={item.status ?? null} status={item.status?.status ?? null}
compact compact
onChange={(next) => onStatusChange?.(item, next)} onChange={(next) => onStatusChange?.(item, next)}
onTriggerClick={(e) => e.stopPropagation()} onTriggerClick={(e) => e.stopPropagation()}

View File

@@ -81,7 +81,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}> <div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
<StatusControl <StatusControl
status={item.status ?? null} status={item.status?.status ?? null}
compact compact
onChange={(next) => onStatusChange?.(item, next)} onChange={(next) => onStatusChange?.(item, next)}
onTriggerClick={(e) => e.stopPropagation()} onTriggerClick={(e) => e.stopPropagation()}

View File

@@ -21,6 +21,7 @@ import {
Spin, Spin,
Toast, Toast,
TextArea, TextArea,
Tooltip,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { import {
IconArrowLeft, IconArrowLeft,
@@ -315,35 +316,58 @@ export default function ListingDetail() {
if (!listing) return null; if (!listing) return null;
const statusLabel = listing.status?.status
? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1)
: null;
const data = [ const data = [
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> }, {
key: 'Price',
value: `${listing.price}`,
Icon: <IconCart />,
helpText: 'The asking price of this listing, as reported by the provider.',
},
{ {
key: 'Size', key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A', value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconExpand />, Icon: <IconExpand />,
helpText: 'Living space of the listing in square meters.',
}, },
{ {
key: 'Rooms', key: 'Rooms',
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A', value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
Icon: <IconGridView />, Icon: <IconGridView />,
helpText: 'Number of rooms in the listing.',
}, },
{ {
key: 'Job', key: 'Job',
value: listing.job_name, value: listing.job_name,
Icon: <IconBriefcase />, Icon: <IconBriefcase />,
helpText: 'The Fredy job that found this listing.',
}, },
{ {
key: 'Provider', key: 'Provider',
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown', value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: <IconBriefcase />, Icon: <IconBriefcase />,
helpText: 'The real estate portal where this listing was scraped from.',
}, },
{ {
key: 'Added', key: 'Added',
value: timeService.format(listing.created_at), value: timeService.format(listing.created_at),
Icon: <IconClock />, Icon: <IconClock />,
helpText: 'When Fredy first added this listing to your database.',
}, },
]; ];
if (statusLabel) {
data.push({
key: 'Status',
value: listing.status?.setAt ? `${statusLabel} (set ${timeService.format(listing.status.setAt)})` : statusLabel,
Icon: <IconActivity />,
helpText: 'The status you marked for this listing and when you set it.',
});
}
return ( return (
<div className="listing-detail"> <div className="listing-detail">
<Headline <Headline
@@ -381,7 +405,7 @@ export default function ListingDetail() {
> >
{listing.isWatched === 1 ? 'Watched' : 'Watch'} {listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button> </Button>
<StatusControl status={listing.status ?? null} onChange={handleStatusChange} /> <StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn"> <a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
<IconLink style={{ marginRight: 6 }} /> <IconLink style={{ marginRight: 6 }} />
Open listing Open listing
@@ -450,10 +474,12 @@ export default function ListingDetail() {
<Descriptions column={1}> <Descriptions column={1}>
{data.map((item, index) => ( {data.map((item, index) => (
<Descriptions.Item key={index}> <Descriptions.Item key={index}>
<Space> <Tooltip content={item.helpText} position="left">
{item.Icon} <span className="listing-detail__details-item">
{item.value} {item.Icon}
</Space> {item.value}
</span>
</Tooltip>
</Descriptions.Item> </Descriptions.Item>
))} ))}
</Descriptions> </Descriptions>

View File

@@ -171,6 +171,13 @@
padding: 1.5rem; padding: 1.5rem;
} }
&__details-item {
cursor: help;
display: inline-flex;
align-items: center;
gap: 8px;
}
&__map-container { &__map-container {
height: 400px; height: 400px;
width: 100%; width: 100%;