core+cli: DATE_TIME typed and shown in the local zone, sent as UTC instants

forms/datetime converts typed wall-clock times (2026-10-31T14:30) to UTC in
the TZ zone with Intl offsets: a time in a spring-forward gap moves forward,
a repeated fall-back time means its first occurrence, and an unchanged
wall-clock time keeps the original instant with its precision. Instants are
shown as 2026-10-31 14:30:00 CDT; display values the server formats in a
declared zone are kept; --utc shows raw instants.

The form session converts DATE_TIME on set (table create, update, copy and
process values), DATE_TIME validation now requires a date and time, --where
and --group criteria on DATE_TIME fields are converted (a date alone is local
midnight), and table get/query and process screens format in the zone.
Tests cover Chicago, London, Sydney and Kolkata DST edges; verified live on
fieldLab (create, get, --utc, TZ=Asia/Tokyo, filters, update across the gap,
fieldLab.bulkEdit values).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-10-31 17:54:29 -05:00
1 parent 65de37c76e
commit e058942257
16 files changed
+374 -32

No files matched your search

+15 -1
View File
@@ -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
+8 -8
View File
@@ -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 | |
+2 -2
View File
@@ -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`)
+9 -4
View File
@@ -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<string, unknown>
@@ -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<string, Json>)[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)
}
+6 -5
View File
@@ -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<Json> {
}
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<string, FieldMeta>)
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<string, Json>
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<string, Json>)[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)
+7 -1
View File
@@ -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
+1
View File
@@ -38,6 +38,7 @@ program
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
.option('--fields <names>', '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) })
+1 -1
View File
@@ -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) {
+1
View File
@@ -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 |
+186
View File
@@ -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<string, Intl.DateTimeFormat>()
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<FieldMeta>, values: FieldValues, options: Omit<ToUtcOptions, 'original'> & { 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<F extends Pick<QueryFilter, 'criteria'> & { subFilters?: unknown[] }>(filter: F, fields: Record<string, Pick<FieldMeta, 'type'>>, 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
}
+1 -1
View File
@@ -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'
+16
View File
@@ -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<string, unknown> }
/** 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<string>
/** 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
+1
View File
@@ -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'
+5 -5
View File
@@ -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<string, ScreenStep
return Object.values(meta.helpContents ?? {}).flatMap((entries) => helpText(entries))
}
function recordTable(fields: FieldMeta[], records: Array<Record<string, unknown>>, format: (f: FieldMeta, v: unknown) => string): { columns: string[]; headers: string[]; rows: Row[] } {
function recordTable(fields: FieldMeta[], records: Array<Record<string, unknown>>, 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<string, unknown>).slice(0, 8)
const byName = Object.fromEntries(shown.map((f) => [f.name, f]))
@@ -185,8 +185,8 @@ function recordTable(fields: FieldMeta[], records: Array<Record<string, unknown>
const display = (r.displayValues ?? {}) as Record<string, unknown>
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]
}))
})
+111
View File
@@ -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' })
})
+4 -4
View File
@@ -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 <t> <pk> [--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'),