d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
/**
|
|
* Canonical date-cell semantics for user tables.
|
|
*
|
|
* A `date` cell stores exactly one of two shapes:
|
|
*
|
|
* - **Calendar date** `YYYY-MM-DD` — a timezone-free day.
|
|
* - **Instant with preserved offset** — RFC 3339 `YYYY-MM-DDTHH:mm:ss±HH:MM`
|
|
* (or `Z`). The wall-time part is what was written and is what every viewer
|
|
* sees — display never converts across timezones. The offset suffix carries
|
|
* the true instant for machine consumers (SQL `::timestamptz` casts,
|
|
* workflows, agents, exports).
|
|
*
|
|
* The interpretation of an input is determined once, at write time: explicit
|
|
* offsets (`Z`, `-07:00`, `PDT`) are preserved as written; naive datetime
|
|
* strings are stamped with the offset of the writer's effective timezone
|
|
* (via {@link NormalizeDateCellOptions.timezone}), else the runtime's local
|
|
* zone — the browser for UI writes, the server (UTC in production) for raw
|
|
* API writes. After that the stored value is final: reads render its wall
|
|
* time verbatim, identically for everyone.
|
|
*
|
|
* This module is pure and shared by server coercion and client rendering.
|
|
* Client code must import it via this concrete path, never the `@/lib/table`
|
|
* barrel (the barrel is server-tainted).
|
|
*/
|
|
|
|
const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
|
|
/**
|
|
* Canonical (or canonical-enough legacy) instant: a literal wall time with an
|
|
* optional fractional-seconds part and an optional offset suffix. The capture
|
|
* groups are the wall-time fields display renders verbatim.
|
|
*/
|
|
const WALL_INSTANT_PATTERN =
|
|
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/
|
|
|
|
/**
|
|
* Legacy shape: old CSV imports stored date-only columns as UTC-midnight
|
|
* instants. Treated as calendar dates so historical rows render as pure days
|
|
* rather than a spurious "12:00 AM".
|
|
*/
|
|
const UTC_MIDNIGHT_PATTERN = /^\d{4}-\d{2}-\d{2}T00:00:00(\.000)?Z$/
|
|
|
|
/** A time-of-day component anywhere in the string (e.g. `16:04`). */
|
|
const TIME_COMPONENT_PATTERN = /\d{1,2}:\d{2}/
|
|
|
|
/**
|
|
* ISO reduced-precision date forms (`2026`, `2026-07`) parse as UTC per spec,
|
|
* unlike other date-only forms which V8 parses as local time.
|
|
*/
|
|
const ISO_REDUCED_DATE_PATTERN = /^\d{4}(-\d{2})?$/
|
|
|
|
/**
|
|
* Fixed offsets (minutes east of UTC) for the RFC 2822 US timezone
|
|
* abbreviations — the only abbreviations `Date.parse` accepts, applied as
|
|
* literal offsets exactly as the engine does.
|
|
*/
|
|
const US_ABBREVIATION_OFFSET_MINUTES: Record<string, number> = {
|
|
EST: -300,
|
|
EDT: -240,
|
|
CST: -360,
|
|
CDT: -300,
|
|
MST: -420,
|
|
MDT: -360,
|
|
PST: -480,
|
|
PDT: -420,
|
|
}
|
|
|
|
/** True when `value` is a canonical timezone-free calendar date. */
|
|
export function isCalendarDateString(value: string): boolean {
|
|
return CALENDAR_DATE_PATTERN.test(value) && !Number.isNaN(Date.parse(value))
|
|
}
|
|
|
|
/** A wall-clock reading of an instant in some timezone. */
|
|
export interface WallClockParts {
|
|
year: number
|
|
/** 1-based month. */
|
|
month: number
|
|
day: number
|
|
hour: number
|
|
minute: number
|
|
second: number
|
|
}
|
|
|
|
/**
|
|
* The wall-clock reading of `date` in `timeZone` — or in the runtime's local
|
|
* zone when omitted. Throws a RangeError on an invalid IANA zone — callers
|
|
* validate at the boundary.
|
|
*/
|
|
export function getWallClockParts(date: Date, timeZone?: string): WallClockParts {
|
|
if (!timeZone) {
|
|
return {
|
|
year: date.getFullYear(),
|
|
month: date.getMonth() + 1,
|
|
day: date.getDate(),
|
|
hour: date.getHours(),
|
|
minute: date.getMinutes(),
|
|
second: date.getSeconds(),
|
|
}
|
|
}
|
|
const parts = 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',
|
|
}).formatToParts(date)
|
|
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
|
|
return {
|
|
year: get('year'),
|
|
month: get('month'),
|
|
day: get('day'),
|
|
hour: get('hour'),
|
|
minute: get('minute'),
|
|
second: get('second'),
|
|
}
|
|
}
|
|
|
|
/** Offset of `timeZone` from UTC (ms east) at the moment `at`. */
|
|
function zoneOffsetMs(timeZone: string, at: Date): number {
|
|
const wall = getWallClockParts(at, timeZone)
|
|
const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second)
|
|
return asUtc - at.getTime()
|
|
}
|
|
|
|
/**
|
|
* Converts a wall-clock reading in `timeZone` to the UTC instant it denotes.
|
|
* Two-pass so readings near a DST transition resolve with the offset in
|
|
* force at that wall time.
|
|
*/
|
|
function wallTimeInZoneToUtc(wall: Date, timeZone: string): Date {
|
|
const guess = Date.UTC(
|
|
wall.getFullYear(),
|
|
wall.getMonth(),
|
|
wall.getDate(),
|
|
wall.getHours(),
|
|
wall.getMinutes(),
|
|
wall.getSeconds(),
|
|
wall.getMilliseconds()
|
|
)
|
|
const adjusted = guess - zoneOffsetMs(timeZone, new Date(guess))
|
|
return new Date(guess - zoneOffsetMs(timeZone, new Date(adjusted)))
|
|
}
|
|
|
|
function pad(n: number): string {
|
|
return String(n).padStart(2, '0')
|
|
}
|
|
|
|
function toLocalCalendarDate(date: Date): string {
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
}
|
|
|
|
function toUtcCalendarDate(date: Date): string {
|
|
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
|
|
}
|
|
|
|
/** `Z` for zero, else `±HH:MM`. */
|
|
function formatOffsetSuffix(offsetMinutes: number): string {
|
|
if (offsetMinutes === 0) return 'Z'
|
|
const sign = offsetMinutes > 0 ? '+' : '-'
|
|
const abs = Math.abs(offsetMinutes)
|
|
return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
|
}
|
|
|
|
/**
|
|
* Trailing offset (minutes east of UTC) of a datetime string, or null when
|
|
* naive. Recognizes exactly what `Date.parse` recognizes: numeric offsets,
|
|
* `Z`/`UT`/`UTC`/`GMT`, and the RFC 2822 US abbreviations. Deliberately does
|
|
* not match a trailing `AM`/`PM`.
|
|
*/
|
|
function extractExplicitOffsetMinutes(value: string): number | null {
|
|
const numeric = value.match(/([+-])(\d{1,2}):?(\d{2})?\s*$/)
|
|
if (numeric) {
|
|
const sign = numeric[1] === '-' ? -1 : 1
|
|
return sign * (Number(numeric[2]) * 60 + Number(numeric[3] ?? 0))
|
|
}
|
|
if (/(?:Z|UTC?|GMT)$/i.test(value)) return 0
|
|
const abbreviation = value.match(/([ECMP][SD]T)$/i)
|
|
if (abbreviation) return US_ABBREVIATION_OFFSET_MINUTES[abbreviation[1].toUpperCase()]
|
|
return null
|
|
}
|
|
|
|
/** Serializes UTC-read fields of `shifted` as a wall time with `offset`. */
|
|
function formatUtcFieldsAsWall(shifted: Date, offsetMinutes: number): string {
|
|
return `${toUtcCalendarDate(shifted)}T${pad(shifted.getUTCHours())}:${pad(
|
|
shifted.getUTCMinutes()
|
|
)}:${pad(shifted.getUTCSeconds())}${formatOffsetSuffix(offsetMinutes)}`
|
|
}
|
|
|
|
/** Serializes local-read fields of `parsed` as a wall time with `offset`. */
|
|
function formatLocalFieldsAsWall(parsed: Date, offsetMinutes: number): string {
|
|
return `${toLocalCalendarDate(parsed)}T${pad(parsed.getHours())}:${pad(
|
|
parsed.getMinutes()
|
|
)}:${pad(parsed.getSeconds())}${formatOffsetSuffix(offsetMinutes)}`
|
|
}
|
|
|
|
export interface NormalizeDateCellOptions {
|
|
/**
|
|
* IANA zone whose offset stamps naive datetime strings (no explicit
|
|
* offset), e.g. a CSV import applying the importing user's timezone.
|
|
* Defaults to the runtime's local zone — the author's wall clock in the
|
|
* browser, UTC on production servers. Throws a RangeError on an invalid
|
|
* zone.
|
|
*/
|
|
timezone?: string
|
|
}
|
|
|
|
/**
|
|
* Normalizes a raw string to a canonical date-cell value, or `null` when it
|
|
* cannot be parsed. Date-only inputs become calendar dates; inputs carrying
|
|
* a time become offset-preserved instants: the wall time survives verbatim
|
|
* (explicit offsets kept as written, naive readings stamped per
|
|
* {@link NormalizeDateCellOptions.timezone}) — see module doc.
|
|
*/
|
|
export function normalizeDateCellValue(
|
|
raw: string,
|
|
options?: NormalizeDateCellOptions
|
|
): string | null {
|
|
const trimmed = raw.trim()
|
|
if (!trimmed) return null
|
|
if (CALENDAR_DATE_PATTERN.test(trimmed)) {
|
|
return Number.isNaN(Date.parse(trimmed)) ? null : trimmed
|
|
}
|
|
const ms = Date.parse(trimmed)
|
|
if (Number.isNaN(ms)) return null
|
|
const parsed = new Date(ms)
|
|
if (!TIME_COMPONENT_PATTERN.test(trimmed)) {
|
|
return ISO_REDUCED_DATE_PATTERN.test(trimmed)
|
|
? toUtcCalendarDate(parsed)
|
|
: toLocalCalendarDate(parsed)
|
|
}
|
|
const explicitOffset = extractExplicitOffsetMinutes(trimmed)
|
|
if (explicitOffset !== null) {
|
|
// The input's own wall time = the instant shifted east by its offset,
|
|
// read as UTC fields.
|
|
return formatUtcFieldsAsWall(new Date(ms + explicitOffset * 60_000), explicitOffset)
|
|
}
|
|
if (options?.timezone) {
|
|
// `parsed`'s local getters recover the wall-clock fields V8 read from the
|
|
// naive string; stamp them with the requested zone's offset at that time.
|
|
const instant = wallTimeInZoneToUtc(parsed, options.timezone)
|
|
const offsetMinutes = Math.round(zoneOffsetMs(options.timezone, instant) / 60_000)
|
|
return formatLocalFieldsAsWall(parsed, offsetMinutes)
|
|
}
|
|
return formatLocalFieldsAsWall(parsed, -parsed.getTimezoneOffset())
|
|
}
|
|
|
|
/**
|
|
* Canonical form a stored date cell should be edited (and re-saved) as.
|
|
* Legacy UTC-midnight instants surface as their UTC calendar day (old CSV
|
|
* imports stored date-only columns that way). Unparseable legacy strings
|
|
* pass through so the editor shows what is actually stored.
|
|
*/
|
|
export function storedDateToEditable(stored: string): string {
|
|
if (UTC_MIDNIGHT_PATTERN.test(stored)) return toUtcCalendarDate(new Date(stored))
|
|
return normalizeDateCellValue(stored) ?? stored
|
|
}
|
|
|
|
interface FormatDateCellDisplayOptions {
|
|
/** Include seconds on instants when non-zero (editor drafts round-trip precision). */
|
|
seconds?: boolean
|
|
}
|
|
|
|
function formatWallForDisplay(
|
|
month: string,
|
|
day: string,
|
|
year: string,
|
|
hour: number,
|
|
minute: string,
|
|
second: number,
|
|
withSeconds: boolean | undefined
|
|
): string {
|
|
const hours12 = hour % 12 === 0 ? 12 : hour % 12
|
|
const meridiem = hour < 12 ? 'AM' : 'PM'
|
|
const secondsPart = withSeconds && second !== 0 ? `:${pad(second)}` : ''
|
|
return `${month}/${day}/${year} ${hours12}:${minute}${secondsPart} ${meridiem}`
|
|
}
|
|
|
|
/**
|
|
* Formats a stored date-cell value for display. Calendar dates (and legacy
|
|
* UTC-midnight instants) render as `MM/DD/YYYY`; instants render their
|
|
* **literal wall time** as `MM/DD/YYYY h:mm AM/PM` — identical for every
|
|
* viewer, no timezone conversion. Legacy strings that predate
|
|
* canonicalization render via a runtime-local normalization; unparseable
|
|
* ones are returned as-is.
|
|
*/
|
|
export function formatDateCellDisplay(
|
|
stored: string,
|
|
options?: FormatDateCellDisplayOptions
|
|
): string {
|
|
const calendar = stored.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
|
if (calendar) return `${calendar[2]}/${calendar[3]}/${calendar[1]}`
|
|
if (UTC_MIDNIGHT_PATTERN.test(stored)) {
|
|
const date = new Date(stored)
|
|
return `${pad(date.getUTCMonth() + 1)}/${pad(date.getUTCDate())}/${date.getUTCFullYear()}`
|
|
}
|
|
const wall = stored.match(WALL_INSTANT_PATTERN)
|
|
if (wall) {
|
|
const [, year, month, day, hour, minute, second] = wall
|
|
return formatWallForDisplay(
|
|
month,
|
|
day,
|
|
year,
|
|
Number(hour),
|
|
minute,
|
|
Number(second ?? 0),
|
|
options?.seconds
|
|
)
|
|
}
|
|
const canonical = normalizeDateCellValue(stored)
|
|
if (!canonical) return stored
|
|
return formatDateCellDisplay(canonical, options)
|
|
}
|