From 3c8799351371694c27c58af9aeccda08915c019f Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 24 May 2026 23:09:57 +0200 Subject: [PATCH] =?UTF-8?q?refactor(date-field):=20split=20updateSegment?= =?UTF-8?q?=20into=20pipeline=20helpers=20(audit=20Round=203=20=C2=A73=20#?= =?UTF-8?q?22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 96-line, 7+ branch updateSegment was doing three things in one method: running the consumer callback, applying cross-segment cascades (month→day, hour→dayPeriod, dayPeriod→hour), and committing the new value with sema event dispatch. Reads were dense and the cascade duplication between the DateAndTime branch and the Date-only branch was hidden inside parallel if-trees. Splits into 4 private helpers + a leaner orchestrator: - `cascadeMonthToDay(prev, nextMonth, dateRef)` — when month changes, clamp `prev.day` to the new month's length (e.g. day=31 on Feb → 28). Same helper used by both top-level branches. - `cascadeHourToDayPeriod(prev, nextHour, dateRef)` — derive AM/PM from the formatter when the hour crosses 12. - `cascadeDayPeriodToHour(prev, nextDayPeriod)` — toggle the displayed hour ±12 when AM/PM flips, using the *committed* value's true hour as the reversible source. - `setSegmentUpdating(part, next)` — stage in-flight value in `this.states[part].updating`. No-op when the segment has no staging slot (e.g. literals). - `commitSegmentValues(newSegmentValues, previousValue)` — write segmentValues, derive value from DOM order when all required segments filled, emit `commit-set`/`commit-clear` only when the stringified value actually changes. Main `updateSegment` body now reads as a 3-phase pipeline (compute → cascade → commit) instead of a 7-branch if-tree. Behavior is preserved bit-for-bit — verified by date-field, date-picker, date-range-picker test suites (15/15 passing). Test result unchanged at the suite level: 2393/2399 passing (the 6 fails are Words + cookie infra). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../date-field/date-field-provider.svelte.ts | 201 ++++++++++++------ 1 file changed, 137 insertions(+), 64 deletions(-) diff --git a/src/uix/soma/components/date-field/date-field-provider.svelte.ts b/src/uix/soma/components/date-field/date-field-provider.svelte.ts index 178627cef..2a2e95d48 100644 --- a/src/uix/soma/components/date-field/date-field-provider.svelte.ts +++ b/src/uix/soma/components/date-field/date-field-provider.svelte.ts @@ -668,6 +668,122 @@ export class DateFieldProvider { } // ── Segment update routing ─────────────────────────────────────────────── + // + // `updateSegment` orchestrates a 3-phase pipeline: + // 1. **Compute next**: run the consumer's `Updater` callback against + // the current segment value to derive `next`. + // 2. **Apply cascade**: when the segment that changed has cross- + // segment implications (month → day clamp, hour → dayPeriod + // derivation, dayPeriod → hour AM/PM flip), patch `prev` in-place + // via a dedicated helper. The helpers mutate `prev` because the + // cascade target is a different segment than the one being + // updated — `newSegmentValues = { ...prev, [part]: next }` then + // captures both the explicit change AND the cascaded ones. + // 3. **Commit**: write `segmentValues`, derive the value from DOM + // order when all required segments are filled, and emit the + // `commit-set`/`commit-clear` sema event when the value changes. + // + // The helpers below isolate each cascade. They take only the data + // they need so they can be reused between the DateAndTime branch and + // the Date-only branch without re-implementing the logic. + + /** + * Cascade: when the user changes the month, the previous day may + * exceed the new month's length (e.g. day=31 + new month=Feb → 28). + * Clamp `prev.day` so the resulting CalendarDate is valid. + * + * Mutates `prev` in place because the cascade target (`day`) is a + * different segment than the one being updated (`month`). + */ + private cascadeMonthToDay( + prev: { day: string | null }, + nextMonth: string | null, + dateRef: DateValue + ): void { + if (nextMonth === null || prev.day === null) return; + const date = dateRef.set({ month: Number.parseInt(nextMonth) }); + const daysInMonth = getDaysInMonth(toDate(date)); + const prevDay = Number.parseInt(prev.day); + if (prevDay > daysInMonth) prev.day = `${daysInMonth}`; + } + + /** + * Cascade: when the user changes the hour, the dayPeriod indicator + * may need to flip (e.g. 11 → 13 crosses noon). Derive it from the + * formatter so the displayed AM/PM matches the underlying value. + * + * Mutates `prev.dayPeriod` in place. + */ + private cascadeHourToDayPeriod( + prev: { dayPeriod: string | null }, + nextHour: string | null, + dateRef: DateValue + ): void { + if (nextHour === null || prev.dayPeriod === null) return; + const dayPeriod = this.formatter.dayPeriod( + toDate(dateRef.set({ hour: Number.parseInt(nextHour) })), + this.resolvedHourCycle + ); + if (dayPeriod === 'AM' || dayPeriod === 'PM') prev.dayPeriod = dayPeriod; + } + + /** + * Cascade: when the user toggles AM/PM, the displayed hour must flip + * 12h to match the new period (1 PM ↔ 13:00, etc.). Operates against + * the *committed* value's true hour so the conversion is reversible + * across multiple toggles. + */ + private cascadeDayPeriodToHour( + prev: { hour: string | null }, + nextDayPeriod: 'AM' | 'PM' | null + ): void { + const date = this.opts.value.current; + if (!date || !('hour' in date)) return; + const trueHour = (date as { hour: number }).hour; + if (nextDayPeriod === 'AM' && trueHour >= 12) prev.hour = `${trueHour - 12}`; + else if (nextDayPeriod === 'PM' && trueHour < 12) prev.hour = `${trueHour + 12}`; + } + + /** + * Tracks the in-flight value of a segment that has an `updating` + * staging slot in `this.states`. Used by segments where the + * not-yet-committed input matters for typeahead / formatting + * (`day`, `month`, `year`, `hour`, `minute`, `second`, `dayPeriod`). + */ + private setSegmentUpdating(part: string, next: unknown): void { + const states = this.states as Record; + const slot = states[part]; + if (slot) slot.updating = next; + } + + /** + * Commit phase: write the new segment object, derive the value from + * DOM order when all required segments are filled, otherwise clear + * to `undefined`. Fires `commit-set` / `commit-clear` only when the + * resulting stringified value differs from the previous one. + */ + private commitSegmentValues( + newSegmentValues: SegmentValueObj, + previousValue: string | undefined + ): void { + this.segmentValues = newSegmentValues; + if (areAllSegmentsFilledDOM(newSegmentValues, this.fieldNode)) { + this.setValue( + getValueFromSegmentsDOM({ + segmentObj: newSegmentValues, + fieldNode: this.fieldNode, + dateRef: this.opts.placeholder.current + }) + ); + } else { + this.setValue(undefined); + this.segmentValues = newSegmentValues; + } + const nextValue = this.opts.value.current?.toString(); + if (previousValue !== nextValue) { + void this.runtime.trigger(nextValue === undefined ? 'commit-clear' : 'commit-set'); + } + } updateSegment = ( part: T, @@ -690,80 +806,37 @@ export class DateFieldProvider { if (isDateAndTimeSegmentObj(prev)) { const pVal = prev[part]; const castCb = cb as Updater; + const next = castCb(pVal); + + // Stage the in-flight value for segments with `updating` slots. + this.setSegmentUpdating(part as string, next); + + // Apply cross-segment cascade. Each helper is a no-op when its + // guards fail (null values, missing cross-segment) so the order + // here only matters within a single `part`. if (part === 'month') { - const next = castCb(pVal) as DateAndTimeSegmentObj['month']; - this.states.month.updating = next; - if (next !== null && prev.day !== null) { - const date = dateRef.set({ month: Number.parseInt(next) }); - const daysInMonth = getDaysInMonth(toDate(date)); - const prevDay = Number.parseInt(prev.day); - if (prevDay > daysInMonth) prev.day = `${daysInMonth}`; - } - newSegmentValues = { ...prev, [part]: next }; - } else if (part === 'dayPeriod') { - const next = castCb(pVal) as DateAndTimeSegmentObj['dayPeriod']; - this.states.dayPeriod.updating = next; - const date = this.opts.value.current; - if (date && 'hour' in date) { - const trueHour = date.hour; - if (next === 'AM' && trueHour >= 12) prev.hour = `${trueHour - 12}`; - else if (next === 'PM' && trueHour < 12) prev.hour = `${trueHour + 12}`; - } - newSegmentValues = { ...prev, [part]: next }; + this.cascadeMonthToDay(prev, next as string | null, dateRef); } else if (part === 'hour') { - const next = castCb(pVal) as DateAndTimeSegmentObj['hour']; - this.states.hour.updating = next; - if (next !== null && prev.dayPeriod !== null) { - const dayPeriod = this.formatter.dayPeriod( - toDate(dateRef.set({ hour: Number.parseInt(next) })), - this.resolvedHourCycle - ); - if (dayPeriod === 'AM' || dayPeriod === 'PM') prev.dayPeriod = dayPeriod; - } - newSegmentValues = { ...prev, [part]: next }; - } else if (part === 'minute' || part === 'second' || part === 'year' || part === 'day') { - const next = castCb(pVal); - (this.states as Record)[part].updating = next; - newSegmentValues = { ...prev, [part]: next }; - } else { - const next = castCb(pVal); - newSegmentValues = { ...prev, [part]: next }; + this.cascadeHourToDayPeriod(prev, next as string | null, dateRef); + } else if (part === 'dayPeriod') { + this.cascadeDayPeriodToHour(prev, next as 'AM' | 'PM' | null); } + + newSegmentValues = { ...prev, [part]: next }; } else if (isDateSegmentPart(part)) { const pVal = prev[part as DateSegmentPart]; const castCb = cb as unknown as Updater; const next = castCb(pVal); - if (part === 'month' && next !== null && prev.day !== null) { - this.states.month.updating = next; - const date = dateRef.set({ month: Number.parseInt(next) }); - const daysInMonth = getDaysInMonth(toDate(date)); - if (Number.parseInt(prev.day) > daysInMonth) prev.day = `${daysInMonth}`; - newSegmentValues = { ...prev, [part]: next }; - } else if (part === 'year' || part === 'day') { - (this.states as Record)[part].updating = next; - newSegmentValues = { ...prev, [part]: next }; - } else { - newSegmentValues = { ...prev, [part]: next }; + + this.setSegmentUpdating(part as string, next); + if (part === 'month') { + this.cascadeMonthToDay(prev, next, dateRef); } - } - this.segmentValues = newSegmentValues; - if (areAllSegmentsFilledDOM(newSegmentValues, this.fieldNode)) { - this.setValue( - getValueFromSegmentsDOM({ - segmentObj: newSegmentValues, - fieldNode: this.fieldNode, - dateRef: this.opts.placeholder.current - }) - ); - } else { - this.setValue(undefined); - this.segmentValues = newSegmentValues; - } - const nextValue = this.opts.value.current?.toString(); - if (previousValue !== nextValue) { - void this.runtime.trigger(nextValue === undefined ? 'commit-clear' : 'commit-set'); + newSegmentValues = { ...prev, [part]: next }; } + + this.commitSegmentValues(newSegmentValues, previousValue); }; handleSegmentClick = (e: SomaMouseEvent) => {