Persist the dates the form always collected and always threw away

The creation form has always offered real date and time pickers, and the write
kept only the label derived from them. The user chose a date; the broker never
saw it. Five fields went that way, on create and on update alike, because the
shape defined nowhere for them to land.

`startDate`, `endDate`, `startTime` and `endTime` are now on the Event shape and
carried end to end. `date` is untouched -- it is the label the lists display, and
nothing derives one from the other. `themes` is deliberately left out: it is a
repeated value, so it needs a cardinality decision rather than one more optional
string, and nothing consumes it.

Proven on the running application, both paths, asserted only after a reconnect
because the optimistic overlay shows the values before anything is durable:
written 2026-12-05 / 09:15, read back identical; edited to 2027-01-22 / 08:05,
read back identical. An event missing the fields renders clean -- the time line
simply absent, nothing thrown.

The recorded generator drift no longer reproduces. Regenerating from the
UNCHANGED shape produced bytes identical to the committed bindings, which is how
the additive diff was separated from the generator's own; that separation was the
point of running it twice, and it turned a warning into a measurement. The
doctrine still carries the old warning and is now wrong about it.

Also worth having: the form composes its label from the ISO values joined, not
from the French wording the doctrine gives as its example.
This commit is contained in:
Sylvain Duchesne
2026-08-17 11:30:57 +02:00
parent 32c2302c91
commit 9e4374b678
9 changed files with 122 additions and 0 deletions
@@ -1178,6 +1178,12 @@ function useNgData(): FestipodDataContextValue {
await openDocumentInbox(eventGraph);
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
title: str(event.title), description: str(event.description), date: str(event.date),
// `date` is the human LABEL the form composed; these four are the machine
// values it collected, written UNALTERED as the form's own ISO forms
// (`YYYY-MM-DD`, `HH:MM`). They are stored alongside the label, never
// derived from it — the label is display text and cannot be parsed back.
startDate: str(event.startDate), endDate: str(event.endDate),
startTime: str(event.startTime), endTime: str(event.endTime),
location: str(event.location), distance: flt(event.distance),
// No host notion: the creator merely SIGNALS a public event and is NOT
// obliged to participate, so the count starts at 0 (the owner-materializer
@@ -1231,6 +1237,14 @@ function useNgData(): FestipodDataContextValue {
if (updates.title !== undefined) persists.push(updateEntityField(graph, id, 'title', str(updates.title)));
if (updates.description !== undefined) persists.push(updateEntityField(graph, id, 'description', str(updates.description)));
if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date)));
// The machine dates/times travel with the label, not instead of it. A field
// arriving as `''` (the form's empty date input) CLEARS the triple rather than
// leaving a stale value behind — that is `updateEntityField`'s empty handling,
// and it is the right reading here: the user emptied the input.
if (updates.startDate !== undefined) persists.push(updateEntityField(graph, id, 'startDate', str(updates.startDate)));
if (updates.endDate !== undefined) persists.push(updateEntityField(graph, id, 'endDate', str(updates.endDate)));
if (updates.startTime !== undefined) persists.push(updateEntityField(graph, id, 'startTime', str(updates.startTime)));
if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime)));
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
+9
View File
@@ -37,6 +37,15 @@ export function adaptEvent(s: UnionSubject): FpEventData {
title: one(s, 'title'),
description: one(s, 'description'),
date: one(s, 'date'),
// The four machine values, read back exactly as stored (`YYYY-MM-DD`, `HH:MM`).
// `undefined` — not `''` — when the triple is absent: the app type declares them
// optional and the screens branch on their PRESENCE (`event.startTime && …`), so
// an event written before these fields existed reads as an event without them
// rather than one whose dates are blank strings.
startDate: one(s, 'startDate') || undefined,
endDate: one(s, 'endDate') || undefined,
startTime: one(s, 'startTime') || undefined,
endTime: one(s, 'endTime') || undefined,
location: one(s, 'location'),
distance: s.props[`${FP}distance`] ? num(s, 'distance') : undefined,
participantCount: num(s, 'participantCount'),
@@ -54,6 +54,50 @@ export const festipodShapesSchema = {
iri: "http://festipod.org/date",
readablePredicate: "date",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/startDate",
readablePredicate: "startDate",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/endDate",
readablePredicate: "endDate",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/startTime",
readablePredicate: "startTime",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/endTime",
readablePredicate: "endTime",
},
{
dataTypes: [
{
@@ -40,6 +40,30 @@ export interface Event {
* Original IRI: http://festipod.org/date
*/
date: string;
/**
* The day the event starts, as the ISO calendar date the form collects (YYYY-MM-DD) — stored unaltered, never derived from fp:date
*
* Original IRI: http://festipod.org/startDate
*/
startDate?: string;
/**
* The day the event ends, as an ISO calendar date (YYYY-MM-DD); absent for a single-day event
*
* Original IRI: http://festipod.org/endDate
*/
endDate?: string;
/**
* The time of day the event starts, as the form collects it (HH:MM)
*
* Original IRI: http://festipod.org/startTime
*/
startTime?: string;
/**
* The time of day the event ends (HH:MM)
*
* Original IRI: http://festipod.org/endTime
*/
endTime?: string;
/**
* The location of the event
*
@@ -10,6 +10,14 @@ fp:Event {
// rdfs:comment "A description of the event" ;
fp:date xsd:string
// rdfs:comment "The display date of the event (e.g. 'Lun. 16 - Ven. 20 fév.')" ;
fp:startDate xsd:string ?
// rdfs:comment "The day the event starts, as the ISO calendar date the form collects (YYYY-MM-DD) — stored unaltered, never derived from fp:date" ;
fp:endDate xsd:string ?
// rdfs:comment "The day the event ends, as an ISO calendar date (YYYY-MM-DD); absent for a single-day event" ;
fp:startTime xsd:string ?
// rdfs:comment "The time of day the event starts, as the form collects it (HH:MM)" ;
fp:endTime xsd:string ?
// rdfs:comment "The time of day the event ends (HH:MM)" ;
fp:location xsd:string
// rdfs:comment "The location of the event" ;
fp:distance xsd:float ?