import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { MoreHorizontalIcon, PlusIcon, Trash2Icon, ServerIcon, ClockIcon, CalendarIcon, ActivityIcon, PenSquareIcon, } from "lucide-react" import { useEffect, useState } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useToast } from "@/components/ui/use-toast" import { pb } from "@/lib/api" import { $systems } from "@/lib/stores" import { formatShortDate } from "@/lib/utils" import type { QuietHoursRecord, SystemRecord } from "@/types" export function QuietHours() { const [data, setData] = useState([]) const [dialogOpen, setDialogOpen] = useState(false) const [editingRecord, setEditingRecord] = useState(null) const { toast } = useToast() const systems = useStore($systems) useEffect(() => { let unsubscribe: (() => void) | undefined const pbOptions = { expand: "system", fields: "id,user,system,type,start,end,expand.system.name", } // Initial load pb.collection("quiet_hours") .getList(0, 200, { ...pbOptions, sort: "system", }) .then(({ items }) => setData(items)) // Subscribe to changes ;(async () => { unsubscribe = await pb.collection("quiet_hours").subscribe( "*", (e) => { if (e.action === "create") { setData((current) => [e.record as QuietHoursRecord, ...current]) } if (e.action === "update") { setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r))) } if (e.action === "delete") { setData((current) => current.filter((r) => r.id !== e.record.id)) } }, pbOptions ) })() // Unsubscribe on unmount return () => unsubscribe?.() }, []) const handleDelete = async (id: string) => { try { await pb.collection("quiet_hours").delete(id) } catch (e: unknown) { toast({ variant: "destructive", title: t`Error`, description: (e as Error).message || "Failed to delete quiet hours.", }) } } const openEditDialog = (record: QuietHoursRecord) => { setEditingRecord(record) setDialogOpen(true) } const closeDialog = () => { setDialogOpen(false) setEditingRecord(null) } const formatDateTime = (record: QuietHoursRecord) => { if (record.type === "daily") { // For daily windows, show only time const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) return `${startTime} - ${endTime}` } // For one-time windows, show full date and time const start = formatShortDate(record.start) const end = formatShortDate(record.end) return `${start} - ${end}` } const getWindowState = (record: QuietHoursRecord): "active" | "past" | "future" => { const now = new Date() if (record.type === "daily") { // For daily windows, check if current time is within the window const startDate = new Date(record.start) const endDate = new Date(record.end) // Get current time in local timezone const currentMinutes = now.getHours() * 60 + now.getMinutes() const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes() const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes() // Convert UTC to local time offset const offset = now.getTimezoneOffset() const localStartMinutes = (startMinutes - offset + 1440) % 1440 const localEndMinutes = (endMinutes - offset + 1440) % 1440 // Handle cases where window spans midnight if (localStartMinutes <= localEndMinutes) { return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "future" } else { return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "future" } } else { // For one-time windows const startDate = new Date(record.start) const endDate = new Date(record.end) if (now >= startDate && now < endDate) { return "active" } else if (now >= endDate) { return "past" } else { return "future" } } } return ( <>

Quiet hours

Schedule quiet hours where notifications will not be sent, such as during maintenance periods.

{data.length > 0 && (
System Type State Schedule Actions {data.map((record) => ( {record.system ? record.expand?.system?.name || record.system : All Systems} {record.type === "daily" ? Daily : One-time} {(() => { const state = getWindowState(record) const stateConfig = { active: { label: Active, variant: "success" as const }, past: { label: Past, variant: "danger" as const }, future: { label: Future, variant: "default" as const }, } const config = stateConfig[state] return {config.label} })()} {formatDateTime(record)} openEditDialog(record)}> Edit handleDelete(record.id)}> Delete ))}
)} ) } // Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time function formatDateTimeLocal(date: Date): string { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, "0") const day = String(date.getDate()).padStart(2, "0") const hours = String(date.getHours()).padStart(2, "0") const minutes = String(date.getMinutes()).padStart(2, "0") return `${year}-${month}-${day}T${hours}:${minutes}` } function QuietHoursDialog({ editingRecord, systems, onClose, toast, }: { editingRecord: QuietHoursRecord | null systems: SystemRecord[] onClose: () => void toast: any }) { const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "") const [isGlobal, setIsGlobal] = useState(!editingRecord?.system) const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time") const [startDateTime, setStartDateTime] = useState("") const [endDateTime, setEndDateTime] = useState("") const [startTime, setStartTime] = useState("") const [endTime, setEndTime] = useState("") useEffect(() => { if (editingRecord) { setSelectedSystem(editingRecord.system || "") setIsGlobal(!editingRecord.system) setWindowType(editingRecord.type) if (editingRecord.type === "daily") { // Extract time from datetime const start = new Date(editingRecord.start) const end = editingRecord.end ? new Date(editingRecord.end) : null setStartTime(start.toTimeString().slice(0, 5)) setEndTime(end ? end.toTimeString().slice(0, 5) : "") } else { // For one-time, format as datetime-local (local time, not UTC) const startDate = new Date(editingRecord.start) const endDate = editingRecord.end ? new Date(editingRecord.end) : null setStartDateTime(formatDateTimeLocal(startDate)) setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "") } } else { // Reset form with default dates: today at 12pm and 1pm const today = new Date() const noon = new Date(today) noon.setHours(12, 0, 0, 0) const onePm = new Date(today) onePm.setHours(13, 0, 0, 0) setSelectedSystem("") setIsGlobal(true) setWindowType("one-time") setStartDateTime(formatDateTimeLocal(noon)) setEndDateTime(formatDateTimeLocal(onePm)) setStartTime("12:00") setEndTime("13:00") } }, [editingRecord]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() try { let startValue: string let endValue: string | undefined if (windowType === "daily") { // For daily windows, convert local time to UTC // Create a date with the time in local timezone, then convert to UTC const startDate = new Date(`2000-01-01T${startTime}:00`) startValue = startDate.toISOString() if (endTime) { const endDate = new Date(`2000-01-01T${endTime}:00`) endValue = endDate.toISOString() } } else { // For one-time windows, use the datetime values startValue = new Date(startDateTime).toISOString() endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined } const data = { user: pb.authStore.record?.id, system: isGlobal ? undefined : selectedSystem, type: windowType, start: startValue, end: endValue, } if (editingRecord) { await pb.collection("quiet_hours").update(editingRecord.id, data) toast({ title: t`Updated`, description: t`Quiet hours have been updated.`, }) } else { await pb.collection("quiet_hours").create(data) toast({ title: t`Created`, description: t`Quiet hours have been created.`, }) } onClose() } catch (e) { toast({ variant: "destructive", title: t`Error`, description: t`Failed to save quiet hours.`, }) } } return ( {editingRecord ? Edit Quiet Hours : Add Quiet Hours} Configure quiet hours where notifications will not be sent.
setIsGlobal(value === "global")}> All Systems Specific System
{/* Hidden input for native form validation */} {}} required={!isGlobal} />
{windowType === "one-time" ? ( <>
setStartDateTime(e.target.value)} min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))} required className="tabular-nums tracking-tighter" />
setEndDateTime(e.target.value)} min={startDateTime || formatDateTimeLocal(new Date())} required className="tabular-nums tracking-tighter" />
) : (
setStartTime(e.target.value)} required />
setEndTime(e.target.value)} required />
)}
) }