refactor(date-field): split updateSegment into pipeline helpers (audit Round 3 §3 #22)

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent c1301fb550
commit 3c87993513

@ -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<string, { updating: unknown } | undefined>;
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 = <T extends keyof DateAndTimeSegmentObj>(
part: T,
@ -690,80 +806,37 @@ export class DateFieldProvider {
if (isDateAndTimeSegmentObj(prev)) {
const pVal = prev[part];
const castCb = cb as Updater<DateAndTimeSegmentObj[T]>;
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;
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 (part === 'minute' || part === 'second' || part === 'year' || part === 'day') {
const next = castCb(pVal);
(this.states as Record<string, { updating: unknown }>)[part].updating = next;
newSegmentValues = { ...prev, [part]: next };
} else {
const next = castCb(pVal);
newSegmentValues = { ...prev, [part]: next };
}
} else if (isDateSegmentPart(part)) {
const pVal = prev[part as DateSegmentPart];
const castCb = cb as unknown as Updater<DateSegmentObj[DateSegmentPart]>;
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<string, { updating: unknown }>)[part].updating = next;
newSegmentValues = { ...prev, [part]: next };
} else {
newSegmentValues = { ...prev, [part]: next };
}
}
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;
this.setSegmentUpdating(part as string, next);
if (part === 'month') {
this.cascadeMonthToDay(prev, next, dateRef);
}
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) => {

Loading…
Cancel
Save

Powered by TurnKey Linux.