diff --git a/API.md b/API.md index b7d355f..fc8de52 100644 --- a/API.md +++ b/API.md @@ -232,7 +232,7 @@ depends on the type of result: | Type | `sortDate` is | |------|---------------| | Meeting | The meeting's start, as scheduled by the organization | -| Document | The start of the meeting at which the document was (last) discussed. A document that hangs off no meeting, such as a register entry (ingekomen stukken, raadsvragen), gets the date the source system last modified it | +| Document | The start of the meeting at which the document was (last) discussed. A register document (ingekomen stukken, raadsvragen, toezeggingen) gets the date of its register entry, the day the questions were asked; the entity detail also carries `dateModified`, the day the source system last changed the entry, which for questions is the day the answer was added | | Motion | The meeting at which the motion was last discussed, else the date the source system gives it | | Recording | The start of the meeting it records | diff --git a/src/ibabs/normalize.ts b/src/ibabs/normalize.ts index 8c0ed81..4095bdd 100644 --- a/src/ibabs/normalize.ts +++ b/src/ibabs/normalize.ts @@ -318,6 +318,12 @@ export function normalizeIbabsRegisterDocuments( const reference = parseAgendaPointReference(pickValue(values, ["Agendapunt"])); const meeting = reference && meetings ? meetings.find(reference) : undefined; const sourceIri = `ibabs://${source.ibabsSitename}/listentry/${entry.EntryId}`; + // A register entry has one date, the day it was entered: the day the + // questions were asked, for a raadsvragen entry. The answer is added to the + // same entry weeks later, and iBabs dates documents no further than that. + // The entry's last modification is the only trace of when the answer + // arrived, so every document carries it as date_modified (#294). + const dateModified = normalizeMutationDate(entry.MutationDate); return detail.Documents.map((document) => ({ id: canonicalDocumentId(source, document.Id), @@ -328,6 +334,7 @@ export function normalizeIbabsRegisterDocuments( identifier_url: `ibabs://${source.ibabsSitename}/document/${document.Id}`, file_name: document.FileName, size_in_bytes: document.FileSize, + date_modified: dateModified, last_discussed_at: meeting?.start_date ?? date, is_referenced_by: meeting?.id ?? canonicalOrganizationId(source), organization: canonicalOrganizationId(source), @@ -395,3 +402,15 @@ export function normalizeIbabsDocuments( raw: document, })); } + +/** iBabs writes MutationDate as a local timestamp without a zone + * (`2026-03-25T12:52:40.897`). Kept to the day, at noon UTC, so the date + * survives any zone a reader is in. */ +function normalizeMutationDate(value?: string): string | undefined { + const match = /^(\d{4}-\d{2}-\d{2})/.exec(value ?? ""); + if (!match) { + return undefined; + } + const parsed = new Date(`${match[1]}T12:00:00Z`); + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +} diff --git a/src/types.ts b/src/types.ts index 9e97fad..9869fc6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -808,6 +808,11 @@ export interface EntityContentResponse { organization?: string; date?: string; sortDate?: string; + /** When the source system last changed this entity, formatted, when it + * says so. For an iBabs register entry: the day its last document, usually + * the answer, was added. */ + dateModified?: string; + dateModifiedIso?: string; markdownText?: string; downloadUrl?: string; contentType?: string; diff --git a/tests/registers.test.ts b/tests/registers.test.ts index 6b63f9e..310131b 100644 --- a/tests/registers.test.ts +++ b/tests/registers.test.ts @@ -318,3 +318,49 @@ Deno.test("iBabs register entries reach the document path without a vote lookup" "the failed download is reported against the document", ); }); + +Deno.test("a register document carries the entry's last modification as date_modified", async () => { + // #294: raadsvragen are dated the day they were asked; the answer arrives + // in the same entry weeks later, and only the entry's MutationDate says when. + const { normalizeIbabsRegisterDocuments } = await import("../src/ibabs/normalize.ts"); + const { getSource } = await import("../src/sources/index.ts"); + const source = getSource("harderwijk"); + const list = { ListId: "L1", ListName: "Schriftelijke vragen en antwoorden" }; + const entry = { EntryId: "E1", MutationDate: "2026-03-25T12:52:40.897" }; + const detail = { + EntryId: "E1", + Values: { Datum: "Feb 28 2026 12:00AM", Onderwerp: "Digitale archief Vervolg" }, + Documents: [ + { + Id: "D1", + FileName: "vragen.pdf", + DisplayName: "Schriftelijke vragen", + PublicDownloadURL: "https://x/1", + }, + { + Id: "D2", + FileName: "antwoord.pdf", + DisplayName: "Beantwoording", + PublicDownloadURL: "https://x/2", + }, + ], + }; + // deno-lint-ignore no-explicit-any + const docs = normalizeIbabsRegisterDocuments( + source as any, + list as any, + entry as any, + detail as any, + ); + assert(docs.length === 2, "both documents of the entry"); + for (const doc of docs) { + assert( + doc.last_discussed_at?.startsWith("2026-02-28"), + `the entry date stays the date, got ${doc.last_discussed_at}`, + ); + assert( + doc.date_modified === "2026-03-25T12:00:00.000Z", + `the modification is the day the answer came, got ${doc.date_modified}`, + ); + } +}); diff --git a/web/search_api.ts b/web/search_api.ts index 7fbca16..8ad9258 100644 --- a/web/search_api.ts +++ b/web/search_api.ts @@ -61,6 +61,7 @@ type SearchHit = { chapters?: MeetingRecording["chapters"]; speakers?: MeetingRecording["speakers"]; is_referenced_by?: string; + date_modified?: string; agenda?: MeetingAgendaItem[]; motion_type?: string; status?: string; @@ -1589,6 +1590,11 @@ export async function getEntityContent( organization: displayOrganization(hit), date: formatDate(hit.start_date), sortDate: hit.start_date, + // When the source system last changed the entity, where it says so. For a + // register entry that is the day the answer was added (#294); the date + // above stays the day the entry was made, which is what people search by. + dateModified: hit.payload?.date_modified ? formatDate(hit.payload.date_modified) : undefined, + dateModifiedIso: hit.payload?.date_modified, // Fall back to the attachment's text and file for a motion; its own // fields are always empty. markdownText: markdownText ?? motionAttachment?.markdownText, diff --git a/web/src/App.svelte b/web/src/App.svelte index 85ee5e9..acf4c7c 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -2389,6 +2389,11 @@ {detailItem.organization} {detailItem.entityTypeLabel} {detailItem.date} + {#if detailContent?.dateModified && detailContent.dateModifiedIso?.slice(0, 10) !== detailContent.sortDate?.slice(0, 10)} + + bijgewerkt {detailContent.dateModified} + + {/if}