Files
simstudioai--sim/apps/sim/lib/table/dates.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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)
}