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:
1 parent
65de37c76e
commit
e058942257
16 files changed
+374
-32
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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]
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user