chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* A curated fallback for runtimes without `Intl.supportedValuesOf` (e.g. Safari
|
||||
* < 15.4), so the timezone picker is never an empty dead-end.
|
||||
*/
|
||||
const COMMON_TIMEZONES = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Sao_Paulo',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Singapore',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Tokyo',
|
||||
'Australia/Sydney',
|
||||
]
|
||||
|
||||
/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
|
||||
export function getBrowserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
/**
|
||||
* Every IANA timezone identifier the runtime knows, for populating a picker;
|
||||
* falls back to a curated common set on runtimes without `Intl.supportedValuesOf`.
|
||||
*/
|
||||
export function getSupportedTimezones(): string[] {
|
||||
const zones =
|
||||
typeof Intl.supportedValuesOf === 'function'
|
||||
? Intl.supportedValuesOf('timeZone')
|
||||
: COMMON_TIMEZONES
|
||||
return zones.includes('UTC') ? zones : ['UTC', ...zones]
|
||||
}
|
||||
|
||||
/** A timezone choice for a picker: the canonical IANA value plus a display label. */
|
||||
export interface TimezoneOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** The city/locale portion of an IANA id, formatted for display (e.g. `Los Angeles`). */
|
||||
function timezoneCity(timeZone: string): string {
|
||||
return (timeZone.split('/').pop() ?? timeZone).replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** `GMT±HH:MM` for an offset expressed in minutes east of UTC (e.g. `GMT-08:00`). */
|
||||
function formatGmtOffset(offsetMinutes: number): string {
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||
const absMinutes = Math.abs(offsetMinutes)
|
||||
const hours = String(Math.floor(absMinutes / 60)).padStart(2, '0')
|
||||
const minutes = String(absMinutes % 60).padStart(2, '0')
|
||||
return `GMT${sign}${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Timezone options for a picker. Each zone reads as `City (GMT±HH:MM)` — city
|
||||
* first, offset for reference — and the list is sorted alphabetically by city,
|
||||
* the order usability research (NN/g, Smart Interface Design Patterns) found
|
||||
* users expect; offset-sorting confuses people who don't know their offset. The
|
||||
* offset is computed live, so it tracks DST automatically. Pair this with the
|
||||
* picker's search and a browser-detected default. Values stay canonical IANA
|
||||
* ids — what we persist.
|
||||
*/
|
||||
export function getTimezoneOptions(): TimezoneOption[] {
|
||||
const now = new Date()
|
||||
return getSupportedTimezones()
|
||||
.map((value) => ({
|
||||
value,
|
||||
city: timezoneCity(value),
|
||||
offsetMinutes: Math.round(timezoneOffsetMs(now, value) / 60_000),
|
||||
}))
|
||||
.sort((a, b) => a.city.localeCompare(b.city))
|
||||
.map(({ value, city, offsetMinutes }) => ({
|
||||
value,
|
||||
label: `${city} (${formatGmtOffset(offsetMinutes)})`,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
|
||||
* string. Lets callers reason about a user's local date/time without UTC — e.g.
|
||||
* to recover the local date/time a stored task instant represents in its zone.
|
||||
*/
|
||||
export function zonedWallClock(instant: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(instant)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`
|
||||
}
|
||||
|
||||
/** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */
|
||||
export function wallClockNow(timeZone: string): string {
|
||||
return zonedWallClock(new Date(), timeZone)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Date` whose device-local fields (year…minute) equal the wall-clock time of
|
||||
* `instant` in `timeZone`. It deliberately does NOT represent the same instant —
|
||||
* it is a positioning coordinate that lets naive-local layout code (the calendar
|
||||
* grid, {@link zonedWallClock}-free pixel offsets) render in `timeZone` without
|
||||
* itself being timezone-aware. Never read its `getTime()` as a real timestamp;
|
||||
* the true instant always lives alongside it (e.g. a task's `runAt`).
|
||||
*/
|
||||
export function zonedClockDate(instant: Date, timeZone: string): Date {
|
||||
const [datePart, timePart] = zonedWallClock(instant, timeZone).split('T')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hour, minute] = timePart.split(':').map(Number)
|
||||
return new Date(year, month - 1, day, hour, minute)
|
||||
}
|
||||
|
||||
/** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */
|
||||
function timezoneOffsetMs(instant: Date, timeZone: string): number {
|
||||
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(instant)
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
|
||||
const asUtc = Date.UTC(
|
||||
get('year'),
|
||||
get('month') - 1,
|
||||
get('day'),
|
||||
get('hour'),
|
||||
get('minute'),
|
||||
get('second')
|
||||
)
|
||||
return asUtc - instant.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a naive `yyyy-MM-ddTHH:mm[:ss]` wall-clock — interpreted as local
|
||||
* time in `timeZone` — to the exact UTC instant. It resolves to the instant
|
||||
* whose own offset reproduces the requested wall-clock, which is correct for any
|
||||
* date (including future ones whose offset differs from today's) and across DST:
|
||||
* a naive single pass reads the offset on the wrong side of a same-day boundary
|
||||
* — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
|
||||
* fall-back wall-clock the later (post-transition) instant is chosen; a
|
||||
* wall-clock in the spring-forward gap (a nonexistent local hour) has no
|
||||
* self-consistent instant and resolves forward by the DST shift, matching how
|
||||
* calendar apps treat that once-a-year hour.
|
||||
*/
|
||||
export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date {
|
||||
const [datePart, timePart] = wallClock.split('T')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hour, minute, second = 0] = timePart.split(':').map(Number)
|
||||
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
|
||||
const guessOffset = timezoneOffsetMs(new Date(utcGuess), timeZone)
|
||||
const candidate = utcGuess - guessOffset
|
||||
const candidateOffset = timezoneOffsetMs(new Date(candidate), timeZone)
|
||||
if (candidateOffset === guessOffset) return new Date(candidate)
|
||||
const adjusted = utcGuess - candidateOffset
|
||||
return timezoneOffsetMs(new Date(adjusted), timeZone) === candidateOffset
|
||||
? new Date(adjusted)
|
||||
: new Date(candidate)
|
||||
}
|
||||
Reference in New Issue
Block a user