Files
agent-desk-ng/web/lib/support-availability.ts
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
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.
2026-10-16 15:21:42 +00:00

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