Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
71 lines
4.3 KiB
TypeScript
71 lines
4.3 KiB
TypeScript
import type { SupportCalendar } from "@/lib/api/support-availability"
|
|
|
|
export function isValidIanaTimezone(value: string): boolean {
|
|
// Intl also accepts offset identifiers on some engines; the server expects an IANA name.
|
|
if (!value || /^[+-]/.test(value)) return false
|
|
try { new Intl.DateTimeFormat("en-US", { timeZone: value }).format(); return true } catch { return false }
|
|
}
|
|
export function calendarValidationKey(calendar: SupportCalendar): string | null {
|
|
if ((!calendar.timezone && calendar.teamId === 0) || (calendar.timezone && !isValidIanaTimezone(calendar.timezone))) return "supportAvailability.invalidTimezone"
|
|
if (calendar.mode === "legacy") return null
|
|
const validTime = (value: string) => /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)
|
|
const validPeriod = (period: { start: string; end: string }) => validTime(period.start) && (period.end === "24:00" || validTime(period.end)) && period.start !== period.end
|
|
if (calendar.weekly.some(p => !Number.isInteger(p.weekday) || p.weekday < 1 || p.weekday > 7 || !validPeriod(p))) return "supportAvailability.invalidHours"
|
|
const dates = new Set<string>()
|
|
for (const day of calendar.exceptions) {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(day.date) || !Number.isFinite(Date.parse(`${day.date}T00:00:00Z`)) || new Date(`${day.date}T00:00:00Z`).toISOString().slice(0, 10) !== day.date || dates.has(day.date)) return "supportAvailability.invalidExceptionDate"
|
|
dates.add(day.date)
|
|
if (!day.closed && (day.periods.length === 0 || day.periods.some(p => !validPeriod(p)))) return "supportAvailability.invalidHours"
|
|
}
|
|
return null
|
|
}
|
|
export function toLocalDateTime(value?: string): string {
|
|
if (!value) return ""
|
|
const date = new Date(value)
|
|
if (!Number.isFinite(date.getTime())) return ""
|
|
const pad = (n: number) => String(n).padStart(2, "0")
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
}
|
|
export function localDateTimeToISO(value: string): string | null {
|
|
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return null
|
|
const date = new Date(value)
|
|
// Reject invalid dates and the DST gap rather than silently shifting the user's input.
|
|
if (!Number.isFinite(date.getTime()) || toLocalDateTime(date.toISOString()) !== value) return null
|
|
return date.toISOString()
|
|
}
|
|
export function vacationValidationKey(start: string, end: string): string | null {
|
|
const first = localDateTimeToISO(start), last = localDateTimeToISO(end)
|
|
return !first || !last || Date.parse(first) >= Date.parse(last) ? "supportAvailability.invalidVacation" : null
|
|
}
|
|
export function formatAvailabilityTime(value: string | undefined, timezone: string, locale: string): string {
|
|
if (!value) return ""
|
|
const date = new Date(value)
|
|
if (!Number.isFinite(date.getTime())) return ""
|
|
// Legacy process zones can be named "Local". Show the actual UTC instant, not an invented offset.
|
|
if (!isValidIanaTimezone(timezone)) timezone = "UTC"
|
|
// This feature explicitly previews a configured timezone, unlike the default app timestamp formatter.
|
|
return new Intl.DateTimeFormat(locale, { timeZone: timezone, year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" }).format(date)
|
|
}
|
|
|
|
// Explicit alternatives for repeated local wall-clock times during a DST overlap.
|
|
export function localDateTimeCandidates(value: string): string[] {
|
|
const first = localDateTimeToISO(value)
|
|
if (!first) return []
|
|
const result = new Set([first])
|
|
const timestamp = Date.parse(first)
|
|
for (let minutes = -180; minutes <= 180; minutes += 15) {
|
|
const candidate = new Date(timestamp + minutes * 60000).toISOString()
|
|
if (toLocalDateTime(candidate) === value) result.add(candidate)
|
|
}
|
|
return [...result].sort()
|
|
}
|
|
export function selectedLocalDateTimeISO(value: string, occurrence: string): string | null {
|
|
const candidates = localDateTimeCandidates(value)
|
|
return candidates.includes(occurrence) ? occurrence : candidates[0] ?? null
|
|
}
|
|
|
|
// Closing an exception ignores its draft periods but does not erase them while editing.
|
|
export function supportCalendarPayload(calendar: SupportCalendar): SupportCalendar {
|
|
return { ...calendar, timezone: calendar.timezone.trim(), weekly: calendar.weekly.map(period => ({ ...period })), exceptions: calendar.exceptions.map(day => ({ ...day, periods: day.closed ? [] : day.periods.map(period => ({ ...period })) })) }
|
|
}
|