diff --git a/apps/dashboard/src/components/common/date-time-picker.tsx b/apps/dashboard/src/components/common/date-time-picker.tsx new file mode 100644 index 00000000..0ab2157a --- /dev/null +++ b/apps/dashboard/src/components/common/date-time-picker.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { Calendar } from "@openstatus/ui/components/ui/calendar"; +import { Input } from "@openstatus/ui/components/ui/input"; +import { Label } from "@openstatus/ui/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@openstatus/ui/components/ui/popover"; +import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; +import { cn } from "@openstatus/ui/lib/utils"; +import { format, startOfDay } from "date-fns"; +import { useId } from "react"; + +type DateTimePickerProps = { + value: Date; + onChange: (date: Date) => void; + /** Bounds, inclusive; a picked time is clamped into them. */ + min?: Date; + max?: Date; +} & Omit, "value" | "onChange">; + +/** + * Calendar + time field in a popover. The time input is uncontrolled: a + * controlled one gets its value attribute rewritten per keystroke and WebKit + * then drops the focused segment. It only follows `value` on open, so an + * external change while the popover is open is not reflected in it. + */ +export function DateTimePicker({ + value, + onChange, + min, + max, + className, + size = "sm", + ...props +}: DateTimePickerProps) { + const mobile = useIsMobile(); + const id = useId(); + + /** Clamps into the bounds and reports a change; returns what was applied. */ + function commit(next: Date) { + if (Number.isNaN(next.getTime())) return null; + const clamped = min && next < min ? min : max && next > max ? max : next; + if (clamped.getTime() !== value.getTime()) onChange(clamped); + return clamped; + } + + return ( + + + + + + { + if (!day) return; + const next = new Date(day); + next.setHours( + value.getHours(), + value.getMinutes(), + value.getSeconds(), + value.getMilliseconds(), + ); + commit(next); + }} + disabled={(day) => + (min ? day < startOfDay(min) : false) || (max ? day > max : false) + } + initialFocus + /> +
+ +
+ { + // "" while a segment is cleared + if (!e.target.value) return; + const [hours, minutes] = e.target.value.split(":").map(Number); + const next = new Date(value); + next.setHours(hours, minutes); + const applied = commit(next); + // show the clamped time instead of what was typed + if (applied && applied.getTime() !== next.getTime()) { + e.target.value = format(applied, "HH:mm"); + } + }} + /> +
+
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/content/property-list.tsx b/apps/dashboard/src/components/content/property-list.tsx index 7cc2ebdf..1f07b966 100644 --- a/apps/dashboard/src/components/content/property-list.tsx +++ b/apps/dashboard/src/components/content/property-list.tsx @@ -3,6 +3,7 @@ import { Input } from "@openstatus/ui/components/ui/input"; import { SelectTrigger } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; +import { DateTimePicker } from "@/components/common/date-time-picker"; import { Link } from "@/components/common/link"; export function PropertyList({ @@ -136,3 +137,20 @@ export function PropertyInput({ /> ); } + +export function PropertyDateTimePicker({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/components/forms/incident/form.tsx b/apps/dashboard/src/components/forms/incident/form.tsx index 9e01685b..3ddaa962 100644 --- a/apps/dashboard/src/components/forms/incident/form.tsx +++ b/apps/dashboard/src/components/forms/incident/form.tsx @@ -28,6 +28,7 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { DateTimePicker } from "@/components/common/date-time-picker"; import { Link } from "@/components/common/link"; import { FormCardContent, @@ -35,7 +36,6 @@ import { } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { personName, severityConfig } from "@/data/managed-incidents.client"; -import { formatDateForInput } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; @@ -47,10 +47,9 @@ const schema = z.object({ summary: z.string().max(4000), commanderId: z.string(), startedAt: z - .string() - .min(1, "Start time is required.") + .date() .refine( - (value) => new Date(value) <= new Date(), + (value) => value <= new Date(), "Start time cannot be in the future.", ), statusReportId: z.string(), @@ -96,7 +95,7 @@ export function FormDeclareIncident({ // user.get is prefetched in the dashboard layout, so it is hydrated // before the sheet can mount and the default is never NONE for members. commanderId: user ? String(user.id) : NONE, - startedAt: formatDateForInput(new Date()), + startedAt: new Date(), statusReportId: NONE, openSlackChannel: slack === "ready", ...defaultValues, @@ -120,7 +119,7 @@ export function FormDeclareIncident({ summary: values.summary || undefined, commanderId: values.commanderId === NONE ? null : Number(values.commanderId), - startedAt: new Date(values.startedAt), + startedAt: values.startedAt, statusReportId: values.statusReportId === NONE ? undefined @@ -239,7 +238,12 @@ export function FormDeclareIncident({ Started at - + When the impact began. Set it in the past to record an diff --git a/apps/dashboard/src/components/forms/maintenance/form.tsx b/apps/dashboard/src/components/forms/maintenance/form.tsx index a0ab4655..b61acbf7 100644 --- a/apps/dashboard/src/components/forms/maintenance/form.tsx +++ b/apps/dashboard/src/components/forms/maintenance/form.tsx @@ -1,9 +1,6 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, @@ -16,25 +13,20 @@ import { } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@openstatus/ui/components/ui/popover"; import { TabsContent } from "@openstatus/ui/components/ui/tabs"; import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; -import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { addDays, format } from "date-fns"; +import { addDays } from "date-fns"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { DateTimePicker } from "@/components/common/date-time-picker"; import { EmptyStateContainer, EmptyStateTitle, @@ -80,7 +72,6 @@ export function FormMaintenance({ }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - const mobile = useIsMobile(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const form = useForm({ resolver: zodResolver(schema), @@ -158,101 +149,25 @@ export function FormMaintenance({ render={({ field }) => ( Start Date - - - - - - - - { - if (!selectedDate) return; - - const newDate = new Date(selectedDate); - newDate.setHours( - field.value.getHours(), - field.value.getMinutes(), - field.value.getSeconds(), - field.value.getMilliseconds(), + + { + // a start moved past the end drags the end along, + // keeping the duration + if (watchEndDate && date > watchEndDate) { + const duration = + watchEndDate.getTime() - field.value.getTime(); + form.setValue( + "endDate", + new Date(date.getTime() + duration), ); - field.onChange(newDate); - - // NOTE: if end date is before start date, set it to the same day as the start date - if (watchEndDate && newDate > watchEndDate) { - form.setValue("endDate", newDate); - } - }} - initialFocus - /> -
-
- -
- { - try { - const timeValue = e.target.value; - if (!timeValue || !field.value) return; - - const [hours, minutes, seconds] = timeValue - .split(":") - .map(Number); - - const newDate = new Date(field.value); - newDate.setHours( - hours, - minutes, - seconds || 0, - 0, - ); - - field.onChange(newDate); - } catch (error) { - console.error(error); - } - }} - /> -
-
-
-
-
-
-
+ } + field.onChange(date); + }} + /> + When the maintenance starts. Shown in your timezone ( @@ -277,96 +192,13 @@ export function FormMaintenance({ render={({ field }) => ( End Date - - - - - - - - { - if (!selectedDate) return; - - const newDate = new Date(selectedDate); - newDate.setHours( - field.value.getHours(), - field.value.getMinutes(), - field.value.getSeconds(), - field.value.getMilliseconds(), - ); - field.onChange(newDate); - }} - initialFocus - /> -
-
- -
- { - try { - const timeValue = e.target.value; - if (!timeValue || !field.value) return; - - const [hours, minutes, seconds] = timeValue - .split(":") - .map(Number); - - const newDate = new Date(field.value); - newDate.setHours( - hours, - minutes, - seconds || 0, - 0, - ); - - field.onChange(newDate); - } catch (error) { - console.error(error); - } - }} - /> -
-
-
-
-
-
-
+ + + When the maintenance ends. Shown in your timezone ( diff --git a/apps/dashboard/src/components/forms/status-report-update/form.tsx b/apps/dashboard/src/components/forms/status-report-update/form.tsx index 94e821c4..6a26bd54 100644 --- a/apps/dashboard/src/components/forms/status-report-update/form.tsx +++ b/apps/dashboard/src/components/forms/status-report-update/form.tsx @@ -3,9 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/constants"; -import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, @@ -16,13 +13,7 @@ import { FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; -import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; -import { - PopoverContent, - PopoverTrigger, -} from "@openstatus/ui/components/ui/popover"; -import { Popover } from "@openstatus/ui/components/ui/popover"; import { Select, SelectContent, @@ -34,16 +25,15 @@ import { TabsContent } from "@openstatus/ui/components/ui/tabs"; import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; -import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { format } from "date-fns"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { DateTimePicker } from "@/components/common/date-time-picker"; import { ProcessMessage } from "@/components/content/process-message"; import { FormCardContent, @@ -88,7 +78,6 @@ export function FormStatusReportUpdate({ }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - const mobile = useIsMobile(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const form = useForm({ resolver: zodResolver(schema), @@ -202,94 +191,15 @@ export function FormStatusReportUpdate({ render={({ field }) => ( Date - - - - - - - - { - if (!selectedDate) return; - const newDate = new Date(selectedDate); - newDate.setHours( - field.value.getHours(), - field.value.getMinutes(), - field.value.getSeconds(), - field.value.getMilliseconds(), - ); - field.onChange(newDate); - }} - disabled={(date) => - date > new Date() || date < new Date("1900-01-01") - } - initialFocus - /> -
-
- -
- { - try { - const timeValue = e.target.value; - if (!timeValue || !field.value) return; - - const [hours, minutes, seconds] = timeValue - .split(":") - .map(Number); - - const newDate = new Date(field.value); - newDate.setHours( - hours, - minutes, - seconds || 0, - 0, - ); - - field.onChange(newDate); - } catch (error) { - console.error(error); - } - }} - /> -
-
-
-
-
-
-
+ + + When the status report was created. Shown in your timezone ( diff --git a/apps/dashboard/src/components/forms/status-report/form.tsx b/apps/dashboard/src/components/forms/status-report/form.tsx index bf769987..b309e8da 100644 --- a/apps/dashboard/src/components/forms/status-report/form.tsx +++ b/apps/dashboard/src/components/forms/status-report/form.tsx @@ -3,9 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/constants"; -import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, @@ -18,11 +15,6 @@ import { } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@openstatus/ui/components/ui/popover"; import { Select, SelectContent, @@ -34,16 +26,15 @@ import { TabsContent } from "@openstatus/ui/components/ui/tabs"; import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; -import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { format } from "date-fns"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { DateTimePicker } from "@/components/common/date-time-picker"; import { EmptyStateContainer, EmptyStateTitle, @@ -102,7 +93,6 @@ export function FormStatusReport({ }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - const mobile = useIsMobile(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const form = useForm({ resolver: zodResolver(defaultValues ? updateSchema : schema), @@ -226,96 +216,15 @@ export function FormStatusReport({ render={({ field }) => ( Date - - - - - - - - { - if (!selectedDate) return; - const newDate = new Date(selectedDate); - newDate.setHours( - field.value.getHours(), - field.value.getMinutes(), - field.value.getSeconds(), - field.value.getMilliseconds(), - ); - field.onChange(newDate); - }} - disabled={(date) => - date > new Date() || date < new Date("1900-01-01") - } - initialFocus - /> -
-
- -
- { - try { - const timeValue = e.target.value; - if (!timeValue || !field.value) return; - - const [hours, minutes, seconds] = timeValue - .split(":") - .map(Number); - - const newDate = new Date(field.value); - newDate.setHours( - hours, - minutes, - seconds || 0, - 0, - ); - - field.onChange(newDate); - } catch (error) { - console.error(error); - } - }} - /> -
-
-
-
-
-
-
+ + + When the status report was created. Shown in your timezone ( diff --git a/apps/dashboard/src/components/incidents/declare-from-row.tsx b/apps/dashboard/src/components/incidents/declare-from-row.tsx index 7ad624b9..99c11cf0 100644 --- a/apps/dashboard/src/components/incidents/declare-from-row.tsx +++ b/apps/dashboard/src/components/incidents/declare-from-row.tsx @@ -4,7 +4,6 @@ import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; import { useFeature } from "@/hooks/use-feature"; -import { formatDateForInput } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { @@ -41,9 +40,7 @@ export function DeclareFromRow({ defaultValues={{ title, // epoch = legacy report without dates; prefill now instead - startedAt: formatDateForInput( - startedAt.getTime() === 0 ? new Date() : startedAt, - ), + startedAt: startedAt.getTime() === 0 ? new Date() : startedAt, ...(statusReportId ? { statusReportId: String(statusReportId) } : {}), }} > diff --git a/apps/dashboard/src/components/maintenances/maintenance-properties.tsx b/apps/dashboard/src/components/maintenances/maintenance-properties.tsx index 79fc2c25..03deaf58 100644 --- a/apps/dashboard/src/components/maintenances/maintenance-properties.tsx +++ b/apps/dashboard/src/components/maintenances/maintenance-properties.tsx @@ -10,7 +10,7 @@ import { StatusDot } from "@/components/common/status-dot"; import { UserAvatar } from "@/components/common/user-avatar"; import { Property, - PropertyInput, + PropertyDateTimePicker, PropertyLabel, PropertyLink, PropertyList, @@ -22,7 +22,6 @@ import { maintenanceStatusConfig, } from "@/data/overview-events.client"; import { useHydrated } from "@/hooks/use-hydrated"; -import { formatDateForInput } from "@/lib/formatter"; import { useUpdateMaintenance } from "./use-update-maintenance"; @@ -57,15 +56,13 @@ export function MaintenanceProperties({ status: MaintenanceStatus; publicUrl: string; }) { - // datetime-local values and the timezone are browser-local + // the formatted dates and the timezone are browser-local const hydrated = useHydrated(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - // null = pristine: the inputs follow the server copy until edited. - const [draft, setDraft] = useState<{ from: string; to: string } | null>(null); - const serverFrom = formatDateForInput(maintenance.from); - const serverTo = formatDateForInput(maintenance.to); - const from = draft?.from ?? serverFrom; - const to = draft?.to ?? serverTo; + // null = pristine: the pickers follow the server copy until edited. + const [draft, setDraft] = useState<{ from: Date; to: Date } | null>(null); + const from = draft?.from ?? maintenance.from; + const to = draft?.to ?? maintenance.to; const { update, isPending } = useUpdateMaintenance(maintenance.id, { onSuccess: () => { toast.success("Schedule saved"); @@ -73,8 +70,11 @@ export function MaintenanceProperties({ }, }); - const dirty = draft !== null && (from !== serverFrom || to !== serverTo); - const invalid = !from || !to || new Date(to) <= new Date(from); + const dirty = + draft !== null && + (from.getTime() !== maintenance.from.getTime() || + to.getTime() !== maintenance.to.getTime()); + const invalid = to <= from; const author = maintenance.createdByUser; const editor = distinctEditor(maintenance); @@ -96,37 +96,36 @@ export function MaintenanceProperties({ From - setDraft({ from: e.target.value, to })} - /> + {hydrated ? ( + setDraft({ from: date, to })} + /> + ) : ( +
+ )} To - setDraft({ from, to: e.target.value })} - /> + {hydrated ? ( + setDraft({ from, to: date })} + /> + ) : ( +
+ )} {dirty ? (
@@ -139,7 +138,7 @@ export function MaintenanceProperties({ > Reset - {from && to && invalid ? ( + {invalid ? ( End must be after start diff --git a/apps/dashboard/src/components/status-reports/status-report-composer.tsx b/apps/dashboard/src/components/status-reports/status-report-composer.tsx index 7fd807c5..27839efd 100644 --- a/apps/dashboard/src/components/status-reports/status-report-composer.tsx +++ b/apps/dashboard/src/components/status-reports/status-report-composer.tsx @@ -10,7 +10,6 @@ import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/con import { Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; -import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { Select, @@ -23,6 +22,7 @@ import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; +import { DateTimePicker } from "@/components/common/date-time-picker"; import { StatusDot } from "@/components/common/status-dot"; import { ComponentImpact, @@ -49,7 +49,6 @@ import { statusVariants, toCreateStatusReportUpdateInput, } from "@/data/status-report-updates.client"; -import { formatDateForInput } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; @@ -90,7 +89,9 @@ export function StatusReportComposer({ const [notifyChecked, setNotifyChecked] = useState(true); const [selected, setSelected] = useState(null); // null = now, resolved at publish time so an open composer never backdates. - const [date, setDate] = useState(null); + const [date, setDate] = useState(null); + // shown while `date` is null; refreshed on reset + const [now, setNow] = useState(() => new Date()); const [overrides, setOverrides] = useState>( () => new Map(), ); @@ -100,13 +101,7 @@ export function StatusReportComposer({ const publish = usePublishUpdate(report.id); const notify = canNotify && notifyChecked; const status = selected ?? getNextStatus(report.status); - const now = formatDateForInput(new Date()); - // the input's `max` is not enforced on the button handler - const invalidDate = - date !== null && - (date === "" || - Number.isNaN(new Date(date).getTime()) || - new Date(date) > new Date()); + const invalidDate = date !== null && date > new Date(); const disabled = publish.isPending || !message.trim() || invalidDate; const byId = new Map( @@ -129,6 +124,7 @@ export function StatusReportComposer({ setMessage(""); setSelected(null); setDate(null); + setNow(new Date()); setOverrides(new Map()); setIds(null); } @@ -141,7 +137,7 @@ export function StatusReportComposer({ values: { status, message: message.trim(), - date: date ? new Date(date) : new Date(), + date: date ?? new Date(), componentImpacts: components.map((c) => ({ pageComponentId: c.id, impact: impactFor(c.id), @@ -310,13 +306,13 @@ export function StatusReportComposer({ at - setDate(e.target.value)} - className="bg-background text-foreground h-8 w-auto font-mono md:text-sm" + max={new Date()} + onChange={setDate} + className="bg-background text-foreground h-8 font-mono" />