@@ -28,6 +28,7 @@ import {
2828} from './walk.js';
2929import { resolveDriverId, type BuiltinDriverId } from '../data/driver/config-registry.zod.js';
3030import { RETIRED_SUB_DAY_INTERVALS } from '../data/analytics.zod.js';
31+ import { ClockTimeValueSchema } from '../data/field-value.zod.js';
3132import {
3233 FILTER_ARRAY_LOGIC_KEYWORDS,
3334 FILTER_OPERATORS,
@@ -7341,6 +7342,152 @@ const translationPerAppSettingsRemoved: MetadataConversion = {
73417342 },
73427343};
73437344
7345+ /**
7346+ * A `time` literal default drops a `Z` or zero-offset suffix (protocol 18).
7347+ *
7348+ * A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the stored form
7349+ * (`ClockTimeValueSchema`) no longer admits a zone. A `Z` or a zero offset
7350+ * (`+00:00`, `+0000`, `-00:00`, `-0000`) names the same wall clock without it,
7351+ * so the suffix is dropped. A non-zero offset is left as stored and reported as
7352+ * a TODO: whether it meant its own digits or the UTC time is the author's call,
7353+ * and the parse refuses it where it lands. Only the suffixes the old stored
7354+ * form admitted are recognised, and `ClockTimeValueSchema` judges the rest.
7355+ *
7356+ * Reach: the literal defaults the narrowed gates judge — a `time` field's
7357+ * (`objects[]`, `objectExtensions[]`) and an action param typed `time`
7358+ * (`actions[]`, `objects[].actions[]`, an `element:button`'s inline action).
7359+ * Retired from the load path: an author is refused at parse, and data at rest
7360+ * and `os migrate meta` replay it.
7361+ */
7362+ const timeDefaultUtcSuffixDropped: MetadataConversion = {
7363+ id: 'time-default-utc-suffix-dropped',
7364+ toMajor: 18,
7365+ retiredFromLoadPath: true,
7366+ retiredAfter: '17.5.0',
7367+ surface:
7368+ 'object.fields.*.defaultValue / action.params[].defaultValue / '
7369+ + 'page.component.element:button.action.params[].defaultValue (type time)',
7370+ summary:
7371+ "a `time` literal default's `Z` or zero-offset suffix is dropped, which names the same wall "
7372+ + 'clock; a default with a non-zero offset is left as stored and reported as a TODO, because '
7373+ + 'a `time` value carries no zone (ADR-0053 D-C1) and only its author knows which wall clock '
7374+ + 'it meant',
7375+ apply(stack, emit, context) {
7376+ const convert = (holder: Dict, path: string, subject: string): Dict => {
7377+ const value = holder.defaultValue;
7378+ if (typeof value !== 'string') return holder;
7379+ const suffix = /(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/.exec(value);
7380+ if (!suffix) return holder;
7381+ const wallClock = value.slice(0, suffix.index);
7382+ if (!ClockTimeValueSchema.safeParse(wallClock).success) return holder;
7383+ const at = `${path}.defaultValue`;
7384+ if (/[1-9]/.test(suffix[0])) {
7385+ context?.reportTodo?.({
7386+ path: at,
7387+ from: JSON.stringify(value),
7388+ reason: `${subject} is a time with no time zone, and this default carries a non-zero UTC `
7389+ + 'offset. Left as stored, it is refused where it is parsed. Rewrite it by hand as the wall '
7390+ + 'clock it meant, HH:MM or HH:MM:SS with no zone, or use a datetime field for an instant.',
7391+ });
7392+ return holder;
7393+ }
7394+ emit({ from: JSON.stringify(value), to: JSON.stringify(wallClock), path: at });
7395+ return { ...holder, defaultValue: wallClock };
7396+ };
7397+ const params = (action: Dict, path: string): Dict => {
7398+ const list = action.params;
7399+ if (!Array.isArray(list)) return action;
7400+ let changed = false;
7401+ const next = list.map((p, i) => {
7402+ if (!isDict(p) || p.type !== 'time') return p;
7403+ const name = typeof p.name === 'string' ? p.name : String(p.field ?? '');
7404+ const mapped = convert(p, `${path}.params[${i}]`, `Action param "${name}"`);
7405+ if (mapped !== p) changed = true;
7406+ return mapped;
7407+ });
7408+ return changed ? { ...action, params: next } : action;
7409+ };
7410+ const fields = (owner: Dict, path: string): Dict => {
7411+ const map = owner.fields;
7412+ if (!isDict(map)) return owner;
7413+ let changed = false;
7414+ const next: Dict = {};
7415+ for (const [name, def] of Object.entries(map)) {
7416+ next[name] = isDict(def) && def.type === 'time'
7417+ ? convert(def, `${path}.fields.${name}`, `Field "${name}"`)
7418+ : def;
7419+ if (next[name] !== def) changed = true;
7420+ }
7421+ return changed ? { ...owner, fields: next } : owner;
7422+ };
7423+ let next = mapCollection(stack, 'objects', (obj, path) =>
7424+ mapCollection(fields(obj, path), 'actions', (action, actionPath) => params(action, `${path}.${actionPath}`)));
7425+ next = mapCollection(next, 'objectExtensions', fields);
7426+ next = mapCollection(next, 'actions', params);
7427+ return mapPageComponents(next, (component, path) => {
7428+ if (component.type !== 'element:button') return component;
7429+ const properties = component.properties;
7430+ if (!isDict(properties) || !isDict(properties.action)) return component;
7431+ const action = params(properties.action, `${path}.properties.action`);
7432+ return action === properties.action ? component : { ...component, properties: { ...properties, action } };
7433+ });
7434+ },
7435+ fixture: {
7436+ before: {
7437+ objects: [{
7438+ name: 'shift',
7439+ fields: {
7440+ starts_at: { type: 'time', defaultValue: '09:00Z' },
7441+ ends_at: { type: 'time', defaultValue: '17:30:00+00:00' },
7442+ // A non-zero offset: left as stored (a TODO, not a notice).
7443+ handover_at: { type: 'time', defaultValue: '08:00+08:00' },
7444+ label: { type: 'text', defaultValue: '09:00Z' },
7445+ },
7446+ actions: [{ name: 'reschedule', params: [{ name: 'at', type: 'time', defaultValue: '10:00-0000' }] }],
7447+ }],
7448+ objectExtensions: [{ extend: 'shift', fields: { breaks_at: { type: 'time', defaultValue: '12:00Z' } } }],
7449+ actions: [{ name: 'clock_in', params: [{ name: 'at', type: 'time', defaultValue: '07:45:00.500Z' }] }],
7450+ pages: [{
7451+ name: 'shift_board',
7452+ regions: [{
7453+ name: 'main',
7454+ components: [{
7455+ type: 'element:button',
7456+ properties: { action: { type: 'script', target: 'clockOut', params: [{ name: 'at', type: 'time', defaultValue: '18:00Z' }] } },
7457+ }],
7458+ }],
7459+ }],
7460+ },
7461+ after: {
7462+ objects: [{
7463+ name: 'shift',
7464+ fields: {
7465+ starts_at: { type: 'time', defaultValue: '09:00' },
7466+ ends_at: { type: 'time', defaultValue: '17:30:00' },
7467+ handover_at: { type: 'time', defaultValue: '08:00+08:00' },
7468+ label: { type: 'text', defaultValue: '09:00Z' },
7469+ },
7470+ actions: [{ name: 'reschedule', params: [{ name: 'at', type: 'time', defaultValue: '10:00' }] }],
7471+ }],
7472+ objectExtensions: [{ extend: 'shift', fields: { breaks_at: { type: 'time', defaultValue: '12:00' } } }],
7473+ actions: [{ name: 'clock_in', params: [{ name: 'at', type: 'time', defaultValue: '07:45:00.500' }] }],
7474+ pages: [{
7475+ name: 'shift_board',
7476+ regions: [{
7477+ name: 'main',
7478+ components: [{
7479+ type: 'element:button',
7480+ properties: { action: { type: 'script', target: 'clockOut', params: [{ name: 'at', type: 'time', defaultValue: '18:00' }] } },
7481+ }],
7482+ }],
7483+ }],
7484+ },
7485+ // One per dropped suffix: two object fields, the object-nested action, the
7486+ // extension field, the stack action and the inline action.
7487+ expectedNotices: 6,
7488+ },
7489+ };
7490+
73447491/**
73457492 * `translation.pages.<name>.components.<id>.submitLabel` — the component-copy
73467493 * key retired with its only declarer (protocol 18, commit d173125fb, ADR-0049).
@@ -12712,6 +12859,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
1271212859 { conversion: recordChatterPositionVocabulary, order: 2 },
1271312860 { conversion: recordHighlightsFieldIconRemoved, order: 10 },
1271412861 { conversion: reportJoinedChartRemoved, order: 38 },
12862+ { conversion: timeDefaultUtcSuffixDropped, order: 48 },
1271512863 { conversion: translationComponentSubmitLabelRemoved, order: 12 },
1271612864 { conversion: translationPerAppSettingsRemoved, order: 34 },
1271712865 { conversion: tursoConfigTimeoutToTimeoutMs, order: 28 },
0 commit comments