Luxon provides immutable, timezone-aware date handling — DateTime.now() returns the current moment. DateTime.fromISO("2024-01-15T10:30:00Z") parses ISO strings. DateTime.fromFormat("15/01/2024", "dd/MM/yyyy") parses custom formats. dt.plus({ days: 7, hours: 2 }) adds durations. dt.minus({ months: 1 }) subtracts. dt.startOf("week") snaps to start of period. dt.diff(other, ["days", "hours"]) computes differences. dt.setZone("America/New_York") converts timezone. dt.setLocale("fr").toLocaleString(DateTime.DATE_FULL) formats in locale. dt.toRelative() returns “3 hours ago”. dt.toISO() outputs ISO 8601. Interval.fromDateTimes(start, end) represents a range with .contains(), .count("days"). Duration.fromObject({ hours: 2, minutes: 30 }). Settings.defaultLocale = "en-US" sets global locale. Claude Code generates Luxon date utilities, timezone converters, duration formatters, and calendar helpers.
CLAUDE.md for Luxon
## Luxon Stack
- Version: luxon >= 3.5
- TypeScript: npm install @types/luxon
- Now: const now = DateTime.now() — timezone-aware, locale-aware
- Parse ISO: DateTime.fromISO("2024-01-15") — returns Invalid if wrong
- Parse custom: DateTime.fromFormat("01/15/2024", "MM/dd/yyyy")
- Math: dt.plus({ weeks: 2 }).minus({ hours: 3 })
- Format: dt.toFormat("yyyy-MM-dd") — custom tokens
- Locale: dt.setLocale("de").toLocaleString(DateTime.DATETIME_MED)
- Timezone: dt.setZone("Europe/Paris") or dt.toLocal() / dt.toUTC()
- Relative: dt.toRelative() → "5 minutes ago"
- Diff: end.diff(start, ["days", "hours"]).toObject()
- Interval: Interval.fromDateTimes(start, end).count("days")
Date Utility Library
// lib/dates/utils.ts — Luxon date helpers for a web app
import { DateTime, Duration, Interval, Settings } from "luxon"
// Global defaults — set once at app startup
Settings.defaultLocale = "en-US"
Settings.throwOnInvalid = true // Surface invalid dates early
// ── Parsing ────────────────────────────────────────────────────────────────
/** Parse an ISO string or epoch ms, return null on invalid */
export function parseDate(value: string | number | null | undefined): DateTime | null {
if (!value) return null
if (typeof value === "number") {
const dt = DateTime.fromMillis(value)
return dt.isValid ? dt : null
}
const dt = DateTime.fromISO(value)
return dt.isValid ? dt : null
}
/** Parse user-input date in locale-appropriate format with fallback */
export function parseUserDate(input: string, locale = "en-US"): DateTime | null {
const formats = ["MM/dd/yyyy", "dd/MM/yyyy", "yyyy-MM-dd", "d MMM yyyy", "MMMM d, yyyy"]
for (const fmt of formats) {
const dt = DateTime.fromFormat(input, fmt, { locale })
if (dt.isValid) return dt
}
// Last resort: try ISO
const iso = DateTime.fromISO(input)
return iso.isValid ? iso : null
}
// ── Formatting ─────────────────────────────────────────────────────────────
export function formatDate(dt: DateTime | string | null, locale = "en-US"): string {
const parsed = typeof dt === "string" ? parseDate(dt) : dt
if (!parsed) return "—"
return parsed.setLocale(locale).toLocaleString(DateTime.DATE_MED)
}
export function formatDateTime(dt: DateTime | string | null, locale = "en-US"): string {
const parsed = typeof dt === "string" ? parseDate(dt) : dt
if (!parsed) return "—"
return parsed.setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
}
export function formatRelative(dt: DateTime | string | null): string {
const parsed = typeof dt === "string" ? parseDate(dt) : dt
if (!parsed) return "—"
return parsed.toRelative() ?? formatDate(parsed)
}
export function formatDuration(ms: number): string {
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(1)}h`
return `${Math.round(ms / 86_400_000)}d`
}
// ── Calendar math ──────────────────────────────────────────────────────────
export function getWeekDays(referenceDate?: DateTime): DateTime[] {
const start = (referenceDate ?? DateTime.now()).startOf("week")
return Array.from({ length: 7 }, (_, i) => start.plus({ days: i }))
}
export function getMonthGrid(year: number, month: number): (DateTime | null)[][] {
const firstDay = DateTime.local(year, month, 1)
const daysInMonth = firstDay.daysInMonth ?? 31
const startPad = firstDay.weekday % 7 // 0 = Sunday
const cells: (DateTime | null)[] = [
...Array<null>(startPad).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => firstDay.plus({ days: i })),
]
// Pad end to complete last week
while (cells.length % 7 !== 0) cells.push(null)
const rows: (DateTime | null)[][] = []
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
return rows
}
export function getDateRange(start: DateTime, end: DateTime): DateTime[] {
const interval = Interval.fromDateTimes(start.startOf("day"), end.endOf("day"))
const count = Math.ceil(interval.count("days"))
return Array.from({ length: count }, (_, i) => start.startOf("day").plus({ days: i }))
}
// ── Business logic ─────────────────────────────────────────────────────────
export function isWithinBusinessHours(dt: DateTime, timezone: string): boolean {
const local = dt.setZone(timezone)
const hour = local.hour
const dow = local.weekday // 1=Mon, 7=Sun
return dow <= 5 && hour >= 9 && hour < 17
}
export function nextBusinessDay(from?: DateTime): DateTime {
let d = (from ?? DateTime.now()).plus({ days: 1 }).startOf("day")
while (d.weekday > 5) d = d.plus({ days: 1 }) // Skip weekends
return d
}
export function workdaysBetween(start: DateTime, end: DateTime): number {
const days = getDateRange(start, end)
return days.filter((d) => d.weekday <= 5).length
}
React Date Picker Component
// components/dates/DateRangePicker.tsx — calendar range selector using Luxon
"use client"
import { useState, useCallback } from "react"
import { DateTime } from "luxon"
import { getMonthGrid, getWeekDays } from "@/lib/dates/utils"
interface DateRangePickerProps {
value?: { start: DateTime; end: DateTime }
onChange?: (range: { start: DateTime; end: DateTime }) => void
minDate?: DateTime
maxDate?: DateTime
locale?: string
}
const WEEKDAY_HEADERS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
export function DateRangePicker({ value, onChange, minDate, maxDate, locale = "en-US" }: DateRangePickerProps) {
const [viewMonth, setViewMonth] = useState(() => (value?.start ?? DateTime.now()).startOf("month"))
const [hovered, setHovered] = useState<DateTime | null>(null)
const [selecting, setSelecting] = useState<DateTime | null>(null)
const grid = getMonthGrid(viewMonth.year, viewMonth.month)
const isInRange = useCallback((day: DateTime): boolean => {
const { start, end } = value ?? {}
if (!start) return false
if (selecting && !end) {
const rangeEnd = hovered ?? selecting
const lo = start < rangeEnd ? start : rangeEnd
const hi = start < rangeEnd ? rangeEnd : start
return day >= lo && day <= hi
}
return !!end && day >= start && day <= end
}, [value, selecting, hovered])
const handleDayClick = useCallback((day: DateTime) => {
if (!selecting) {
setSelecting(day)
onChange?.({ start: day, end: day })
} else {
const start = selecting < day ? selecting : day
const end = selecting < day ? day : selecting
onChange?.({ start, end })
setSelecting(null)
}
}, [selecting, onChange])
const isDisabled = (day: DateTime) =>
(minDate ? day < minDate.startOf("day") : false) ||
(maxDate ? day > maxDate.endOf("day") : false)
return (
<div className="rounded-xl border bg-card p-4 w-72 shadow-md">
{/* Month nav */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setViewMonth((m) => m.minus({ months: 1 }))}
className="size-8 rounded-lg hover:bg-muted flex items-center justify-center text-sm"
>‹</button>
<span className="text-sm font-medium">
{viewMonth.setLocale(locale).toLocaleString({ month: "long", year: "numeric" })}
</span>
<button
onClick={() => setViewMonth((m) => m.plus({ months: 1 }))}
className="size-8 rounded-lg hover:bg-muted flex items-center justify-center text-sm"
>›</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{WEEKDAY_HEADERS.map((d) => (
<div key={d} className="text-center text-xs text-muted-foreground py-1">{d}</div>
))}
</div>
{/* Days grid */}
{grid.map((week, wi) => (
<div key={wi} className="grid grid-cols-7">
{week.map((day, di) => {
if (!day) return <div key={di} />
const isToday = day.hasSame(DateTime.now(), "day")
const isStart = value?.start && day.hasSame(value.start, "day")
const isEnd = value?.end && day.hasSame(value.end, "day")
const inRange = isInRange(day)
const disabled = isDisabled(day)
return (
<button
key={di}
disabled={disabled}
onClick={() => handleDayClick(day)}
onMouseEnter={() => selecting && setHovered(day)}
onMouseLeave={() => setHovered(null)}
className={[
"text-xs py-1.5 text-center transition-colors rounded-lg",
disabled ? "opacity-30 cursor-not-allowed" : "cursor-pointer",
isStart || isEnd ? "bg-primary text-primary-foreground font-semibold" : "",
inRange && !isStart && !isEnd ? "bg-primary/15 rounded-none" : "",
isToday && !isStart && !isEnd ? "ring-1 ring-primary" : "",
!disabled && !isStart && !isEnd ? "hover:bg-muted" : "",
].filter(Boolean).join(" ")}
>
{day.day}
</button>
)
})}
</div>
))}
{/* Selection summary */}
{value?.start && (
<div className="mt-3 pt-3 border-t text-xs text-muted-foreground flex justify-between">
<span>{value.start.setLocale(locale).toLocaleString(DateTime.DATE_SHORT)}</span>
<span>→</span>
<span>{value.end?.setLocale(locale).toLocaleString(DateTime.DATE_SHORT) ?? "…"}</span>
</div>
)}
</div>
)
}
For the date-fns alternative when tree-shakeable pure functions with no class wrapping, minimal bundle per-function, and a simpler functional style are preferred — date-fns has smaller per-function imports while Luxon provides a richer instance API with built-in timezone support without needing date-fns-tz, see the date-fns guide. For the Day.js alternative when a Moment.js-compatible minimal API (~2KB), optional plugin system, and broad familiarity from Moment.js migrations are needed — Day.js is even lighter than Luxon while Luxon has stronger timezone support via the Intl API and better TypeScript ergonomics, see the Day.js guide. The Claude Skills 360 bundle includes Luxon skill sets covering parsing, formatting, timezone handling, and calendar math. Start with the free tier to try date utility generation.