Openstatus www.openstatus.dev

chore: status page v2 improvements (#1361)

* fix: monitors empty state

* chore: add markdown

* fix: dates

* refactor: layout

* chore: footer timestamp

* refactor: status page config

authored by

Maximilian Kaske and committed by
GitHub
814971e1 2433039e

+304 -255
+20
apps/status-page/src/app/(public)/layout.tsx
··· 1 + import { ThemeProvider } from "@/components/theme-provider"; 2 + import { Toaster } from "@/components/ui/sonner"; 3 + 4 + export default async function Layout({ 5 + children, 6 + }: { 7 + children: React.ReactNode; 8 + }) { 9 + return ( 10 + <ThemeProvider 11 + attribute="class" 12 + defaultTheme="system" 13 + enableSystem 14 + disableTransitionOnChange 15 + > 16 + {children} 17 + <Toaster richColors expand /> 18 + </ThemeProvider> 19 + ); 20 + }
+7 -37
apps/status-page/src/app/(status-page)/[domain]/(private)/layout.tsx
··· 1 1 import { Footer } from "@/components/nav/footer"; 2 - import { 3 - FloatingButton, 4 - StatusPageProvider, 5 - } from "@/components/status-page/floating-button"; 6 - import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 7 2 8 - export default function Layout({ 9 - children, 10 - params, 11 - }: { 12 - children: React.ReactNode; 13 - params: Promise<{ domain: string }>; 14 - }) { 3 + export default function Layout({ children }: { children: React.ReactNode }) { 15 4 return ( 16 - <Hydrate params={params}> 17 - <StatusPageProvider> 18 - <div className="flex min-h-screen flex-col gap-4"> 19 - <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 20 - {children} 21 - </main> 22 - <Footer className="w-full border-t" /> 23 - </div> 24 - <FloatingButton /> 25 - </StatusPageProvider> 26 - </Hydrate> 27 - ); 28 - } 29 - 30 - async function Hydrate({ 31 - children, 32 - params, 33 - }: { 34 - children: React.ReactNode; 35 - params: Promise<{ domain: string }>; 36 - }) { 37 - const queryClient = getQueryClient(); 38 - await queryClient.prefetchQuery( 39 - trpc.statusPage.get.queryOptions({ slug: (await params).domain }), 5 + <div className="flex min-h-screen flex-col gap-4"> 6 + <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 7 + {children} 8 + </main> 9 + <Footer className="w-full border-t" /> 10 + </div> 40 11 ); 41 - return <HydrateClient>{children}</HydrateClient>; 42 12 }
+1 -3
apps/status-page/src/app/(status-page)/[domain]/(public)/events/layout.tsx
··· 1 1 "use client"; 2 2 3 - import { useStatusPage } from "@/components/status-page/floating-button"; 4 3 import { 5 4 Status, 6 5 StatusContent, ··· 17 16 }: { 18 17 children: React.ReactNode; 19 18 }) { 20 - const { variant } = useStatusPage(); 21 19 const { domain } = useParams<{ domain: string }>(); 22 20 const trpc = useTRPC(); 23 21 const { data: page } = useQuery( ··· 27 25 if (!page) return null; 28 26 29 27 return ( 30 - <Status variant={variant}> 28 + <Status> 31 29 <StatusHeader> 32 30 <StatusTitle>{page.title}</StatusTitle> 33 31 <StatusDescription>{page.description}</StatusDescription>
+9 -38
apps/status-page/src/app/(status-page)/[domain]/(public)/layout.tsx
··· 1 1 import { defaultMetadata, ogMetadata, twitterMetadata } from "@/app/metadata"; 2 2 import { Footer } from "@/components/nav/footer"; 3 3 import { Header } from "@/components/nav/header"; 4 - import { 5 - FloatingButton, 6 - StatusPageProvider, 7 - } from "@/components/status-page/floating-button"; 8 - import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 4 + import { getQueryClient, trpc } from "@/lib/trpc/server"; 9 5 import type { Metadata } from "next"; 10 6 import { notFound } from "next/navigation"; 11 7 12 - export default function Layout({ 13 - children, 14 - params, 15 - }: { 16 - children: React.ReactNode; 17 - params: Promise<{ domain: string }>; 18 - }) { 8 + export default function Layout({ children }: { children: React.ReactNode }) { 19 9 return ( 20 - <Hydrate params={params}> 21 - <StatusPageProvider> 22 - <div className="flex min-h-screen flex-col gap-4"> 23 - <Header className="w-full border-b" /> 24 - <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 25 - {children} 26 - </main> 27 - <Footer className="w-full border-t" /> 28 - </div> 29 - <FloatingButton /> 30 - </StatusPageProvider> 31 - </Hydrate> 10 + <div className="flex min-h-screen flex-col gap-4"> 11 + <Header className="w-full border-b" /> 12 + <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 13 + {children} 14 + </main> 15 + <Footer className="w-full border-t" /> 16 + </div> 32 17 ); 33 - } 34 - 35 - async function Hydrate({ 36 - children, 37 - params, 38 - }: { 39 - children: React.ReactNode; 40 - params: Promise<{ domain: string }>; 41 - }) { 42 - const queryClient = getQueryClient(); 43 - await queryClient.prefetchQuery( 44 - trpc.statusPage.get.queryOptions({ slug: (await params).domain }), 45 - ); 46 - return <HydrateClient>{children}</HydrateClient>; 47 18 } 48 19 49 20 export async function generateMetadata({
+56 -60
apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/page.tsx
··· 5 5 ChartAreaPercentilesSkeleton, 6 6 } from "@/components/chart/chart-area-percentiles"; 7 7 import { 8 - EmptyStateContainer, 9 - EmptyStateDescription, 10 - EmptyStateTitle, 11 - } from "@/components/content/empty-state"; 12 - import { useStatusPage } from "@/components/status-page/floating-button"; 13 - import { 14 8 Status, 15 9 StatusContent, 16 10 StatusDescription, 11 + StatusEmptyState, 12 + StatusEmptyStateDescription, 13 + StatusEmptyStateTitle, 17 14 StatusHeader, 18 15 StatusTitle, 19 16 } from "@/components/status-page/status"; ··· 25 22 import { useParams } from "next/navigation"; 26 23 27 24 export default function Page() { 28 - const { variant } = useStatusPage(); 29 25 const { domain } = useParams<{ domain: string }>(); 30 26 const trpc = useTRPC(); 31 27 const { data: page } = useQuery( ··· 36 32 ); 37 33 38 34 if (!page) return null; 35 + 36 + const publicMonitors = page.monitors.filter((monitor) => monitor.public); 39 37 40 38 return ( 41 - <Status variant={variant}> 39 + <Status> 42 40 <StatusHeader> 43 41 <StatusTitle>{page.title}</StatusTitle> 44 42 <StatusDescription>{page.description}</StatusDescription> 45 43 </StatusHeader> 46 44 <StatusContent className="flex flex-col gap-6"> 47 - {page.monitors.length > 0 ? ( 48 - page.monitors 49 - .filter((monitor) => monitor.public) 50 - .map((monitor) => { 51 - const data = 52 - monitors 53 - ?.find((item) => item.id === monitor.id) 54 - ?.data?.map((item) => ({ 55 - ...item, 56 - // TODO: create formatter 57 - timestamp: new Date(item.timestamp).toLocaleString( 58 - "default", 59 - { 60 - day: "numeric", 61 - month: "short", 62 - hour: "numeric", 63 - minute: "numeric", 64 - timeZoneName: "short", 65 - }, 66 - ), 67 - })) ?? []; 45 + {publicMonitors.length > 0 ? ( 46 + publicMonitors.map((monitor) => { 47 + const data = 48 + monitors 49 + ?.find((item) => item.id === monitor.id) 50 + ?.data?.map((item) => ({ 51 + ...item, 52 + // TODO: create formatter 53 + timestamp: new Date(item.timestamp).toLocaleString( 54 + "default", 55 + { 56 + day: "numeric", 57 + month: "short", 58 + hour: "numeric", 59 + minute: "numeric", 60 + timeZoneName: "short", 61 + }, 62 + ), 63 + })) ?? []; 68 64 69 - return ( 70 - <Link 71 - key={monitor.id} 72 - href={`./monitors/${monitor.id}`} 73 - className="rounded-lg" 74 - > 75 - <div className="group -mx-3 -my-2 flex flex-col gap-2 rounded-lg border border-transparent px-3 py-2 hover:border-border/50 hover:bg-muted/50"> 76 - <div className="flex flex-row items-center gap-2"> 77 - <StatusMonitorTitle>{monitor.name}</StatusMonitorTitle> 78 - <StatusMonitorDescription> 79 - {monitor.description} 80 - </StatusMonitorDescription> 81 - </div> 82 - {isLoading ? ( 83 - <ChartAreaPercentilesSkeleton className="h-[80px]" /> 84 - ) : ( 85 - <ChartAreaPercentiles 86 - className="h-[80px]" 87 - legendClassName="pb-1" 88 - data={data} 89 - singleSeries 90 - /> 91 - )} 65 + return ( 66 + <Link 67 + key={monitor.id} 68 + href={`./monitors/${monitor.id}`} 69 + className="rounded-lg" 70 + > 71 + <div className="group -mx-3 -my-2 flex flex-col gap-2 rounded-lg border border-transparent px-3 py-2 hover:border-border/50 hover:bg-muted/50"> 72 + <div className="flex flex-row items-center gap-2"> 73 + <StatusMonitorTitle>{monitor.name}</StatusMonitorTitle> 74 + <StatusMonitorDescription> 75 + {monitor.description} 76 + </StatusMonitorDescription> 92 77 </div> 93 - </Link> 94 - ); 95 - }) 78 + {isLoading ? ( 79 + <ChartAreaPercentilesSkeleton className="h-[80px]" /> 80 + ) : ( 81 + <ChartAreaPercentiles 82 + className="h-[80px]" 83 + legendClassName="pb-1" 84 + data={data} 85 + singleSeries 86 + /> 87 + )} 88 + </div> 89 + </Link> 90 + ); 91 + }) 96 92 ) : ( 97 - <EmptyStateContainer> 98 - <EmptyStateTitle>No public monitors</EmptyStateTitle> 99 - <EmptyStateDescription> 93 + <StatusEmptyState> 94 + <StatusEmptyStateTitle>No public monitors</StatusEmptyStateTitle> 95 + <StatusEmptyStateDescription> 100 96 No public monitors have been added to this page. 101 - </EmptyStateDescription> 102 - </EmptyStateContainer> 97 + </StatusEmptyStateDescription> 98 + </StatusEmptyState> 103 99 )} 104 100 </StatusContent> 105 101 </Status>
+1 -1
apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx
··· 37 37 trpc.statusPage.getUptime.queryOptions({ 38 38 slug: domain, 39 39 monitorIds: page?.monitors?.map((monitor) => monitor.id.toString()) || [], 40 - // NOTE: this will be moved to db config 40 + // NOTE: we could move that to the server as we query the page entry anyways 41 41 cardType, 42 42 barType, 43 43 }),
+57
apps/status-page/src/app/(status-page)/[domain]/layout.tsx
··· 1 + import { 2 + FloatingButton, 3 + StatusPageProvider, 4 + } from "@/components/status-page/floating-button"; 5 + import { ThemeProvider } from "@/components/theme-provider"; 6 + import { Toaster } from "@/components/ui/sonner"; 7 + import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 8 + import { z } from "zod"; 9 + 10 + export const schema = z.object({ 11 + card: z.enum(["duration", "requests", "manual"]).default("duration"), 12 + bar: z.enum(["absolute", "manual"]).default("absolute"), 13 + uptime: z.boolean().default(true), 14 + theme: z.enum(["default"]).default("default"), 15 + }); 16 + 17 + const DISPLAY_FLOATING_BUTTON = 18 + process.env.NODE_ENV === "development" || 19 + process.env.ENABLE_FLOATING_BUTTON === "true"; 20 + 21 + export default async function Layout({ 22 + children, 23 + params, 24 + }: { 25 + children: React.ReactNode; 26 + params: Promise<{ domain: string }>; 27 + }) { 28 + const queryClient = getQueryClient(); 29 + const { domain } = await params; 30 + const page = await queryClient.fetchQuery( 31 + trpc.statusPage.get.queryOptions({ slug: domain }), 32 + ); 33 + 34 + const validation = schema.safeParse(page?.configuration); 35 + 36 + return ( 37 + <HydrateClient> 38 + <ThemeProvider 39 + attribute="class" 40 + defaultTheme={page?.forceTheme ?? "system"} 41 + enableSystem 42 + disableTransitionOnChange 43 + > 44 + <StatusPageProvider 45 + defaultBarType={validation.data?.bar} 46 + defaultCardType={validation.data?.card} 47 + defaultShowUptime={validation.data?.uptime} 48 + defaultCommunityTheme={validation.data?.theme} 49 + > 50 + {children} 51 + {DISPLAY_FLOATING_BUTTON ? <FloatingButton /> : null} 52 + <Toaster richColors expand /> 53 + </StatusPageProvider> 54 + </ThemeProvider> 55 + </HydrateClient> 56 + ); 57 + }
+4 -14
apps/status-page/src/app/layout.tsx
··· 2 2 import { Geist, Geist_Mono } from "next/font/google"; 3 3 import "./globals.css"; 4 4 import { TailwindIndicator } from "@/components/tailwind-indicator"; 5 - import { ThemeProvider } from "@/components/theme-provider"; 6 - import { Toaster } from "@/components/ui/sonner"; 7 5 import { TRPCReactProvider } from "@/lib/trpc/client"; 8 6 import { cn } from "@/lib/utils"; 9 7 import LocalFont from "next/font/local"; ··· 54 52 )} 55 53 > 56 54 <NuqsAdapter> 57 - <ThemeProvider 58 - attribute="class" 59 - defaultTheme="system" 60 - enableSystem 61 - disableTransitionOnChange 62 - > 63 - <TRPCReactProvider> 64 - {children} 65 - <TailwindIndicator /> 66 - <Toaster richColors expand /> 67 - </TRPCReactProvider> 68 - </ThemeProvider> 55 + <TRPCReactProvider> 56 + {children} 57 + <TailwindIndicator /> 58 + </TRPCReactProvider> 69 59 </NuqsAdapter> 70 60 </body> 71 61 </html>
+97
apps/status-page/src/components/content/timestamp-hover-card.tsx
··· 1 + import { 2 + HoverCard, 3 + HoverCardContent, 4 + HoverCardTrigger, 5 + } from "@/components/ui/hover-card"; 6 + import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 7 + import { UTCDate } from "@date-fns/utc"; 8 + import { 9 + type HoverCardContentProps, 10 + HoverCardPortal, 11 + } from "@radix-ui/react-hover-card"; 12 + import { format } from "date-fns"; 13 + import { formatDistanceToNowStrict } from "date-fns"; 14 + import { Check, Copy } from "lucide-react"; 15 + import { useEffect, useState } from "react"; 16 + 17 + export function TimestampHoverCard({ 18 + date, 19 + side = "right", 20 + align = "start", 21 + alignOffset = -4, 22 + sideOffset, 23 + children, 24 + ...props 25 + }: React.ComponentProps<typeof HoverCardTrigger> & { 26 + date: Date; 27 + side?: HoverCardContentProps["side"]; 28 + align?: HoverCardContentProps["align"]; 29 + alignOffset?: HoverCardContentProps["alignOffset"]; 30 + sideOffset?: HoverCardContentProps["sideOffset"]; 31 + }) { 32 + const [open, setOpen] = useState(false); 33 + const [_, setRerender] = useState(0); 34 + 35 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 36 + const relative = formatDistanceToNowStrict(date, { addSuffix: true }); 37 + const formatted = format(date, "LLL dd, y HH:mm:ss"); 38 + const utc = format(new UTCDate(date), "LLL dd, y HH:mm:ss"); 39 + 40 + useEffect(() => { 41 + // only setInterval if open 42 + if (!open) return; 43 + 44 + const interval = setInterval(() => { 45 + setRerender((prev) => prev + 1); 46 + }, 1000); 47 + 48 + return () => clearInterval(interval); 49 + }, [open]); 50 + 51 + return ( 52 + <HoverCard openDelay={0} closeDelay={0} open={open} onOpenChange={setOpen}> 53 + {/* NOTE: the trigger is an `a` tag per default */} 54 + <HoverCardTrigger {...props}>{children}</HoverCardTrigger> 55 + <HoverCardPortal> 56 + <HoverCardContent 57 + className="z-10 w-auto p-2" 58 + {...{ side, align, alignOffset, sideOffset }} 59 + > 60 + <dl className="flex flex-col gap-1"> 61 + <Row value={formatted} label={timezone} /> 62 + <Row value={utc} label="UTC" /> 63 + {/* <Row value={date.toISOString()} label="ISO" /> */} 64 + {/* <Row value={String(date.getTime())} label="Timestamp" /> */} 65 + <Row value={relative} label="Relative" /> 66 + </dl> 67 + </HoverCardContent> 68 + </HoverCardPortal> 69 + </HoverCard> 70 + ); 71 + } 72 + 73 + function Row({ value, label }: { value: string; label: string }) { 74 + const { copy, isCopied } = useCopyToClipboard(); 75 + 76 + return ( 77 + <div 78 + className="group flex items-center justify-between gap-4 text-sm" 79 + onClick={(e) => { 80 + e.stopPropagation(); 81 + copy(value, {}); 82 + }} 83 + > 84 + <dt className="text-muted-foreground">{label}</dt> 85 + <dd className="flex items-center gap-1 truncate font-mono"> 86 + <span className="invisible group-hover:visible"> 87 + {!isCopied ? ( 88 + <Copy className="h-3 w-3" /> 89 + ) : ( 90 + <Check className="h-3 w-3" /> 91 + )} 92 + </span> 93 + {value} 94 + </dd> 95 + </div> 96 + ); 97 + }
+18 -5
apps/status-page/src/components/nav/footer.tsx
··· 1 1 "use client"; 2 2 3 3 import { Link } from "@/components/common/link"; 4 + import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; 4 5 import { ThemeToggle } from "@/components/theme-toggle"; 5 6 import { useTRPC } from "@/lib/trpc/client"; 7 + import { cn } from "@/lib/utils"; 6 8 import { useQuery } from "@tanstack/react-query"; 9 + import { format } from "date-fns"; 7 10 import { useParams } from "next/navigation"; 8 11 9 12 export function Footer(props: React.ComponentProps<"footer">) { 10 13 const { domain } = useParams<{ domain: string }>(); 11 14 const trpc = useTRPC(); 12 - const { data: page } = useQuery( 15 + const { data: page, dataUpdatedAt } = useQuery( 13 16 trpc.statusPage.get.queryOptions({ slug: domain }), 14 17 ); 15 18 ··· 17 20 18 21 return ( 19 22 <footer {...props}> 20 - <div className="mx-auto flex max-w-2xl items-center justify-between px-3 py-2"> 21 - {page.workspacePlan === "team" ? null : ( 22 - <p className="text-muted-foreground"> 23 + <div className="mx-auto flex gap-4 max-w-2xl items-center justify-between px-3 py-2"> 24 + <div className="leading-[0.9]"> 25 + <p 26 + className={cn( 27 + "text-muted-foreground text-sm", 28 + page.workspacePlan === "team" && "sr-only", 29 + )} 30 + > 23 31 Powered by <Link href="#">OpenStatus</Link> 24 32 </p> 25 - )} 33 + <TimestampHoverCard date={new Date(dataUpdatedAt)} side="top"> 34 + <span className="text-muted-foreground/70 text-xs"> 35 + {format(new Date(dataUpdatedAt), "LLL dd, y HH:mm:ss")} 36 + </span> 37 + </TimestampHoverCard> 38 + </div> 26 39 <ThemeToggle className="w-[140px]" /> 27 40 </div> 28 41 </footer>
+1 -2
apps/status-page/src/components/status-page/floating-button.tsx
··· 39 39 40 40 export const COMMUNITY_THEME = ["default", "github", "supabase"] as const; 41 41 export type CommunityTheme = (typeof COMMUNITY_THEME)[number]; 42 - 43 42 interface StatusPageContextType { 44 43 variant: VariantType; 45 44 setVariant: (variant: VariantType) => void; ··· 149 148 } = useStatusPage(); 150 149 151 150 return ( 152 - <div className={cn("fixed right-4 bottom-4 z-50 bg-background", className)}> 151 + <div className={cn("fixed right-4 bottom-4 z-50", className)}> 153 152 <Popover> 154 153 <PopoverTrigger asChild> 155 154 <Button
+14 -90
apps/status-page/src/components/status-page/status-events.tsx
··· 1 + import { ProcessMessage } from "@/components/content/process-message"; 2 + import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; 1 3 import { Separator } from "@/components/ui/separator"; 2 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 3 4 import { formatDateRange, formatDateTime } from "@/lib/formatter"; 4 5 import { cn } from "@/lib/utils"; 5 - import { UTCDate } from "@date-fns/utc"; 6 - import { HoverCardPortal } from "@radix-ui/react-hover-card"; 7 - import { 8 - format, 9 - formatDistanceStrict, 10 - formatDistanceToNowStrict, 11 - } from "date-fns"; 12 - import { Check, Copy } from "lucide-react"; 13 - import { 14 - HoverCard, 15 - HoverCardContent, 16 - HoverCardTrigger, 17 - } from "../ui/hover-card"; 6 + import { formatDistanceStrict } from "date-fns"; 18 7 import { status } from "./messages"; 19 8 20 9 // TODO: rename file to status-event and move the `StatusEvents` component to the page level. ··· 169 158 <span>{status[report.status]}</span>{" "} 170 159 {/* underline decoration-dashed underline-offset-2 decoration-muted-foreground/30 */} 171 160 <span className="font-mono text-muted-foreground/70 text-xs"> 172 - <StatusEventDateHoverCard date={new Date(report.date)}> 173 - {formatDateTime(report.date)} 174 - </StatusEventDateHoverCard> 161 + <TimestampHoverCard date={new Date(report.date)} asChild> 162 + <span>{formatDateTime(report.date)}</span> 163 + </TimestampHoverCard> 175 164 </span>{" "} 176 165 {duration ? ( 177 166 <span className="font-mono text-muted-foreground/70 text-xs"> ··· 180 169 ) : null} 181 170 </StatusEventTimelineTitle> 182 171 <StatusEventTimelineMessage> 183 - {report.message} 172 + <ProcessMessage value={report.message} /> 184 173 </StatusEventTimelineMessage> 185 174 </div> 186 175 </div> ··· 220 209 <StatusEventTimelineTitle> 221 210 <span>Maintenance</span>{" "} 222 211 <span className="font-mono text-muted-foreground/70 text-xs"> 223 - <StatusEventDateHoverCard date={maintenance.from}> 224 - {from} 225 - </StatusEventDateHoverCard> 212 + <TimestampHoverCard date={maintenance.from} asChild> 213 + <span>{from}</span> 214 + </TimestampHoverCard> 226 215 {" - "} 227 - <StatusEventDateHoverCard date={maintenance.to}> 228 - {to} 229 - </StatusEventDateHoverCard> 216 + <TimestampHoverCard date={maintenance.to} asChild> 217 + <span>{to}</span> 218 + </TimestampHoverCard> 230 219 </span>{" "} 231 220 {duration ? ( 232 221 <span className="font-mono text-muted-foreground/70 text-xs"> ··· 235 224 ) : null} 236 225 </StatusEventTimelineTitle> 237 226 <StatusEventTimelineMessage> 238 - {maintenance.message} 227 + <ProcessMessage value={maintenance.message} /> 239 228 </StatusEventTimelineMessage> 240 229 </div> 241 230 </div> ··· 312 301 /> 313 302 ); 314 303 } 315 - 316 - export function StatusEventDateHoverCard({ 317 - date, 318 - side = "right", 319 - align = "start", 320 - alignOffset = -4, 321 - sideOffset, 322 - children, 323 - }: React.ComponentProps<typeof HoverCardContent> & { date: Date }) { 324 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 325 - return ( 326 - <HoverCard openDelay={0} closeDelay={0}> 327 - {/* NOTE: the trigger is an `a` tag per default */} 328 - <HoverCardTrigger asChild> 329 - <span>{children}</span> 330 - </HoverCardTrigger> 331 - <HoverCardPortal> 332 - <HoverCardContent 333 - className="z-10 w-auto p-2" 334 - {...{ side, align, alignOffset, sideOffset }} 335 - > 336 - <dl className="flex flex-col gap-1"> 337 - <Row value={format(date, "LLL dd, y HH:mm:ss")} label={timezone} /> 338 - <Row 339 - value={format(new UTCDate(date), "LLL dd, y HH:mm:ss")} 340 - label="UTC" 341 - /> 342 - {/* <Row value={date.toISOString()} label="ISO" /> */} 343 - {/* <Row value={String(date.getTime())} label="Timestamp" /> */} 344 - <Row 345 - value={formatDistanceToNowStrict(date, { addSuffix: true })} 346 - label="Relative" 347 - /> 348 - </dl> 349 - </HoverCardContent> 350 - </HoverCardPortal> 351 - </HoverCard> 352 - ); 353 - } 354 - 355 - function Row({ value, label }: { value: string; label: string }) { 356 - const { copy, isCopied } = useCopyToClipboard(); 357 - 358 - return ( 359 - <div 360 - className="group flex items-center justify-between gap-4 text-sm" 361 - onClick={(e) => { 362 - e.stopPropagation(); 363 - copy(value, {}); 364 - }} 365 - > 366 - <dt className="text-muted-foreground">{label}</dt> 367 - <dd className="flex items-center gap-1 truncate font-mono"> 368 - <span className="invisible group-hover:visible"> 369 - {!isCopied ? ( 370 - <Copy className="h-3 w-3" /> 371 - ) : ( 372 - <Check className="h-3 w-3" /> 373 - )} 374 - </span> 375 - {value} 376 - </dd> 377 - </div> 378 - ); 379 - }
+19 -5
packages/api/src/router/statusPage.utils.ts
··· 401 401 switch (cardType) { 402 402 case "requests": 403 403 if (total === 0) { 404 - cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; 404 + cardData = [{ status: eventStatus ?? "empty", value: "" }]; 405 405 } else { 406 406 const entries = [ 407 407 { status: "success" as const, count: dayData.ok }, ··· 421 421 422 422 case "duration": 423 423 if (total === 0) { 424 - cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; 424 + cardData = [{ status: eventStatus ?? "empty", value: "" }]; 425 425 } else { 426 426 const entries = [ 427 427 { status: "error" as const, count: dayData.error }, ··· 477 477 let total = 0; 478 478 // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> 479 479 map.forEach((d) => (total += d)); 480 - const day = 24 * 60; 481 - const minutes = Math.max(day - total, 0); 480 + 481 + const now = new Date(); 482 + const startOfDay = new Date(date); 483 + startOfDay.setUTCHours(0, 0, 0, 0); 484 + 485 + let totalMinutesInDay: number; 486 + if (isToday(date)) { 487 + const minutesElapsed = Math.floor( 488 + (now.getTime() - startOfDay.getTime()) / (1000 * 60), 489 + ); 490 + totalMinutesInDay = minutesElapsed; 491 + } else { 492 + totalMinutesInDay = 24 * 60; 493 + } 494 + 495 + const minutes = Math.max(totalMinutesInDay - total, 0); 482 496 if (minutes === 0) return null; 483 497 return { 484 498 status: entry.status, ··· 515 529 default: 516 530 // Default to requests behavior 517 531 if (total === 0) { 518 - cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; 532 + cardData = [{ status: eventStatus ?? "empty", value: "" }]; 519 533 } else { 520 534 const entries = [ 521 535 { status: "error" as const, count: dayData.error },