diff --git a/TRY-IT.md b/TRY-IT.md index 506f06b..79b3448 100644 --- a/TRY-IT.md +++ b/TRY-IT.md @@ -175,13 +175,27 @@ lists the candidates. Validation (required, type, length, range, pattern) runs l same codes and messages the server would give. Form adjusters (on-load and on-change rules an application declares) are called when the server has them; this sample app has none. +DATE_TIME values are typed and shown in your zone (the `TZ` environment) and sent as UTC instants, as the web +UI does; `--utc` shows the raw instants. A time skipped by a spring-forward change moves forward by the gap; a +time repeated by a fall-back change means its first occurrence. In filters, a date alone on a DATE_TIME field +means local midnight. Fields the server formats in a declared zone (fixed or per-record) keep its text. + +```sh +TZ=America/Chicago qqq table create fieldLab --set name=tz --set dateTimeValue=2026-10-31T14:30 # stored 19:30Z +qqq table get fieldLab 4 # Date Time Value 2026-10-31 14:30:00 CDT +qqq --utc table get fieldLab 4 # Date Time Value 2026-10-31T19:30:00Z +TZ=Asia/Tokyo qqq table get fieldLab 4 # 2026-11-01 04:30:00 GMT+9 +qqq table query fieldLab -w "dateTimeValue BETWEEN 2026-10-31,2026-10-31T23:59" --print-filter +qqq process run fieldLab.bulkEdit -r 4 -v bulkEditEnabledFields=dateTimeValue -v dateTimeValue=2026-10-31T09:15 -v name=tz --validate +``` + Filter values can be expressions: `now()`, `now(-7d)`, `thisPeriod(month)`, `lastPeriod(week)`, `startOf(quarter,-1)`, `me()`, `${variable.x}`; quote values with commas: `city IN "St. Louis, MO",Chicago`. Where M1 stands: `docs/parity/feature-matrix.md` has the CLI status of every Next UI acceptance row. Not yet in the CLI: bulk load (the sample server cannot accept uploads, see `docs/server-findings.md` #10), saved views, column statistics, joins, variants, audits, jobs on a durable-jobs server, -TABLE_BASED sign-in, DATE_TIME local-to-UTC conversion. +TABLE_BASED sign-in. ## Reporting what you find diff --git a/docs/parity/feature-matrix.md b/docs/parity/feature-matrix.md index 2193dc6..f2f8399 100644 --- a/docs/parity/feature-matrix.md +++ b/docs/parity/feature-matrix.md @@ -12,15 +12,15 @@ The TUI column fills in during M3. | HLP | 6 | 1 | 0 | 4 | 1 | | INT | 14 | 5 | 0 | 0 | 9 | | NAV | 40 | 9 | 3 | 1 | 27 | -| PRC | 60 | 34 | 12 | 2 | 12 | +| PRC | 60 | 35 | 11 | 2 | 12 | | PRF | 5 | 4 | 1 | 0 | 0 | -| QRY | 70 | 27 | 20 | 0 | 23 | -| REC | 65 | 28 | 25 | 0 | 12 | +| QRY | 70 | 28 | 19 | 0 | 23 | +| REC | 65 | 30 | 23 | 0 | 12 | | REL | 9 | 4 | 5 | 0 | 0 | | RPT | 20 | 1 | 19 | 0 | 0 | | SEC | 43 | 17 | 14 | 0 | 12 | | WID | 69 | 29 | 27 | 0 | 13 | -| **all** | 401 | 159 | 126 | 7 | 109 | +| **all** | 401 | 163 | 122 | 7 | 109 | ## Rows @@ -128,7 +128,7 @@ The TUI column fills in during M3. | PRC-061 | Typed bulk load default values, searchable value mapping and the grouped, searchable Add Fields menu (Material | todo | todo | | | PRC-062 | Composite INPUT_FIELD metadata editors share process validation and multipart submission | n/a | todo | composite editors | | PRC-063 | INPUT_FIELD autofocus and trimmed Enter values on subsequent process screens | n/a | todo | | -| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | todo | todo | DATE_TIME local to UTC | +| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | pass | todo | process values typed local, sent UTC; screens show the local zone | | PRC-065 | Configured Google Drive shared-folder selection and process submission | n/a | n/a | Google Drive | | PRC-066 | INPUT_FIELD case behaviors preserve caret and submitted process values | pass | todo | field behaviors applied before submit | | QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | n/a | todo | saved views and column UI | @@ -148,7 +148,7 @@ The TUI column fills in during M3. | QRY-010 | Text operators match Material labels and backend semantics | pass | todo | all operators and shorthand | | QRY-011 | Number operators including ranges and lists | pass | todo | BETWEEN, IN lists | | QRY-012 | Date operators and relative date expressions | pass | todo | now(), now(-7d), thisPeriod(), startOf() | -| QRY-013 | Date-time operators convert local input to UTC and support relative values | todo | todo | DATE_TIME local to UTC conversion | +| QRY-013 | Date-time operators convert local input to UTC and support relative values | pass | todo | --where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before | | QRY-014 | Boolean, blob and long-text operators | pass | todo | IS_BLANK, TRUE, FALSE | | QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | todo | todo | filters take ids; labels in filters todo | | QRY-016 | AND/OR and nested groups combine as declared | pass | todo | --group with parentheses | @@ -227,7 +227,7 @@ The TUI column fills in during M3. | REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | todo | | | REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | todo | defaults applied | | REC-017 | DATE and TIME editors round-trip values and clearing | pass | todo | | -| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | todo | todo | DATE_TIME local to UTC | +| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | todo | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested | | REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | n/a | todo | display zones | | REC-020 | HTML field edits in the rich-text editor and renders sanitized | todo | todo | raw HTML; $EDITOR todo | | REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | todo | server masks | @@ -274,7 +274,7 @@ The TUI column fills in during M3. | REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | todo | todo | adjusters wired, no sample to test | | REC-064 | Record sections render qualified fields from readable exposed joins | todo | todo | joins | | REC-063 | Record view, edit and copy load from the table variant selected on the query screen | todo | todo | variants | -| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | todo | todo | | +| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | todo | an unchanged wall-clock time keeps the original instant and its precision | | SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend ref | pass | todo | | | SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denia | todo | todo | disabled tables listed with a reason | | SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | pass | todo | | diff --git a/packages/cli/src/commands/process.ts b/packages/cli/src/commands/process.ts index 926df3e..ce36d1d 100644 --- a/packages/cli/src/commands/process.ts +++ b/packages/cli/src/commands/process.ts @@ -7,7 +7,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { basename, join } from 'node:path' import type { Command } from 'commander' import { UiApiError } from '@qrun-io/qqq-ui-api-client' -import { BACK, alternativeNames, safeDownloadName, FormInputError, FormSession, ProcessCancelledError, ProcessError, blocksToText, checkInputRecords, downloadProcessFile, formatTable, helpText, isMissingValue, processHelpText, runProcess, screenToText, summaryLines, type FieldMeta, type ProcessMeta, type RunOutcome, type ScreenBlock } from '@qrun-io/qqq-core' +import { BACK, displayDateTime, formatValue, alternativeNames, safeDownloadName, FormInputError, FormSession, ProcessCancelledError, ProcessError, blocksToText, checkInputRecords, downloadProcessFile, formatTable, helpText, isMissingValue, processHelpText, runProcess, screenToText, summaryLines, type FieldMeta, type ProcessMeta, type RunOutcome, type ScreenBlock } from '@qrun-io/qqq-core' import { UsageError, type Context } from '../context.js' import { ask, promptForm } from '../prompt.js' import { authenticatedFetch } from '../session.js' @@ -122,7 +122,7 @@ export function registerProcess(program: Command, ctx: () => Context): void { stepTimeoutMillis: Number(options.timeout), always: Boolean(options.interactive), signal: abort.signal, - screen: { recordLimit: Number(options.recordLimit) }, + screen: { recordLimit: Number(options.recordLimit), format: (field, value, shown) => (field.type === 'DATE_TIME' ? displayDateTime(value, shown, c.dateTimeDisplay) : formatValue(field, value)) }, onProgress: (e) => { if (options.quiet) return if (e.phase === 'init') c.note(`${meta.label ?? name}: starting`) diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 07b8f19..08735f6 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -4,6 +4,7 @@ */ import { readFileSync } from 'node:fs' +import { displayDateTime, type DisplayOptions } from '@qrun-io/qqq-core' import { UsageError } from '../context.js' export type Json = Record @@ -29,11 +30,15 @@ export function readJson(source: string, flag: string): unknown { } } -/** A record's value as text: its display value when the server sent one. */ -export function valueText(record: Json, field: string): string { - const display = (record.displayValues as Json | undefined)?.[field] +/** + * A record's value as text: its display value when the server sent one. With the table's + * metadata, DATE_TIME values are shown in the local zone (or raw with `display.utc`). + */ +export function valueText(record: Json, field: string, meta?: Json, display?: DisplayOptions): string { + const shown = (record.displayValues as Json | undefined)?.[field] const raw = (record.values as Json | undefined)?.[field] - const value = display ?? raw + if ((((meta?.fields ?? {}) as Record)[field])?.type === 'DATE_TIME') return displayDateTime(raw, shown, display) + const value = shown ?? raw if (value === null || value === undefined) return '' return typeof value === 'object' ? JSON.stringify(value) : String(value) } diff --git a/packages/cli/src/commands/table.ts b/packages/cli/src/commands/table.ts index bbe3b82..69dab9f 100644 --- a/packages/cli/src/commands/table.ts +++ b/packages/cli/src/commands/table.ts @@ -5,7 +5,7 @@ import { writeFileSync } from 'node:fs' import type { Command } from 'commander' -import { FormInputError, FormSession, buildFilter, combineWithQuickSearch, copyRecordValues, findRecordByKey, gotoKeys, isGotoKey, printFilter, quickSearchFields, quickSearchFilter, type FieldMeta, type QueryFilter } from '@qrun-io/qqq-core' +import { FormInputError, FormSession, buildFilter, combineWithQuickSearch, copyRecordValues, filterDateTimesToUtc, findRecordByKey, gotoKeys, isGotoKey, printFilter, quickSearchFields, quickSearchFilter, type FieldMeta, type QueryFilter } from '@qrun-io/qqq-core' import { UiApiError } from '@qrun-io/qqq-ui-api-client' import { promptForm } from '../prompt.js' import { authenticatedFetch } from '../session.js' @@ -31,8 +31,9 @@ async function tableMeta(c: Context, table: string): Promise { } function filterFrom(options: QueryOptions, meta: Json, columns?: string[]): QueryFilter { + // --where / --group values on DATE_TIME fields are local times (or dates: local midnight), sent as UTC instants; --filter-json is sent as given const filter = options.filterJson ? readJson(options.filterJson, '--filter-json') as QueryFilter - : buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: Object.keys((meta.fields ?? {}) as Json) }) + : filterDateTimesToUtc(buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: Object.keys((meta.fields ?? {}) as Json) }), (meta.fields ?? {}) as Record) if (options.search === undefined) return filter // quick search as the web UI does it: CONTAINS on each shown string field, ORed, ANDed onto the filter const fields = quickSearchFields(meta, columns) @@ -126,7 +127,7 @@ export function registerTable(program: Command, ctx: () => Context): void { skip += limit } const fields = columnsFrom(c, options) ?? defaultFields(meta) - const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f)]))) + const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f, meta, c.dateTimeDisplay)]))) c.print({ data: records, rows, columns: fields, headers: labelsFor(meta, fields), footer: `(${records.length} record${records.length === 1 ? '' : 's'}${filter.criteria?.length || filter.subFilters?.length ? `; filter: ${printFilter(filter)}` : ''}${!options.all && records.length === limit ? '; more may exist: --limit, --skip or --all' : ''})` }) }) @@ -167,7 +168,7 @@ export function registerTable(program: Command, ctx: () => Context): void { const result = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { includeAssociations: options.associations ? true : undefined } as never } })) as Json const record = (result.record ?? result) as Json const fieldMeta = (meta.fields ?? {}) as Record - const rows = defaultFields(meta, 1000).map((f) => ({ field: String(fieldMeta[f]?.label ?? f), value: valueText(record, f) })) + const rows = defaultFields(meta, 1000).map((f) => ({ field: String(fieldMeta[f]?.label ?? f), value: valueText(record, f, meta, c.dateTimeDisplay) })) c.print({ data: record, rows, columns: ['field', 'value'], headers: ['Field', 'Value'], footer: String(record.recordLabel ?? `${meta.label} ${primaryKey}`) }) }) @@ -234,7 +235,7 @@ export function registerTable(program: Command, ctx: () => Context): void { const copied = copyRecordValues(meta, sourceRecord) const given = parsePairs(options.set, '--set') const session = await fillForm(c, meta, name, given, { interactive: false }, copied, [String(meta.primaryKeyField)]) - for (const [field, label] of Object.entries((sourceRecord.displayValues ?? {}) as Json)) if (copied[field] !== undefined && given[field] === undefined && session.values[field] === copied[field]) session.displayValues[field] = String(label) + for (const [field, label] of Object.entries((sourceRecord.displayValues ?? {}) as Json)) if (((meta.fields as Record)[field])?.type !== 'DATE_TIME' && copied[field] !== undefined && given[field] === undefined && session.values[field] === copied[field]) session.displayValues[field] = String(label) if (options.interactive) await promptForm(session, { skipSet: false }) const problems = session.validate() if (Object.keys(problems).length) validationError(session, problems) diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 6797d14..86ebcf1 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -10,7 +10,7 @@ import type { Command } from 'commander' import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client' -import { FilterSyntaxError, OUTPUT_FORMATS, ProcessError, applyQuery, callOperation, defaultFormat, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core' +import { FilterSyntaxError, OUTPUT_FORMATS, ProcessError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core' import { authenticatedFetch, clientFor, selectTarget, target, type Target } from './session.js' /** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */ @@ -26,6 +26,7 @@ export interface GlobalOptions { yes?: boolean trace?: boolean noPager?: boolean + utc?: boolean } export class Context { @@ -63,6 +64,11 @@ export class Context { return defaultFormat(Boolean(process.stdout.isTTY)) } + /** How DATE_TIME values are shown: in the TZ zone, or raw UTC instants with --utc. */ + get dateTimeDisplay(): DisplayOptions { + return { utc: Boolean(this.options.utc), timeZone: defaultTimeZone() } + } + /** `--fields a,b,c` as a list, or undefined. */ get fields(): string[] | undefined { return this.options.fields ? this.options.fields.split(',').map((f) => f.trim()).filter(Boolean) : undefined diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index ca7b8ce..88816bf 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -38,6 +38,7 @@ program .option('-Q, --query ', 'reshape the output with a JMESPath expression, as `aws --query`') .option('--fields ', 'comma-separated columns to show in table, text and csv output') .option('-y, --yes', 'answer yes to confirmations (delete, cancel)') + .option('--utc', 'show DATE_TIME values as raw UTC instants instead of in the local zone (TZ)') .option('--trace', 'print stack traces for unexpected errors') .exitOverride() .configureOutput({ writeErr: (text) => process.stderr.write(text) }) diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index 71ad64d..a5ef3d0 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -73,7 +73,7 @@ async function promptField(rl: Interface, out: NodeJS.WritableStream, session: F return } const current = state.displayValue ?? (isMissingValue(state.value) ? '' : String(state.value)) - let hint = field.type.toLowerCase().replace('_', ' ') + let hint = field.type === 'DATE_TIME' ? `local date time YYYY-MM-DDTHH:MM, ${session.timeZone}` : field.type.toLowerCase().replace('_', ' ') if (session.hasChoices(field.name)) { const choices = await session.options(field.name, '', 12) if (choices.length) { diff --git a/packages/core/README.md b/packages/core/README.md index 246bf59..7624d49 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,6 +11,7 @@ what it produces; the TUI draws it. | `forms/fields` | field metadata helpers: contingent-field dependencies and the DISABLE_UNTIL_SET rule, behaviors, validation that mirrors the server's rules | | `forms/possible-values` | possible-value search with dependency values; resolution of typed text by id, exact label or unique partial | | `forms/adjuster` | form adjusters (`onLoad`, `onChange`; 404 means none) and how their output is applied | +| `forms/datetime` | DATE_TIME between people and the server: local wall-clock text to UTC instants in a zone (DST gaps forward, repeated hours first), instants shown in the zone, server zoned display kept, filter criteria converted | | `forms/form-session` | one form being filled: values, display values, clearing dependents, adjusters, validation, submission | | `process/runner` | init, step until complete, poll asynchronous jobs, apply metadata adjustments, ask a provider for each step's input, go back (`BACK`), cancel on an abort signal (`POST …/cancel`), check min/max input records | | `process/screen` | each step's components as neutral screen blocks: help, HTML as text, view pairs, process records (paged), validation review, summary lines, downloads (`GET /download`), widgets | diff --git a/packages/core/src/forms/datetime.ts b/packages/core/src/forms/datetime.ts new file mode 100644 index 0000000..9069525 --- /dev/null +++ b/packages/core/src/forms/datetime.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2026 QRun.IO, Inc. + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +/** + * DATE_TIME values between people and the server. The server stores and sends UTC instants + * (`2026-10-31T19:30:00Z`); people type and read wall-clock times in their own zone. Typed local + * times (`2026-10-31T14:30`, `2026-10-31 14:30:15`) become UTC instants; instants are shown in the + * zone (the TZ environment, or an explicit IANA zone). Daylight-saving edges follow what a clock + * does: a time skipped by a spring-forward gap moves forward by the gap, and a time repeated by a + * fall-back hour means its first occurrence (the earlier instant), the same choices JavaScript's + * `Date` makes. No terminal API is used here. + */ + +import type { FieldMeta, FieldValues } from './fields.js' +import type { QueryFilter } from '../filter/grammar.js' + +/** An ISO instant with a zone designator (Z or an offset). */ +const ZONED_ISO = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i + +/** A local wall-clock date and time without a zone. */ +const LOCAL = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,9})?)?$/ + +/** A date alone. */ +const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/ + +/** A display value the server already formatted in a declared zone: `2026-10-31 2:30:00 PM CDT`. */ +const SERVER_ZONED = /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}(:\d{2})? [AP]M \S+$/i + +/** The zone in effect: the TZ environment as the runtime resolved it, else UTC. */ +export function defaultTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + } catch { + return 'UTC' + } +} + +/** Whether a zone name is one this runtime knows. */ +export function isTimeZone(zone: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: zone }) + return true + } catch { + return false + } +} + +const formats = new Map() +function wallFormat(timeZone: string): Intl.DateTimeFormat { + let format = formats.get(timeZone) + if (!format) { + format = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short' }) + formats.set(timeZone, format) + } + return format +} + +interface Wall { year: number; month: number; day: number; hour: number; minute: number; second: number; zoneName: string } + +/** The wall clock of an instant in a zone. */ +function wallOf(ms: number, timeZone: string): Wall { + const parts = Object.fromEntries(wallFormat(timeZone).formatToParts(new Date(ms)).map((p) => [p.type, p.value])) + return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour: Number(parts.hour) % 24, minute: Number(parts.minute), second: Number(parts.second), zoneName: String(parts.timeZoneName ?? '') } +} + +/** The zone's offset from UTC at an instant, in milliseconds (positive east of Greenwich). */ +export function zoneOffset(ms: number, timeZone: string): number { + const w = wallOf(ms, timeZone) + const whole = Math.floor(ms / 1000) * 1000 + return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) - whole +} + +/** The instant a wall-clock time names in a zone (DST gaps move forward; repeated hours take the first). */ +export function wallTimeToInstant(year: number, month: number, day: number, hour: number, minute: number, second: number, millis: number, timeZone: string): number { + const wall = Date.UTC(year, month - 1, day, hour, minute, second, millis) + const day1 = 86_400_000 + const offsets = [...new Set([zoneOffset(wall - day1, timeZone), zoneOffset(wall, timeZone), zoneOffset(wall + day1, timeZone)])] + const valid = offsets.map((o) => wall - o).filter((t) => zoneOffset(t, timeZone) === wall - t).sort((a, b) => a - b) + if (valid.length) return valid[0]! + // a gap: read the time with the offset in force before it, as a clock moved forward would + return wall - zoneOffset(wall - day1, timeZone) +} + +/** Formats epoch milliseconds as a UTC instant, keeping fractional seconds only when present. */ +function isoInstant(ms: number, fraction?: string): string { + const base = new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(/\.000Z$/, '') + return `${base}${fraction ?? ''}Z` +} + +export interface ToUtcOptions { + timeZone?: string + /** + * The value before editing: when the typed text names the same wall-clock time, the original + * instant comes back unchanged (its offset in a repeated hour, and precision finer than seconds). + */ + original?: unknown + /** A date alone (`2026-10-31`) means local midnight (filters); otherwise it is not converted. */ + dateMeansMidnight?: boolean +} + +/** + * Converts what a person typed for a DATE_TIME to the UTC instant the server takes. Zoned instants + * pass through; local wall-clock times are read in the zone; anything else is returned unchanged + * for validation to report. + */ +export function localToUtc(text: string, options: ToUtcOptions = {}): string { + const value = text.trim() + if (!value) return value + if (ZONED_ISO.test(value)) return value.replace(' ', 'T') + const timeZone = options.timeZone ?? defaultTimeZone() + let match = LOCAL.exec(value) + if (!match && options.dateMeansMidnight) { + const d = DATE_ONLY.exec(value) + if (d) match = [value, d[1]!, d[2]!, d[3]!, '00', '00', '00', ''] as unknown as RegExpExecArray + } + if (!match) return value + const [, y, mo, d, h, mi, s, fraction] = match + if (isInstant(options.original) && utcToLocalInput(String(options.original), timeZone) === `${y}-${mo}-${d}T${h}:${mi}:${s ?? '00'}` && !fraction) return String(options.original) + const ms = wallTimeToInstant(Number(y), Number(mo), Number(d), Number(h), Number(mi), Number(s ?? 0), 0, timeZone) + return isoInstant(ms, fraction || undefined) +} + +/** Whether a value is a zoned ISO instant. */ +export function isInstant(value: unknown): value is string { + return typeof value === 'string' && ZONED_ISO.test(value.trim()) && !Number.isNaN(Date.parse(value.trim().replace(' ', 'T'))) +} + +/** An instant as local `YYYY-MM-DDTHH:MM:SS` in the zone (what an editor pre-fills). */ +export function utcToLocalInput(instant: string, timeZone: string = defaultTimeZone()): string { + if (!isInstant(instant)) return instant + const w = wallOf(Date.parse(instant.trim().replace(' ', 'T')), timeZone) + const p = (n: number) => String(n).padStart(2, '0') + return `${w.year}-${p(w.month)}-${p(w.day)}T${p(w.hour)}:${p(w.minute)}:${p(w.second)}` +} + +/** An instant for people: `2026-10-31 14:30:00 CDT` in the zone; other values unchanged. */ +export function utcToLocal(instant: string, timeZone: string = defaultTimeZone()): string { + if (!isInstant(instant)) return instant + const w = wallOf(Date.parse(instant.trim().replace(' ', 'T')), timeZone) + const p = (n: number) => String(n).padStart(2, '0') + return `${w.year}-${p(w.month)}-${p(w.day)} ${p(w.hour)}:${p(w.minute)}:${p(w.second)} ${w.zoneName}` +} + +export interface DisplayOptions { + timeZone?: string + /** Show the raw UTC instant (`--utc`). */ + utc?: boolean +} + +/** + * The text to show for a DATE_TIME value: the server's display value when it is formatted in a + * declared zone (a field with a fixed or per-record zone), else the instant in the viewer's zone, + * else the raw value (`utc`). + */ +export function displayDateTime(value: unknown, displayValue?: unknown, options: DisplayOptions = {}): string { + if (value === null || value === undefined || value === '') return displayValue === null || displayValue === undefined ? '' : String(displayValue) + if (options.utc) return String(value) + if (typeof displayValue === 'string' && SERVER_ZONED.test(displayValue.trim())) return displayValue.trim() + return isInstant(value) ? utcToLocal(value, options.timeZone) : String(displayValue ?? value) +} + +/** Converts the DATE_TIME values among `values` (by field type) to UTC instants; returns a new object. */ +export function dateTimesToUtc(fields: Iterable, values: FieldValues, options: Omit & { originals?: FieldValues } = {}): FieldValues { + const out = { ...values } + for (const field of fields) { + if (field.type !== 'DATE_TIME') continue + const v = out[field.name] + if (typeof v === 'string') out[field.name] = localToUtc(v, { ...options, original: options.originals?.[field.name] }) + } + return out +} + +/** + * Converts the values of criteria on DATE_TIME fields (local times and dates) to UTC instants, + * in place through sub-filters. Expression values (`now(-7d)`, `thisPeriod(month)`) are left to the server. + */ +export function filterDateTimesToUtc & { subFilters?: unknown[] }>(filter: F, fields: Record>, timeZone: string = defaultTimeZone()): F { + for (const criterion of filter.criteria ?? []) { + if (fields[criterion.fieldName]?.type !== 'DATE_TIME') continue + criterion.values = (criterion.values ?? []).map((v) => (typeof v === 'string' ? localToUtc(v, { timeZone, dateMeansMidnight: true }) : v)) as typeof criterion.values + } + for (const sub of filter.subFilters ?? []) filterDateTimesToUtc(sub as F, fields, timeZone) + return filter +} diff --git a/packages/core/src/forms/fields.ts b/packages/core/src/forms/fields.ts index f82ab28..e5ed4b7 100644 --- a/packages/core/src/forms/fields.ts +++ b/packages/core/src/forms/fields.ts @@ -131,7 +131,7 @@ export function validateValue(field: FieldMeta, raw: unknown): string | undefine if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(Date.parse(text))) return 'must be a date, YYYY-MM-DD' break case 'DATE_TIME': - if (Number.isNaN(Date.parse(text))) return 'must be a date and time, YYYY-MM-DDTHH:MM' + if (!/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i.test(text) || Number.isNaN(Date.parse(text.replace(' ', 'T')))) return 'must be a date and time, YYYY-MM-DDTHH:MM (local time) or an ISO instant' break case 'TIME': if (!/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/.test(text)) return 'must be a time, HH:MM' diff --git a/packages/core/src/forms/form-session.ts b/packages/core/src/forms/form-session.ts index e28ae72..a132cd8 100644 --- a/packages/core/src/forms/form-session.ts +++ b/packages/core/src/forms/form-session.ts @@ -13,6 +13,7 @@ import { applyAdjustment, fieldAdjusterIdentifier, hasTableOnLoadAdjuster, runFormAdjuster, type FormAdjusterOutput } from './adjuster.js' import { applyBehaviors, dependentsOf, dependencyValues, getDependencies, isContingentlyDisabled, isMissingValue, missingDependencies, validateValues, type FieldMeta, type FieldValues, type PossibleValue } from './fields.js' +import { defaultTimeZone, isInstant, localToUtc, utcToLocal } from './datetime.js' import { resolvePossibleValue, searchPossibleValues, type FormScope } from './possible-values.js' export interface FormSessionOptions { @@ -25,6 +26,8 @@ export interface FormSessionOptions { tableMeta?: { supplementalMetaData?: Record } /** Fields never offered for input (the primary key on create). */ exclude?: string[] + /** The zone DATE_TIME values are typed and shown in (default: the TZ environment). */ + timeZone?: string } export interface FieldState { @@ -56,6 +59,9 @@ export class FormSession { private readonly fetchImpl: typeof fetch private readonly tableMeta?: FormSessionOptions['tableMeta'] private readonly exclude: Set + /** The zone typed DATE_TIME values are read in. */ + readonly timeZone: string + private readonly originals: FieldValues constructor(options: FormSessionOptions) { this.baseUrl = options.baseUrl @@ -66,7 +72,11 @@ export class FormSession { this.order = options.fields.map((f) => f.name) this.fields = Object.fromEntries(options.fields.map((f) => [f.name, f])) this.values = { ...(options.initialValues ?? {}) } + this.originals = { ...(options.initialValues ?? {}) } + this.timeZone = options.timeZone ?? defaultTimeZone() for (const f of options.fields) if (this.values[f.name] === undefined && f.defaultValue !== undefined && f.defaultValue !== null) this.values[f.name] = f.defaultValue + // DATE_TIME instants are shown in the local zone + for (const f of options.fields) if (f.type === 'DATE_TIME' && isInstant(this.values[f.name])) this.displayValues[f.name] = utcToLocal(String(this.values[f.name]), this.timeZone) } /** Runs the table's on-load adjuster when it declares one. */ @@ -163,6 +173,12 @@ export class FormSession { if (isContingentlyDisabled(field, this.values)) throw new FormInputError(`${field.label}: ${this.state(name).disabledBecause}`) let value: unknown = input if (typeof input === 'string') value = applyBehaviors(field, input) + if (field.type === 'DATE_TIME' && typeof value === 'string' && !isMissingValue(value)) { + // typed local time to the UTC instant the server stores; an unchanged wall-clock time keeps the original instant + value = localToUtc(value, { timeZone: this.timeZone, original: this.originals[name] }) + if (isInstant(value)) this.displayValues[name] = utcToLocal(String(value), this.timeZone) + else delete this.displayValues[name] + } if (this.hasChoices(name) && typeof value === 'string' && !isMissingValue(value)) { const resolved = await this.resolveChoice(name, value) value = resolved.id diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2066561..9e84a32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,3 +23,4 @@ export * from './records/variants.js' export * from './metadata/navigation.js' export * from './help/help-content.js' export * from './process/screen.js' +export * from './forms/datetime.js' diff --git a/packages/core/src/process/screen.ts b/packages/core/src/process/screen.ts index 08cb0be..3f987dd 100644 --- a/packages/core/src/process/screen.ts +++ b/packages/core/src/process/screen.ts @@ -69,8 +69,8 @@ export interface ScreenOptions { /** Records previewed on a validation review screen; default 10 (0: none). */ previewLimit?: number pageSize?: number - /** Formats a value for display (DATE_TIME in the local zone, etc.); default {@link formatValue}. */ - format?: (field: FieldMeta, value: unknown) => string + /** Formats a value for display (DATE_TIME in the local zone, etc.), given the server's display value when there is one; default {@link formatValue}. */ + format?: (field: FieldMeta, value: unknown, displayValue?: unknown) => string tableVariant?: string } @@ -176,7 +176,7 @@ export function processHelpText(meta: { helpContents?: Record helpText(entries)) } -function recordTable(fields: FieldMeta[], records: Array>, format: (f: FieldMeta, v: unknown) => string): { columns: string[]; headers: string[]; rows: Row[] } { +function recordTable(fields: FieldMeta[], records: Array>, format: (f: FieldMeta, v: unknown, d?: unknown) => string): { columns: string[]; headers: string[]; rows: Row[] } { const shown = fields.filter((f) => !f.isHidden) const columns = shown.length ? shown.map((f) => f.name) : Object.keys((records[0]?.values ?? {}) as Record).slice(0, 8) const byName = Object.fromEntries(shown.map((f) => [f.name, f])) @@ -185,8 +185,8 @@ function recordTable(fields: FieldMeta[], records: Array const display = (r.displayValues ?? {}) as Record return Object.fromEntries(columns.map((c) => { const field = byName[c] ?? { name: c, label: c, type: 'STRING' } - // the server's display value wins, except for DATE_TIME, which the client shows in the local zone - const text = field.type !== 'DATE_TIME' && display[c] !== undefined && display[c] !== null ? String(display[c]) : format(field, values[c]) + // the server's display value wins, except for DATE_TIME, which the formatter shows in the local zone (or the server's declared zone) + const text = field.type !== 'DATE_TIME' && display[c] !== undefined && display[c] !== null ? String(display[c]) : format(field, values[c], display[c]) return [c, text] })) }) diff --git a/packages/core/src/test/datetime.test.ts b/packages/core/src/test/datetime.test.ts new file mode 100644 index 0000000..23ef40e --- /dev/null +++ b/packages/core/src/test/datetime.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2026 QRun.IO, Inc. + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { dateTimesToUtc, displayDateTime, filterDateTimesToUtc, localToUtc, utcToLocal, utcToLocalInput, wallTimeToInstant } from '../forms/datetime.js' +import type { QueryFilter } from '../filter/grammar.js' + +const CHI = 'America/Chicago' + +test('local wall-clock times become UTC instants in the given zone', () => { + assert.equal(localToUtc('2026-10-31T14:30', { timeZone: CHI }), '2026-10-31T19:30:00Z') + assert.equal(localToUtc('2026-10-31 14:30:15', { timeZone: CHI }), '2026-10-31T19:30:15Z') + assert.equal(localToUtc('2026-01-15T09:00', { timeZone: CHI }), '2026-01-15T15:00:00Z') + assert.equal(localToUtc('2026-07-01T12:00', { timeZone: 'Asia/Kolkata' }), '2026-07-01T06:30:00Z') + assert.equal(localToUtc('2026-07-01T12:00', { timeZone: 'UTC' }), '2026-07-01T12:00:00Z') + assert.equal(localToUtc('2026-07-01T12:00:00.123456', { timeZone: 'UTC' }), '2026-07-01T12:00:00.123456Z') +}) + +test('zoned instants pass through and unparseable text is left for validation', () => { + assert.equal(localToUtc('2026-10-31T19:30:00Z', { timeZone: CHI }), '2026-10-31T19:30:00Z') + assert.equal(localToUtc('2026-10-31T14:30:00-05:00', { timeZone: 'UTC' }), '2026-10-31T14:30:00-05:00') + assert.equal(localToUtc('next tuesday', { timeZone: CHI }), 'next tuesday') + assert.equal(localToUtc('2026-10-31', { timeZone: CHI }), '2026-10-31') + assert.equal(localToUtc('2026-10-31', { timeZone: CHI, dateMeansMidnight: true }), '2026-10-31T05:00:00Z') +}) + +test('spring forward: a time in the gap moves forward by the gap (Chicago, 2026-03-08 02:00 to 03:00)', () => { + assert.equal(localToUtc('2026-03-08T01:59', { timeZone: CHI }), '2026-03-08T07:59:00Z') + assert.equal(localToUtc('2026-03-08T02:30', { timeZone: CHI }), '2026-03-08T08:30:00Z') + assert.equal(utcToLocal('2026-03-08T08:30:00Z', CHI), '2026-03-08 03:30:00 CDT') + assert.equal(localToUtc('2026-03-08T03:00', { timeZone: CHI }), '2026-03-08T08:00:00Z') +}) + +test('fall back: a repeated time means its first occurrence (Chicago, 2026-11-01 01:00 to 02:00 twice)', () => { + assert.equal(localToUtc('2026-11-01T01:30', { timeZone: CHI }), '2026-11-01T06:30:00Z') + assert.equal(utcToLocal('2026-11-01T06:30:00Z', CHI), '2026-11-01 01:30:00 CDT') + assert.equal(utcToLocal('2026-11-01T07:30:00Z', CHI), '2026-11-01 01:30:00 CST') + assert.equal(localToUtc('2026-11-01T02:30', { timeZone: CHI }), '2026-11-01T08:30:00Z') + // the second 01:30 survives an edit that leaves the wall-clock text alone + assert.equal(localToUtc('2026-11-01T01:30:00', { timeZone: CHI, original: '2026-11-01T07:30:00Z' }), '2026-11-01T07:30:00Z') +}) + +test('southern hemisphere and Europe: Sydney and London DST edges', () => { + // Sydney leaves DST 2026-04-05 03:00 -> 02:00; enters 2026-10-04 02:00 -> 03:00 + assert.equal(localToUtc('2026-04-05T02:30', { timeZone: 'Australia/Sydney' }), '2026-04-04T15:30:00Z') + assert.equal(localToUtc('2026-10-04T02:30', { timeZone: 'Australia/Sydney' }), '2026-10-03T16:30:00Z') + // London enters BST 2026-03-29 01:00 -> 02:00 + assert.equal(localToUtc('2026-03-29T01:30', { timeZone: 'Europe/London' }), '2026-03-29T01:30:00Z') + assert.equal(localToUtc('2026-03-29T00:30', { timeZone: 'Europe/London' }), '2026-03-29T00:30:00Z') + assert.equal(localToUtc('2026-03-29T02:30', { timeZone: 'Europe/London' }), '2026-03-29T01:30:00Z') +}) + +test('unchanged instants keep their precision through an edit', () => { + const original = '2026-10-31T22:41:52.344419Z' + const local = utcToLocalInput(original, CHI) + assert.equal(local, '2026-10-31T17:41:52') + assert.equal(localToUtc(local, { timeZone: CHI, original }), original) + assert.equal(localToUtc('2026-10-31T17:42', { timeZone: CHI, original }), '2026-10-31T22:42:00Z') +}) + +test('display: local zone, the server zoned display when given, raw with utc', () => { + assert.equal(displayDateTime('2026-10-31T19:30:00Z', '2026-10-31T19:30:00Z', { timeZone: CHI }), '2026-10-31 14:30:00 CDT') + assert.equal(displayDateTime('2026-10-31T19:30:00Z', '2026-10-31 3:30:00 PM EDT', { timeZone: CHI }), '2026-10-31 3:30:00 PM EDT') + assert.equal(displayDateTime('2026-10-31T19:30:00Z', undefined, { timeZone: CHI, utc: true }), '2026-10-31T19:30:00Z') + assert.equal(displayDateTime(null, undefined, { timeZone: CHI }), '') + assert.match(displayDateTime('2026-12-01T12:00:00Z', undefined, { timeZone: 'Europe/London' }), /^2026-12-01 12:00:00 GMT/) +}) + +test('only DATE_TIME fields and DATE_TIME criteria are converted', () => { + const fields = [{ name: 'when', label: 'When', type: 'DATE_TIME' }, { name: 'day', label: 'Day', type: 'DATE' }, { name: 'note', label: 'Note', type: 'STRING' }] + assert.deepEqual(dateTimesToUtc(fields, { when: '2026-10-31T14:30', day: '2026-10-31', note: '2026-10-31T14:30' }, { timeZone: CHI }), + { when: '2026-10-31T19:30:00Z', day: '2026-10-31', note: '2026-10-31T14:30' }) + const filter: QueryFilter = { + criteria: [{ fieldName: 'when', operator: 'BETWEEN', values: ['2026-10-31', '2026-10-31T23:59'] }, { fieldName: 'day', operator: 'EQUALS', values: ['2026-10-31'] }], + booleanOperator: 'AND', + subFilters: [{ criteria: [{ fieldName: 'when', operator: 'GREATER_THAN', values: [{ type: 'NowWithOffset', operator: 'MINUS', amount: 7, timeUnit: 'DAYS' } as never] }], booleanOperator: 'AND' }], + } as QueryFilter + filterDateTimesToUtc(filter, Object.fromEntries(fields.map((f) => [f.name, f])), CHI) + assert.deepEqual(filter.criteria[0]!.values, ['2026-10-31T05:00:00Z', '2026-11-01T04:59:00Z']) + assert.deepEqual(filter.criteria[1]!.values, ['2026-10-31']) + assert.equal(typeof (filter.subFilters![0] as QueryFilter).criteria[0]!.values[0], 'object') +}) + +test('wallTimeToInstant agrees with Date for the host zone across a year', () => { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone + for (let month = 1; month <= 12; month++) { + const expected = new Date(2026, month - 1, 15, 10, 20, 0).getTime() + assert.equal(wallTimeToInstant(2026, month, 15, 10, 20, 0, 0, zone), expected) + } +}) + +test('a form session reads typed DATE_TIME values as local time and keeps unchanged instants', async () => { + const { FormSession } = await import('../forms/form-session.js') + const noFetch: typeof fetch = async () => new Response('{}', { status: 404 }) + const session = new FormSession({ + baseUrl: 'http://x', fetch: noFetch, scope: { process: 'p' }, timeZone: CHI, + fields: [{ name: 'when', label: 'When', type: 'DATE_TIME' }, { name: 'other', label: 'Other', type: 'DATE_TIME' }], + initialValues: { when: '2026-10-31T22:41:52.344419Z' }, + }) + assert.equal(session.displayValues.when, '2026-10-31 17:41:52 CDT') + await session.set('when', '2026-10-31T17:41:52') + assert.equal(session.values.when, '2026-10-31T22:41:52.344419Z') + await session.set('other', '2026-11-01T01:30') + assert.equal(session.values.other, '2026-11-01T06:30:00Z') + assert.equal(session.displayValues.other, '2026-11-01 01:30:00 CDT') + await session.set('other', 'soon') + assert.deepEqual(session.validate(), { other: 'must be a date and time, YYYY-MM-DDTHH:MM (local time) or an ISO instant' }) +}) diff --git a/scripts/parity-matrix.py b/scripts/parity-matrix.py index ab170b2..235b1ac 100644 --- a/scripts/parity-matrix.py +++ b/scripts/parity-matrix.py @@ -42,11 +42,11 @@ STATUS = { 'PRC-040': (X, 'no reload'), 'PRC-041': (P, 'UNAUTHENTICATED exits 3 with the sign-in hint'), 'PRC-042': (P, 'server-enforced'), 'PRC-043': (P, '-v values seed every step'), 'PRC-044': (X, 'scanner screens are interactive'), 'PRC-045': (P, 'the runner follows nextStep whatever the flow'), 'PRC-046': (T, 'saved bulk load profiles'), 'PRC-047': (P, 'completes on init'), 'PRC-048': (P, 'searchProcessPossibleValues'), 'PRC-049': (P, 'v1 routes only'), 'PRC-050': (X, 'Material-specific'), 'PRC-051': (P, 'dependent choices narrow after each answer'), - 'PRC-058': (T, ''), 'PRC-059': (T, ''), 'PRC-060': (T, ''), 'PRC-061': (T, ''), 'PRC-062': (X, 'composite editors'), 'PRC-063': (X, ''), 'PRC-064': (T, 'DATE_TIME local to UTC'), 'PRC-065': (X, 'Google Drive'), 'PRC-066': (P, 'field behaviors applied before submit'), + 'PRC-058': (T, ''), 'PRC-059': (T, ''), 'PRC-060': (T, ''), 'PRC-061': (T, ''), 'PRC-062': (X, 'composite editors'), 'PRC-063': (X, ''), 'PRC-064': (P, 'process values typed local, sent UTC; screens show the local zone'), 'PRC-065': (X, 'Google Drive'), 'PRC-066': (P, 'field behaviors applied before submit'), # QRY 'QRY-001': (P, 'default order by primary key'), 'QRY-002': (P, '--limit, --skip, --all'), 'QRY-003': (P, '--order, -field for descending'), 'QRY-004': (T, '--fields per call; no persistence'), 'QRY-005': (X, 'grid presentation'), 'QRY-006': (X, ''), 'QRY-007': (P, 'error with code, status and trace id'), 'QRY-010': (P, 'all operators and shorthand'), 'QRY-011': (P, 'BETWEEN, IN lists'), - 'QRY-012': (P, 'now(), now(-7d), thisPeriod(), startOf()'), 'QRY-013': (T, 'DATE_TIME local to UTC conversion'), 'QRY-014': (P, 'IS_BLANK, TRUE, FALSE'), 'QRY-015': (T, 'filters take ids; labels in filters todo'), + 'QRY-012': (P, 'now(), now(-7d), thisPeriod(), startOf()'), 'QRY-013': (P, '--where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before'), 'QRY-014': (P, 'IS_BLANK, TRUE, FALSE'), 'QRY-015': (T, 'filters take ids; labels in filters todo'), 'QRY-016': (P, '--group with parentheses'), 'QRY-017': (P, '--print-filter gives the JSON the web links accept'), 'QRY-018': (P, 'any operator in --where'), 'QRY-019': (P, '--search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter'), 'QRY-020': (T, 'joins'), 'QRY-021': (T, 'joins'), 'QRY-022': (T, 'column statistics'), 'QRY-023': (T, ''), 'QRY-030': (P, '-r ids, --filter-json, --limit'), 'QRY-031': (T, 'bulk edit'), 'QRY-032': (T, 'bulk delete'), 'QRY-033': (P, 'process run --filter-json with limit'), 'QRY-034': (T, 'process list --table'), 'QRY-035': (P, 'server refuses; exit 3'), 'QRY-040': (P, 'table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out -'), 'QRY-041': (P, 'csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request'), @@ -60,11 +60,11 @@ STATUS = { # REC 'REC-001': (P, 'table get by section and tier'), 'REC-002': (T, 'hidden sections'), 'REC-003': (P, 'exit 5'), 'REC-004': (X, 'tabs'), 'REC-005': (P, 'validated locally before any request'), 'REC-006': (P, ''), 'REC-007': (P, ''), 'REC-008': (X, 'guard'), 'REC-009': (P, 'update sends only changed fields'), 'REC-010': (P, 'non-editable fields excluded'), 'REC-011': (P, ''), 'REC-012': (P, 'confirm unless --yes'), 'REC-013': (P, 'qqq table copy [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped'), 'REC-014': (P, ''), 'REC-015': (P, ''), 'REC-016': (P, 'defaults applied'), - 'REC-017': (P, ''), 'REC-018': (T, 'DATE_TIME local to UTC'), 'REC-019': (X, 'display zones'), 'REC-020': (T, 'raw HTML; $EDITOR todo'), 'REC-021': (P, 'server masks'), 'REC-022': (T, 'blob upload and download'), 'REC-023': (P, 'server'), + 'REC-017': (P, ''), 'REC-018': (P, 'typed and shown in TZ, stored UTC, --utc for raw; DST edges tested'), 'REC-019': (X, 'display zones'), 'REC-020': (T, 'raw HTML; $EDITOR todo'), 'REC-021': (P, 'server masks'), 'REC-022': (T, 'blob upload and download'), 'REC-023': (P, 'server'), 'REC-024': (P, 'maxLength checked locally'), 'REC-025': (P, 'behaviors'), 'REC-026': (P, 'ranges checked locally'), 'REC-027': (P, 'server message'), 'REC-028': (T, 'adornments'), 'REC-029': (T, ''), 'REC-030': (X, ''), 'REC-031': (T, ''), 'REC-032': (T, ''), 'REC-033': (T, 'reveal'), 'REC-034': (T, 'file download'), 'REC-035': (T, 'file upload'), 'REC-036': (X, ''), 'REC-037': (T, ''), 'REC-038': (T, ''), 'REC-039': (T, 'help'), 'REC-040': (T, ''), 'REC-041': (T, ''), 'REC-042': (T, 'audits'), 'REC-043': (T, ''), 'REC-044': (P, 'recordLabel'), 'REC-045': (P, 'labels resolved, defaults looked up'), 'REC-046': (P, 'server'), 'REC-047': (X, ''), 'REC-048': (P, 'fieldErrors by field'), 'REC-049': (X, ''), 'REC-050': (P, ''), 'REC-051': (P, ''), 'REC-052': (P, 'contingent fields'), - 'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (T, 'variants'), 'REC-064': (T, 'joins'), 'REC-065': (T, ''), + 'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (T, 'variants'), 'REC-064': (T, 'joins'), 'REC-065': (P, 'an unchanged wall-clock time keeps the original instant and its precision'), # SEC 'SEC-001': (P, ''), 'SEC-002': (T, 'disabled tables listed with a reason'), 'SEC-003': (P, ''), 'SEC-004': (P, ''), 'SEC-005': (P, ''), 'SEC-006': (T, 'copy'), 'SEC-007': (P, ''), 'SEC-008': (T, ''), 'SEC-009': (P, 'widget get shows the refusal'), 'SEC-010': (P, ''), 'SEC-011': (T, 'saved views and reports'), 'SEC-012': (P, 'server'), 'SEC-013': (P, 'server'), 'SEC-014': (P, ''), 'SEC-015': (P, ''), 'SEC-020': (P, 'qqq login on a MOCK server'), 'SEC-021': (P, 'qqq logout calls POST /logout'), 'SEC-022': (P, 'exit 3 with the sign-in hint'),