session-calendar
Build a session calendar from trading hours, holidays, and early closes in a venue's time zone, so a quiet feed can be distinguished from a stale one.
import { createSessionCalendar, zonedInstant } from "@/lib/session-calendar"
const calendar = createSessionCalendar({
zone: "America/New_York",
sessions: [{ days: [1, 2, 3, 4, 5], open: "09:30", close: "16:00" }],
})
const at = zonedInstant("2026-09-23", "10:00", calendar.zone)
const next = calendar.nextTransition(at)
const left = calendar.timeToClose(at)
const time = new Intl.DateTimeFormat("en-GB", { timeZone: calendar.zone, dateStyle: "short", timeStyle: "short" })
export default function SessionCalendarDemo() {
return (
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<p className="text-muted-foreground">Illustrative weekday schedule · {calendar.zone}</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt>At</dt><dd>{time.format(at)}</dd>
<dt>Status</dt><dd>{calendar.status(at)}</dd>
<dt>Next change</dt><dd>{next ? `${next.status} at ${time.format(next.at)}` : "None"}</dd>
<dt>To close</dt><dd>{left === null ? "Not open" : `${left / 60_000} min`}</dd>
<dt>Trading day</dt><dd>{calendar.isTradingDay(at) ? "Yes" : "No"}</dd>
</dl>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/session-calendar#v2.0.0
npx shadcn@latest add tradecn/ui/session-calendar#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/session-calendar#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/session-calendar#v2.0.0
-
Copy the files into your project:
lib/session-calendar.ts // A session calendar: what tells "quiet" from "dead". feed-health takes a `SessionCalendar` and ships // none; this builds one from sessions, holidays, and early closes, in a venue's own time zone, through // Intl.DateTimeFormat and nothing else, so daylight saving is the runtime's problem and not this // file's. It ships no venue's data: the sessions, the holidays, and the early closes are yours. export type SessionStatus = "open" | "closed" | "pre" | "post" | "holiday" export const SESSION_STATUSES: readonly SessionStatus[] = ["open", "closed", "pre", "post", "holiday"] /** What feed-health reads. */ export interface SessionCalendar { status(now: number): SessionStatus } export interface SessionWindow { /** The days of the week the session opens on, 0 Sunday to 6 Saturday, in the calendar's zone. */ days: readonly number[] /** "HH:MM" in the zone. */ open: string /** "HH:MM" in the zone. At or before `open` means the session runs into the next day. */ close: string /** "HH:MM": the pre-open starts here, on the day the session opens. */ pre?: string /** "HH:MM": the post-close ends here, on the day the session closes. */ post?: string } export interface EarlyClose { /** "YYYY-MM-DD" in the zone: the date the close falls on. */ date: string /** "HH:MM" in the zone. */ close: string } export interface SessionCalendarOptions { /** An IANA zone: "America/New_York". */ zone: string sessions: readonly SessionWindow[] /** "YYYY-MM-DD" dates in the zone on which no session opens. */ holidays?: readonly string[] earlyCloses?: readonly EarlyClose[] } export interface SessionTransition { /** ms since the epoch. */ at: number /** The status from that instant. */ status: SessionStatus } export interface FullSessionCalendar extends SessionCalendar { readonly zone: string /** The next change of status after `now`, or null when the calendar has no sessions. */ nextTransition(now: number): SessionTransition | null /** ms until the open session closes, or null when none is open. */ timeToClose(now: number): number | null /** Whether a session opens on the date: a "YYYY-MM-DD" in the zone, or an instant read in the zone. */ isTradingDay(date: string | number | Date): boolean /** The instant's date, weekday, and minutes since midnight in the zone. */ local(now: number): LocalTime } export interface LocalTime { /** "YYYY-MM-DD". */ date: string /** 0 Sunday to 6 Saturday. */ weekday: number /** Minutes since local midnight. */ minutes: number } const WEEKDAYS: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } const formatters = new Map<string, Intl.DateTimeFormat>() function formatter(zone: string): Intl.DateTimeFormat { let f = formatters.get(zone) if (!f) { f = new Intl.DateTimeFormat("en-US", { timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", weekday: "short" }) formatters.set(zone, f) } return f } interface Wall { year: number month: number day: number hour: number minute: number second: number weekday: number } function wall(ms: number, zone: string): Wall { const out: Partial<Wall> = {} for (const part of formatter(zone).formatToParts(ms)) { if (part.type === "year") out.year = Number(part.value) else if (part.type === "month") out.month = Number(part.value) else if (part.type === "day") out.day = Number(part.value) else if (part.type === "hour") out.hour = Number(part.value) % 24 else if (part.type === "minute") out.minute = Number(part.value) else if (part.type === "second") out.second = Number(part.value) else if (part.type === "weekday") out.weekday = WEEKDAYS[part.value] ?? 0 } return out as Wall } const pad = (n: number) => String(n).padStart(2, "0") /** An instant's date, weekday, and minutes since midnight in a zone. Throws for a zone the runtime does not know. */ export function localTime(ms: number, zone: string): LocalTime { const w = wall(ms, zone) return { date: `${w.year}-${pad(w.month)}-${pad(w.day)}`, weekday: w.weekday, minutes: w.hour * 60 + w.minute } } /** Minutes since midnight from "HH:MM"; NaN when the text is not a time. */ export function parseTime(text: string): number { const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim()) if (!m) return NaN const h = Number(m[1]) const min = Number(m[2]) return h > 24 || min > 59 ? NaN : h * 60 + min } function splitDate(date: string): [number, number, number] { const [y, m, d] = date.split("-").map(Number) return [y ?? NaN, m ?? NaN, d ?? NaN] } /** "YYYY-MM-DD" moved by `days`. */ export function addDays(date: string, days: number): string { const [y, m, d] = splitDate(date) const t = new Date(Date.UTC(y, m - 1, d + days)) return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}` } /** The weekday of a "YYYY-MM-DD", 0 Sunday to 6 Saturday. */ export function weekdayOf(date: string): number { const [y, m, d] = splitDate(date) return new Date(Date.UTC(y, m - 1, d)).getUTCDay() } function wallUtc(ms: number, zone: string): number { const w = wall(ms, zone) return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) } /** * The instant a wall-clock time in a zone falls on. The wall time is read as if it were UTC, the * zone's offset at that instant is taken off, and the result is checked; when the offset changed in * between (a daylight-saving boundary) the other offset is tried. A time that happens twice, in the * hour the clocks fall back, resolves to whichever occurrence that check settles on: New York's * 01:30 is its first, London's its second. A time that never happens, in the hour they spring * forward, moves forward by the length of the gap, as 02:30 becomes 03:30. */ export function zonedInstant(date: string, time: string, zone: string): number { const [y, m, d] = splitDate(date) const minutes = parseTime(time) const wanted = Date.UTC(y, m - 1, d, Math.floor(minutes / 60), minutes % 60) const first = wanted - (wallUtc(wanted, zone) - wanted) if (wallUtc(first, zone) === wanted) return first const second = wanted - (wallUtc(first, zone) - first) if (wallUtc(second, zone) === wanted) return second return Math.max(first, second) } interface Span { pre: number open: number close: number post: number } /** A calendar over sessions, holidays, and early closes in one zone. */ export function createSessionCalendar(options: SessionCalendarOptions): FullSessionCalendar { const { zone } = options const sessions = options.sessions.filter((s) => !Number.isNaN(parseTime(s.open)) && !Number.isNaN(parseTime(s.close))) const holidays = new Set(options.holidays ?? []) const earlyCloses = new Map((options.earlyCloses ?? []).map((e) => [e.date, e.close] as const)) const tradingWeekdays = new Set(sessions.flatMap((s) => [...s.days])) // Spans by opening date, kept: a status read every second asks for the same few days. const spans = new Map<string, Span[]>() const isTradingDate = (date: string) => !holidays.has(date) && tradingWeekdays.has(weekdayOf(date)) function spansOpening(date: string): Span[] { let list = spans.get(date) if (list) return list list = [] if (!holidays.has(date)) { const weekday = weekdayOf(date) for (const s of sessions) { if (!s.days.includes(weekday)) continue const overnight = parseTime(s.close) <= parseTime(s.open) const closeDate = overnight ? addDays(date, 1) : date const closeTime = earlyCloses.get(closeDate) ?? s.close const open = zonedInstant(date, s.open, zone) const close = zonedInstant(closeDate, closeTime, zone) const pre = s.pre !== undefined && !Number.isNaN(parseTime(s.pre)) ? Math.min(open, zonedInstant(date, s.pre, zone)) : open const post = s.post !== undefined && !Number.isNaN(parseTime(s.post)) ? Math.max(close, zonedInstant(closeDate, s.post, zone)) : close list.push({ pre, open, close, post }) } } if (spans.size > 64) spans.clear() spans.set(date, list) return list } function around(now: number): Span[] { // Two days back: a next-day close with an hour-24 post stretches a span to // its opening date plus two, and status must agree with nextTransition there. const today = localTime(now, zone).date return [...spansOpening(addDays(today, -2)), ...spansOpening(addDays(today, -1)), ...spansOpening(today)] } function status(now: number): SessionStatus { let pre = false let post = false for (const span of around(now)) { if (span.open <= now && now < span.close) return "open" if (span.pre <= now && now < span.open) pre = true if (span.close <= now && now < span.post) post = true } if (pre) return "pre" if (post) return "post" const today = localTime(now, zone).date if (holidays.has(today) && tradingWeekdays.has(weekdayOf(today))) return "holiday" return "closed" } function nextTransition(now: number): SessionTransition | null { if (!sessions.length) return null const today = localTime(now, zone).date const marks: SessionTransition[] = [] for (let i = -2; i <= 21; i++) { const date = addDays(today, i) for (const span of spansOpening(date)) { if (span.pre < span.open) marks.push({ at: span.pre, status: "pre" }) marks.push({ at: span.open, status: "open" }) marks.push({ at: span.close, status: span.post > span.close ? "post" : "closed" }) if (span.post > span.close) marks.push({ at: span.post, status: "closed" }) } if (holidays.has(date) && tradingWeekdays.has(weekdayOf(date))) { marks.push({ at: zonedInstant(date, "00:00", zone), status: "holiday" }) marks.push({ at: zonedInstant(addDays(date, 1), "00:00", zone), status: "closed" }) } } marks.sort((a, b) => a.at - b.at) // A boundary is a change only when the status after it differs from the status before it. let current = status(now) for (const mark of marks) { if (mark.at <= now) continue const after = status(mark.at) if (after !== current) return { at: mark.at, status: after } current = after } return null } function timeToClose(now: number): number | null { for (const span of around(now)) if (span.open <= now && now < span.close) return span.close - now return null } return { zone, status, nextTransition, timeToClose, isTradingDay(date) { const text = typeof date === "string" ? date : localTime(date instanceof Date ? date.getTime() : date, zone).date return isTradingDate(text) }, local: (now) => localTime(now, zone), } }
Usage
import { createSessionCalendar, zonedInstant } from "@/lib/session-calendar"
const calendar = createSessionCalendar({
zone: "America/New_York",
sessions: [{ days: [1, 2, 3, 4, 5], open: "09:30", close: "16:00" }],
})
const at = zonedInstant("2026-09-23", "10:00", calendar.zone)
console.log(calendar.status(at)) // "open"
console.log(calendar.nextTransition(at)) // { at: ..., status: "closed" }; 16:00 New York
console.log(calendar.timeToClose(at)) // 21_600_000 ms (6 hours)
console.log(calendar.isTradingDay("2026-09-23")) // true
This illustrative schedule opens Monday through Friday. zonedInstant converts its local date and time to an epoch timestamp. The preview queries one fixed instant, so its status and time to close remain available to inspect. The calendar runs no timer; your application supplies each instant to query.
To use it with a feed, install feed-health separately and pass the calendar to its session prop. That component reads status(now) from the calendar; it supplies no venue schedule.
Calendar exceptions
This comparison adds pre-open and post-close windows, a sample holiday on September 24, and a 13:00 close on September 25. These dates are illustrative. Applications should use their venue's published schedule.
The holiday has no opening session. The early-close date remains a trading day: thirty minutes remain at 12:30, and the status is post at 13:30 because the configured post-close window still ends at 20:00. Saturday is closed. A trading day means a session is scheduled to open that day, even during pre-open or post-close hours. Scroll the table horizontally on narrow screens.
import { createSessionCalendar, zonedInstant } from "@/lib/session-calendar"
// Illustrative dates, not a venue's published schedule.
const calendar = createSessionCalendar({
zone: "America/New_York",
sessions: [{ days: [1, 2, 3, 4, 5], open: "09:30", close: "16:00", pre: "04:00", post: "20:00" }],
holidays: ["2026-09-24"],
earlyCloses: [{ date: "2026-09-25", close: "13:00" }],
})
const moments = [
{ date: "2026-09-23", time: "09:00" },
{ date: "2026-09-23", time: "13:30" },
{ date: "2026-09-24", time: "13:30" },
{ date: "2026-09-25", time: "12:30" },
{ date: "2026-09-25", time: "13:30" },
{ date: "2026-09-26", time: "13:30" },
]
export default function SessionCalendarExceptionsDemo() {
return (
<div role="region" aria-label="Calendar exceptions" tabIndex={0} className="w-fit max-w-full overflow-x-auto rounded border border-border text-xs lining-nums tabular-nums">
<table className="border-collapse text-left whitespace-nowrap">
<caption className="p-2 text-left text-muted-foreground">Illustrative schedule · {calendar.zone}</caption>
<thead><tr className="border-b border-border">
<th scope="col" className="px-2 py-1 font-medium">Local date and time</th>
<th scope="col" className="px-2 py-1 font-medium">Status</th>
<th scope="col" className="px-2 py-1 font-medium">To close</th>
<th scope="col" className="px-2 py-1 font-medium">Trading day</th>
</tr></thead>
<tbody>{moments.map(({ date, time }) => {
const at = zonedInstant(date, time, calendar.zone)
const left = calendar.timeToClose(at)
return (
<tr key={`${date} ${time}`} className="border-b border-border last:border-0">
<th scope="row" className="px-2 py-1 font-normal">{date} {time}</th>
<td className="px-2 py-1">{calendar.status(at)}</td>
<td className="px-2 py-1">{left === null ? "Not open" : `${left / 60_000} min`}</td>
<td className="px-2 py-1">{calendar.isTradingDay(date) ? "Yes" : "No"}</td>
</tr>
)
})}</tbody>
</table>
</div>
)
}
API Reference
Options
createSessionCalendar(options: SessionCalendarOptions) returns a FullSessionCalendar.
| Option | Type | Default | Purpose |
|---|---|---|---|
zone |
string |
Required | IANA time zone, such as "America/New_York" |
sessions |
readonly SessionWindow[] |
Required | Session windows; may be empty |
holidays |
readonly string[] |
[] |
Local YYYY-MM-DD dates on which no session opens |
earlyCloses |
readonly EarlyClose[] |
[] |
Close-time overrides by local closing date |
What feed-health reads
feed-health accepts a SessionCalendar with one method, status(now: number): SessionStatus. It defaults to always open and supplies no venue schedule. Passing this calendar lets it treat a connected feed's silence during closed or holiday hours as closed rather than stale.
The returned FullSessionCalendar has these members. Numeric instants are milliseconds since the Unix epoch.
| Member | Type | Result |
|---|---|---|
zone |
string |
The configured time zone; read-only |
status(now) |
(now: number) => SessionStatus |
Status at that instant |
nextTransition(now) |
(now: number) => SessionTransition | null |
Next status change strictly after now, or null if none is found in the scan |
timeToClose(now) |
(now: number) => number | null |
Milliseconds until the first matching open session closes; null when none is open |
isTradingDay(date) |
(date: string | number | Date) => boolean |
Whether a session is scheduled to open on the local date, excluding holidays |
local(now) |
(now: number) => LocalTime |
Local date, weekday, and minutes since midnight |
SessionTransition is { at: number, status: SessionStatus }: at is an epoch timestamp in milliseconds, and status applies from that instant. nextTransition checks session and holiday boundaries for opening dates from two days back through 21 days ahead in the calendar's zone, the same lookback status uses. It skips boundaries that leave the status unchanged and can return null with nonempty sessions, including a continuously open calendar.
For overlapping sessions, timeToClose checks earlier opening dates before today's, then sessions in input order. Its countdown can end while another session remains open; use nextTransition to find a change in the calendar's status.
isTradingDay reads a YYYY-MM-DD string directly and converts a number or Date to the calendar's zone. It checks opening dates, not settlement eligibility or whether an overnight session is still active. A Friday can return false while Thursday's session remains open.
Sessions
Each SessionWindow uses local times in the calendar's zone.
| Field | Type | Default | Purpose |
|---|---|---|---|
days |
readonly number[] |
Required | Opening weekdays: 0 Sunday through 6 Saturday |
open |
string |
Required | Opening time as HH:MM |
close |
string |
Required | Closing time as HH:MM; at or before open means the next day |
pre |
string |
open |
Pre-open start on the opening date, clamped to no later than open |
post |
string |
close |
Post-close end on the closing date, clamped to no earlier than the effective close |
An overnight session belongs to its opening date. A session opening at 17:00 and closing at 16:00 the next day is still open at 02:00 because it opened yesterday. The original open and close determine the closing date before any early-close override.
Sessions with an invalid open or close are ignored. Missing or invalid pre and post collapse to the open and effective close respectively, leaving no extended window.
SessionStatus follows this precedence across all sessions. The exported SESSION_STATUSES: readonly SessionStatus[] lists the five values.
| Status | When it applies |
|---|---|
"open" |
Any session is open |
"pre" |
No session is open and at least one is in its pre-open window |
"post" |
No session is open or pre-open and at least one is in its post-close window |
"holiday" |
None of those windows applies, and today is a holiday on a scheduled opening weekday |
"closed" |
None of the above |
Each window includes its start and excludes its end. At a session's close, it no longer contributes open; other windows still determine the status.
status and timeToClose consider opening dates from two days back through today — far enough that an hour-24 time on a next-day close cannot stretch a window past the lookup. A UTC Monday session from 17:00 to 16:00 with post: "24:30" reports post until Wednesday 00:30, and nextTransition reports that same cutoff.
Holidays and early closes
Holidays suppress sessions by opening date. They do not cancel an overnight session that opened the day before; its active windows take precedence over holiday.
Each EarlyClose overrides the close for sessions ending on that date. For an overnight session, name the day after it opens.
| Field | Type | Default | Purpose |
|---|---|---|---|
date |
string |
Required | Closing date as YYYY-MM-DD in the calendar's zone |
close |
string |
Required | Replacement close as HH:MM in that zone |
The last override for a date wins. It leaves the opening and pre-open times unchanged; post-close still ends at the configured post, clamped against the replacement close. Overrides are not checked against the open or normal close, so a close before open can leave the session in pre. Supply valid dates and close times; an invalid close time can throw when the calendar is read.
The library ships no venue schedule. Supply sessions, holidays, and early closes from the venue's published calendar. The examples use illustrative schedules, not exchange calendar data.
Time zones
Time-zone conversion uses the runtime's Intl.DateTimeFormat, with no external dependency. For example, 09:30 New York is 14:30 UTC in February and 13:30 UTC in June. A zone the runtime does not recognize throws when a conversion is attempted.
A time inside a spring-forward gap moves forward by the gap: New York's 2026-03-08 02:30 becomes 03:30. Repeated times depend on the offsets the implementation probes: New York's 2026-11-01 01:30 resolves to the first occurrence (05:30Z), but London's 2026-10-25 01:30 resolves to the second (01:30Z). There is no configurable ambiguity policy.
Standalone helpers
These exports use the same conversion rules as the calendar. Date strings are YYYY-MM-DD; numeric instants are epoch milliseconds. All arguments are required.
| Helper | Return type | Result |
|---|---|---|
localTime(ms: number, zone: string) |
LocalTime |
Date, weekday, and minutes since midnight in the zone |
zonedInstant(date: string, time: string, zone: string) |
number |
Epoch milliseconds for a local date and time |
addDays(date: string, days: number) |
string |
Date moved by the given number of calendar days; negative values move backward |
weekdayOf(date: string) |
number |
Weekday, 0 Sunday through 6 Saturday |
parseTime(text: string) |
number |
Minutes since midnight, or NaN for an invalid time |
LocalTime is { date: string, weekday: number, minutes: number }. minutes counts whole minutes and omits seconds.
parseTime trims whitespace and accepts one or two hour digits (0–24) and exactly two minute digits (00–59). For example, "9:05" returns 545, "24:00" returns 1440, and "9.30" returns NaN. It also accepts "24:59"; zonedInstant normalizes hour 24 into the following date. Called directly, zonedInstant and localTime throw a RangeError for a time parseTime rejects, a date with fewer than three parts or a non-numeric one, an instant outside the Date range, or an unknown zone. Dates are otherwise read leniently, as Number and Date.UTC read them: an empty part reads as 0, "2026-02-31" becomes March 3, "2026-02-00" becomes January 31, and "1-2-3" becomes 1901-02-03, so validate dates before passing them.
What it does not do
The calendar reads no feed and runs no timer. Pass the clock's now to its methods, as feed-health does, or schedule a timer from a non-null nextTransition and query again when it fires.
This page is docs/session-calendar.md at v2.0.0.