diff --git a/apps/dashboard/src/app/(dashboard)/chat/layout.tsx b/apps/dashboard/src/app/(dashboard)/chat/layout.tsx index dd3e9bab..62f13add 100644 --- a/apps/dashboard/src/app/(dashboard)/chat/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/chat/layout.tsx @@ -11,7 +11,7 @@ import { RIGHT_SIDEBAR_COOKIE, getSidebarDefaultOpen, } from "@/lib/sidebar-cookie"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; @@ -23,8 +23,7 @@ export default async function Layout({ children: React.ReactNode; }) { // Prefetch the conversation list so the right sidebar paints immediately. - const queryClient = getQueryClient(); - await queryClient.prefetchQuery(trpc.chatSession.list.queryOptions()); + prefetch(trpc.chatSession.list.queryOptions()); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); return ( diff --git a/apps/dashboard/src/app/(dashboard)/invite/page.tsx b/apps/dashboard/src/app/(dashboard)/invite/page.tsx index 80673532..9117c1aa 100644 --- a/apps/dashboard/src/app/(dashboard)/invite/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/invite/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import type { SearchParams } from "nuqs"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; @@ -15,8 +15,7 @@ export default async function InvitePage(props: { return redirect("/overview"); } - const queryClient = getQueryClient(); - await queryClient.prefetchQuery(trpc.invitation.get.queryOptions({ token })); + prefetch(trpc.invitation.get.queryOptions({ token })); return ( diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx index 499cac3c..14e88d32 100644 --- a/apps/dashboard/src/app/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -12,7 +12,7 @@ import { LEFT_SIDEBAR_COOKIE, getSidebarDefaultOpen, } from "@/lib/sidebar-cookie"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, @@ -44,14 +44,16 @@ export default async function Layout({ ); } -async function HydrateSidebar({ children }: { children: React.ReactNode }) { - const queryClient = getQueryClient(); - await Promise.all([ - queryClient.prefetchQuery(trpc.page.list.queryOptions()), - queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), - queryClient.prefetchQuery(trpc.workspace.get.queryOptions()), - queryClient.prefetchQuery(trpc.workspace.list.queryOptions()), - queryClient.prefetchQuery(trpc.user.get.queryOptions()), +function HydrateSidebar({ children }: { children: React.ReactNode }) { + // Not awaited: the pending queries are dehydrated and streamed to the client + // (see `shouldDehydrateQuery` in lib/trpc/query-client). Awaiting here made + // the shell's slowest query the TTFB of every dashboard route. + batchPrefetch([ + trpc.page.list.queryOptions(), + trpc.monitor.list.queryOptions(), + trpc.workspace.get.queryOptions(), + trpc.workspace.list.queryOptions(), + trpc.user.get.queryOptions(), ]); return {children}; diff --git a/apps/dashboard/src/app/(dashboard)/loading.tsx b/apps/dashboard/src/app/(dashboard)/loading.tsx new file mode 100644 index 00000000..23334700 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; + +/** + * Fallback for segment transitions. The shell (sidebar, header) is rendered by + * the surrounding layout and stays put — only the main area swaps. + */ +export default function Loading() { + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx index d5ccaf94..b4d9b80e 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx @@ -1,6 +1,6 @@ import type { SearchParams } from "nuqs"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; @@ -10,13 +10,12 @@ export default async function Page({ }: { searchParams: Promise; }) { - const queryClient = getQueryClient(); - - await Promise.all([ - searchParamsCache.parse(searchParams), - queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), - queryClient.prefetchQuery(trpc.monitorTag.list.queryOptions()), + batchPrefetch([ + trpc.monitor.list.queryOptions(), + trpc.monitorTag.list.queryOptions(), ]); + // NOTE: store in cache to avoid flicker on clients first render + await searchParamsCache.parse(searchParams); return ( diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx index 7132fc6b..df321262 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx @@ -1,14 +1,9 @@ -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const queryClient = getQueryClient(); - await Promise.all([ - queryClient.prefetchQuery(trpc.monitorTag.list.queryOptions()), - queryClient.prefetchQuery(trpc.privateLocation.list.queryOptions()), +export default function Layout({ children }: { children: React.ReactNode }) { + batchPrefetch([ + trpc.monitorTag.list.queryOptions(), + trpc.privateLocation.list.queryOptions(), ]); return {children}; diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx index 5c93f025..2e0d2566 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx @@ -4,7 +4,7 @@ import { RIGHT_SIDEBAR_COOKIE, getSidebarDefaultOpen, } from "@/lib/sidebar-cookie"; -import { getQueryClient, trpc } from "@/lib/trpc/server"; +import { prefetch, trpc } from "@/lib/trpc/server"; import { Sidebar } from "../sidebar"; @@ -15,9 +15,8 @@ export default async function Layout({ children: React.ReactNode; params: Promise<{ id: string }>; }) { - const queryClient = getQueryClient(); const { id } = await params; - await queryClient.prefetchQuery( + prefetch( trpc.incident.list.queryOptions({ monitorId: Number.parseInt(id) }), ); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx index 9bac32ea..ffa919b5 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx @@ -6,8 +6,8 @@ import { import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, + batchPrefetch, fetchQueryOrNotFound, - getQueryClient, trpc, } from "@/lib/trpc/server"; @@ -23,15 +23,16 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; - const queryClient = getQueryClient(); + // Started before the gate is awaited so the 404 check costs one round trip + // rather than serialising ahead of its siblings. + batchPrefetch([ + trpc.notification.list.queryOptions(), + trpc.privateLocation.list.queryOptions(), + ]); await fetchQueryOrNotFound( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); - await Promise.all([ - queryClient.prefetchQuery(trpc.notification.list.queryOptions()), - queryClient.prefetchQuery(trpc.privateLocation.list.queryOptions()), - ]); return ( diff --git a/apps/dashboard/src/app/(dashboard)/notifications/layout.tsx b/apps/dashboard/src/app/(dashboard)/notifications/layout.tsx index dce97938..d3c7221f 100644 --- a/apps/dashboard/src/app/(dashboard)/notifications/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/notifications/layout.tsx @@ -4,7 +4,7 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; @@ -14,8 +14,7 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - const queryClient = getQueryClient(); - await queryClient.prefetchQuery(trpc.notification.list.queryOptions()); + prefetch(trpc.notification.list.queryOptions()); return ( diff --git a/apps/dashboard/src/app/(dashboard)/overview/layout.tsx b/apps/dashboard/src/app/(dashboard)/overview/layout.tsx index a2678679..255fba4f 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/layout.tsx @@ -4,25 +4,22 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { overviewActiveOrClosedSince } from "@/data/overview-window"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const queryClient = getQueryClient(); +export default function Layout({ children }: { children: React.ReactNode }) { + const activeOrClosedSince = overviewActiveOrClosedSince(); // inputs must match page.tsx's queries exactly — a differing input misses the cache - await Promise.all([ - queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), - queryClient.prefetchQuery(trpc.page.list.queryOptions()), - queryClient.prefetchQuery(trpc.incident.list.queryOptions()), - queryClient.prefetchQuery(trpc.statusReport.list.queryOptions({})), - queryClient.prefetchQuery(trpc.maintenance.list.queryOptions()), + batchPrefetch([ + trpc.monitor.list.queryOptions(), + trpc.page.list.queryOptions(), + trpc.incident.list.queryOptions({ activeOrClosedSince }), + trpc.statusReport.list.queryOptions({ activeOrClosedSince }), + trpc.maintenance.list.queryOptions({ activeOrClosedSince }), ]); return ( diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx index 943a2e7e..75d386b8 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -29,24 +29,34 @@ import { } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; import { buildOverviewData } from "@/data/overview-events.client"; +import { overviewActiveOrClosedSince } from "@/data/overview-window"; import { useTRPC } from "@/lib/trpc/client"; import { CreateEventButtonGroup } from "./create-event-button-group"; +import { OverviewSkeleton } from "./skeleton"; export default function Page() { const trpc = useTRPC(); + // Not a period filter: everything still open is returned regardless of age, + // so an incident open for weeks still surfaces. Only *closed* rows are + // windowed, and those are all this page renders under "Recent Activity". + const activeOrClosedSince = overviewActiveOrClosedSince(); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: pages } = useQuery(trpc.page.list.queryOptions()); - // no period — an incident open for weeks must still surface here - const { data: incidents } = useQuery(trpc.incident.list.queryOptions()); + const { data: incidents } = useQuery( + trpc.incident.list.queryOptions({ activeOrClosedSince }), + ); const { data: statusReports } = useQuery( - trpc.statusReport.list.queryOptions({}), + trpc.statusReport.list.queryOptions({ activeOrClosedSince }), + ); + const { data: maintenances } = useQuery( + trpc.maintenance.list.queryOptions({ activeOrClosedSince }), ); - const { data: maintenances } = useQuery(trpc.maintenance.list.queryOptions()); if (!monitors || !pages || !incidents || !statusReports || !maintenances) - return null; + return ; const { needsAttention, upcomingMaintenances, recentlyResolved, metrics } = buildOverviewData({ diff --git a/apps/dashboard/src/app/(dashboard)/overview/skeleton.tsx b/apps/dashboard/src/app/(dashboard)/overview/skeleton.tsx new file mode 100644 index 00000000..1ac95bc2 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/overview/skeleton.tsx @@ -0,0 +1,70 @@ +import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; + +import { + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { Section } from "@/components/content/section"; +import { + MetricCard, + MetricCardGroup, + MetricCardHeader, + MetricCardTitle, +} from "@/components/metric/metric-card"; +import { DataTableSkeleton } from "@/components/ui/data-table/data-table-skeleton"; + +const METRIC_COUNT = 5; + +/** + * Mirrors the section/card structure of `page.tsx` so the streamed shell and + * the loaded page occupy the same space — a smaller placeholder would shift + * the layout on hydration. + */ +export function OverviewSkeleton() { + return ( + +
+ + Overview + + Welcome to your OpenStatus dashboard. + + + + {Array.from({ length: METRIC_COUNT }).map((_, i) => ( + + + + + + + + + + ))} + +
+
+ + Needs Attention + + Everything currently unresolved, newest first. + + + +
+
+ + Recent Activity + + Resolved incidents and reports, and completed maintenances from the + last 7 days. + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx index e69321a7..663ee970 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx @@ -4,7 +4,7 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; @@ -15,8 +15,7 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - const queryClient = getQueryClient(); - await queryClient.prefetchQuery(trpc.member.list.queryOptions()); + prefetch(trpc.member.list.queryOptions()); return ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/layout.tsx index 9b7c7686..082bf909 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/layout.tsx @@ -4,7 +4,7 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; @@ -15,8 +15,7 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - const queryClient = getQueryClient(); - await queryClient.prefetchQuery(trpc.auditLog.list.queryOptions({})); + prefetch(trpc.auditLog.list.queryOptions({})); return (
diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx index bbbd8dd1..585028b6 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx @@ -4,22 +4,17 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const queryClient = getQueryClient(); - await Promise.all([ - queryClient.prefetchQuery(trpc.member.list.queryOptions()), - queryClient.prefetchQuery(trpc.invitation.list.queryOptions()), - queryClient.prefetchQuery(trpc.apiKey.list.queryOptions()), +export default function Layout({ children }: { children: React.ReactNode }) { + batchPrefetch([ + trpc.member.list.queryOptions(), + trpc.invitation.list.queryOptions(), + trpc.apiKey.list.queryOptions(), ]); return ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx index dbc79ae9..2e1d0a8a 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx @@ -4,21 +4,16 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const queryClient = getQueryClient(); - await Promise.all([ - queryClient.prefetchQuery(trpc.integrationRouter.list.queryOptions()), - queryClient.prefetchQuery(trpc.workspace.get.queryOptions()), +export default function Layout({ children }: { children: React.ReactNode }) { + batchPrefetch([ + trpc.integrationRouter.list.queryOptions(), + trpc.workspace.get.queryOptions(), ]); return ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/layout.tsx index f33fb7f3..90064ba4 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/layout.tsx @@ -4,21 +4,16 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const queryClient = getQueryClient(); - await Promise.all([ - queryClient.prefetchQuery(trpc.notification.list.queryOptions()), - queryClient.prefetchQuery(trpc.privateLocation.list.queryOptions()), +export default function Layout({ children }: { children: React.ReactNode }) { + batchPrefetch([ + trpc.notification.list.queryOptions(), + trpc.privateLocation.list.queryOptions(), ]); return ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/sso/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/sso/layout.tsx index 36b8d7cb..eeaca1f7 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/sso/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/sso/layout.tsx @@ -4,7 +4,7 @@ import { AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; @@ -15,8 +15,7 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - const queryClient = getQueryClient(); - await queryClient.prefetchQuery(trpc.workspace.get.queryOptions()); + prefetch(trpc.workspace.get.queryOptions()); return ( diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx index fc3e62a6..1a42164a 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx @@ -1,11 +1,9 @@ -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Client } from "./client"; -export default async function Page() { - const queryClient = getQueryClient(); - - await queryClient.prefetchQuery(trpc.page.list.queryOptions()); +export default function Page() { + prefetch(trpc.page.list.queryOptions()); return ( diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx index 9b18b101..d92a2047 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx @@ -4,7 +4,7 @@ import { RIGHT_SIDEBAR_COOKIE, getSidebarDefaultOpen, } from "@/lib/sidebar-cookie"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; import { Sidebar } from "../sidebar"; @@ -16,16 +16,11 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; - const queryClient = getQueryClient(); - await Promise.all([ - queryClient.prefetchQuery( - trpc.page.get.queryOptions({ id: Number.parseInt(id) }), - ), - queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), - queryClient.prefetchQuery( - trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), - ), + batchPrefetch([ + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + trpc.monitor.list.queryOptions(), + trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), ]); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/layout.tsx index 9b18b101..d92a2047 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/layout.tsx @@ -4,7 +4,7 @@ import { RIGHT_SIDEBAR_COOKIE, getSidebarDefaultOpen, } from "@/lib/sidebar-cookie"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, batchPrefetch, trpc } from "@/lib/trpc/server"; import { Sidebar } from "../sidebar"; @@ -16,16 +16,11 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; - const queryClient = getQueryClient(); - await Promise.all([ - queryClient.prefetchQuery( - trpc.page.get.queryOptions({ id: Number.parseInt(id) }), - ), - queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), - queryClient.prefetchQuery( - trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), - ), + batchPrefetch([ + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + trpc.monitor.list.queryOptions(), + trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), ]); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx index 706e19c5..b52c6dd2 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx @@ -1,6 +1,6 @@ import type { SearchParams } from "nuqs"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; @@ -13,11 +13,10 @@ export default async function Page({ searchParams: Promise; }) { const { id } = await params; - const queryClient = getQueryClient(); // NOTE: store in cache to avoid flicker on clients first render await searchParamsCache.parse(searchParams); - await queryClient.prefetchQuery( + prefetch( trpc.page.getUptimeHistory.queryOptions({ id: Number.parseInt(id) }), ); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx index eb9eac21..95e18960 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx @@ -7,7 +7,7 @@ import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, fetchQueryOrNotFound, - getQueryClient, + prefetch, trpc, } from "@/lib/trpc/server"; @@ -23,12 +23,13 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; - const queryClient = getQueryClient(); + // Started before the gate is awaited so the 404 check costs one round trip + // rather than serialising ahead of it. + prefetch(trpc.monitor.list.queryOptions()); await fetchQueryOrNotFound( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); - await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); return ( diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx index 3b65d74b..66e9d39b 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx @@ -4,7 +4,7 @@ import { RIGHT_SIDEBAR_COOKIE, getSidebarDefaultOpen, } from "@/lib/sidebar-cookie"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Sidebar } from "../sidebar"; @@ -16,9 +16,8 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; - const queryClient = getQueryClient(); - await queryClient.prefetchQuery( + prefetch( trpc.maintenance.list.queryOptions({ pageId: Number.parseInt(id), }), diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx index 9a6eb992..6118c550 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx @@ -4,7 +4,7 @@ import { RIGHT_SIDEBAR_COOKIE, getSidebarDefaultOpen, } from "@/lib/sidebar-cookie"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; import { Sidebar } from "../sidebar"; @@ -15,9 +15,8 @@ export default async function Layout({ children: React.ReactNode; params: Promise<{ id: string }>; }) { - const queryClient = getQueryClient(); const { id } = await params; - await queryClient.prefetchQuery( + prefetch( trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), ); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx index 012f9e6c..1c642cc2 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx @@ -1,4 +1,4 @@ -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, prefetch, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, @@ -7,10 +7,9 @@ export default async function Layout({ children: React.ReactNode; params: Promise<{ id: string }>; }) { - const queryClient = getQueryClient(); const { id } = await params; - await queryClient.prefetchQuery( + prefetch( trpc.pageSubscriber.list.queryOptions({ pageId: Number.parseInt(id) }), ); diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index df18a14d..9ed77270 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -11,7 +11,6 @@ import { NuqsAdapter } from "nuqs/adapters/next/app"; import { TailwindIndicator } from "@/components/tailwind-indicator"; import { ThemeProvider } from "@/components/theme-provider"; -import { auth } from "@/lib/auth"; import { TRPCReactProvider } from "@/lib/trpc/client"; import { ogMetadata, twitterMetadata } from "./metadata"; @@ -75,13 +74,11 @@ export const metadata: Metadata = { // export const dynamic = "error"; -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const session = await auth(); - return ( - + {children}; diff --git a/apps/dashboard/src/data/overview-window.ts b/apps/dashboard/src/data/overview-window.ts new file mode 100644 index 00000000..af33c147 --- /dev/null +++ b/apps/dashboard/src/data/overview-window.ts @@ -0,0 +1,18 @@ +export const OVERVIEW_WINDOW_DAYS = 7; + +/** + * Start of the window `/overview` renders: everything still open, plus what + * closed in the last {@link OVERVIEW_WINDOW_DAYS} days. + * + * Truncated to the hour so the server prefetch and the client query hash to the + * same tRPC query key — an exact `new Date()` on each side would differ by + * milliseconds and miss the hydrated cache. Flooring widens the window, so the + * result is always a superset of what the page filters down to. + */ +export function overviewActiveOrClosedSince(now = new Date()): Date { + const hour = new Date(now); + hour.setUTCMinutes(0, 0, 0); + return new Date( + hour.getTime() - OVERVIEW_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); +} diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts index 2566c03f..c9bfa981 100644 --- a/packages/api/src/router/incident.ts +++ b/packages/api/src/router/incident.ts @@ -37,6 +37,7 @@ export const incidentRouter = createTRPCRouter({ period: z.enum(periods).optional(), monitorId: z.number().nullish(), order: z.enum(["asc", "desc"]).optional(), + activeOrClosedSince: z.coerce.date().optional(), }) .optional(), ) @@ -48,6 +49,7 @@ export const incidentRouter = createTRPCRouter({ monitorId: input?.monitorId ?? undefined, period: input?.period, order: input?.order ?? "desc", + activeOrClosedSince: input?.activeOrClosedSince, // Same sentinel as status-report / maintenance — dashboard has // no paging UI; Connect-equivalent would cap externally. limit: 10_000, diff --git a/packages/api/src/router/maintenance.ts b/packages/api/src/router/maintenance.ts index 12b46da7..d4750eaf 100644 --- a/packages/api/src/router/maintenance.ts +++ b/packages/api/src/router/maintenance.ts @@ -42,6 +42,7 @@ export const maintenanceRouter = createTRPCRouter({ period: z.enum(periods).optional(), pageId: z.number().optional(), order: z.enum(["asc", "desc"]).optional(), + activeOrClosedSince: z.coerce.date().optional(), }) .optional(), ) @@ -53,6 +54,7 @@ export const maintenanceRouter = createTRPCRouter({ pageId: input?.pageId, period: input?.period, order: input?.order ?? "desc", + activeOrClosedSince: input?.activeOrClosedSince, // tRPC callers (dashboard) want the full set — see // statusReport.list for the same reasoning. limit: 10_000, diff --git a/packages/api/src/router/statusReport.ts b/packages/api/src/router/statusReport.ts index 480e5101..67bbf500 100644 --- a/packages/api/src/router/statusReport.ts +++ b/packages/api/src/router/statusReport.ts @@ -203,6 +203,7 @@ export const statusReportRouter = createTRPCRouter({ period: z.enum(periods).optional(), order: z.enum(["asc", "desc"]).optional(), pageId: z.number().optional(), + activeOrClosedSince: z.coerce.date().optional(), }), ) .query(async ({ ctx, input }) => { @@ -213,6 +214,7 @@ export const statusReportRouter = createTRPCRouter({ pageId: input.pageId, period: input.period, order: input.order ?? "desc", + activeOrClosedSince: input.activeOrClosedSince, // tRPC consumers (dashboard) want the full set — no paging UI // today. Pass a sentinel ceiling rather than silently truncating; // Connect's separate handler imposes its own max=100. diff --git a/packages/services/src/incident/__tests__/incident.test.ts b/packages/services/src/incident/__tests__/incident.test.ts index db0028be..53cad437 100644 --- a/packages/services/src/incident/__tests__/incident.test.ts +++ b/packages/services/src/incident/__tests__/incident.test.ts @@ -315,4 +315,44 @@ describe("list / get", () => { expect(ours[1]?.monitor?.id).toBe(testMonitorId); }); }); + + test("activeOrClosedSince keeps every open incident regardless of age", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const day = 24 * 60 * 60 * 1000; + + const open = await insertIncident(tx, { + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + const recentlyResolved = await insertIncident(tx, { + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + resolvedAt: new Date(Date.now() - 2 * day), + }); + const longResolved = await insertIncident(tx, { + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + resolvedAt: new Date(Date.now() - 30 * day), + }); + + const { items } = await listIncidents({ + ctx, + input: { + limit: 100, + offset: 0, + order: "desc", + monitorId: testMonitorId, + activeOrClosedSince: new Date(Date.now() - 7 * day), + }, + }); + + const ids = items.map((i) => i.id); + // Open incidents are never windowed out — an incident open for weeks + // must still surface on /overview. + expect(ids).toContain(open.id); + expect(ids).toContain(recentlyResolved.id); + expect(ids).not.toContain(longResolved.id); + }); + }); }); diff --git a/packages/services/src/incident/list.ts b/packages/services/src/incident/list.ts index 6fe1731f..0220fa89 100644 --- a/packages/services/src/incident/list.ts +++ b/packages/services/src/incident/list.ts @@ -7,6 +7,8 @@ import { eq, gte, inArray, + isNull, + or, sql, } from "@openstatus/db"; import { @@ -97,13 +99,24 @@ export async function listIncidents(args: { const input = ListIncidentsInput.parse(args.input); const db = ctx.db ?? defaultDb; - const conditions: SQL[] = [eq(incidentTable.workspaceId, ctx.workspace.id)]; + // `or(...)` is typed `SQL | undefined`, and `and()` drops undefined entries. + const conditions: (SQL | undefined)[] = [ + eq(incidentTable.workspaceId, ctx.workspace.id), + ]; if (input.monitorId !== undefined) { conditions.push(eq(incidentTable.monitorId, input.monitorId)); } if (input.period !== undefined) { conditions.push(gte(incidentTable.startedAt, periodToSince(input.period))); } + if (input.activeOrClosedSince !== undefined) { + conditions.push( + or( + isNull(incidentTable.resolvedAt), + gte(incidentTable.resolvedAt, input.activeOrClosedSince), + ), + ); + } const whereClause = and(...conditions); const [countRow, rows] = await Promise.all([ diff --git a/packages/services/src/incident/schemas.ts b/packages/services/src/incident/schemas.ts index b90054fa..22c5685a 100644 --- a/packages/services/src/incident/schemas.ts +++ b/packages/services/src/incident/schemas.ts @@ -26,5 +26,10 @@ export const ListIncidentsInput = z.object({ period: incidentListPeriodSchema.optional(), monitorId: z.number().int().optional(), order: z.enum(["asc", "desc"]).default("desc"), + /** + * Keep every unresolved incident, plus those resolved at or after this date. + * Lets a caller that only renders current state skip the full history. + */ + activeOrClosedSince: z.coerce.date().optional(), }); export type ListIncidentsInput = z.infer; diff --git a/packages/services/src/maintenance/list.ts b/packages/services/src/maintenance/list.ts index 5f98a34f..dda14a17 100644 --- a/packages/services/src/maintenance/list.ts +++ b/packages/services/src/maintenance/list.ts @@ -110,6 +110,11 @@ export async function listMaintenances(args: { if (input.period !== undefined) { conditions.push(gte(maintenance.createdAt, periodToSince(input.period))); } + if (input.activeOrClosedSince !== undefined) { + // A window is in-progress or upcoming exactly while `to >= now`, so this + // one predicate covers both "still active" and "ended recently". + conditions.push(gte(maintenance.to, input.activeOrClosedSince)); + } const whereClause = and(...conditions); // Count and page queries run in parallel outside a transaction — a diff --git a/packages/services/src/maintenance/schemas.ts b/packages/services/src/maintenance/schemas.ts index 31172b46..94fc0ac5 100644 --- a/packages/services/src/maintenance/schemas.ts +++ b/packages/services/src/maintenance/schemas.ts @@ -50,6 +50,12 @@ export const ListMaintenancesInput = z.object({ pageId: z.number().int().optional(), period: maintenanceListPeriodSchema.optional(), order: z.enum(["asc", "desc"]).default("desc"), + /** + * Keep every in-progress or upcoming window, plus those that ended at or + * after this date. Lets a caller that only renders current state skip the + * full history. + */ + activeOrClosedSince: z.coerce.date().optional(), }); export type ListMaintenancesInput = z.infer; diff --git a/packages/services/src/monitor/__tests__/reads.test.ts b/packages/services/src/monitor/__tests__/reads.test.ts index 3c770511..2f6f0ee4 100644 --- a/packages/services/src/monitor/__tests__/reads.test.ts +++ b/packages/services/src/monitor/__tests__/reads.test.ts @@ -1,5 +1,6 @@ import { eq } from "@openstatus/db"; import { + incidentTable, monitor, monitorStatusTable, privateLocation, @@ -13,9 +14,10 @@ import { makeUserCtx, withTestTransaction, } from "../../../test/helpers"; -import type { ServiceContext } from "../../context"; +import type { DrizzleTx, ServiceContext } from "../../context"; import { ForbiddenError, NotFoundError, ValidationError } from "../../errors"; import { createMonitor } from "../create"; +import { listMonitors } from "../list"; import { fetchMonitorDailyStats } from "../get-daily-summary"; import { getMonitorStatus } from "../get-monitor-status"; import { getMonitorSummary } from "../get-monitor-summary"; @@ -35,6 +37,88 @@ beforeAll(async () => { freeCtx = makeUserCtx(free, { userId: 2 }); }); +function insertIncidentRow( + tx: DrizzleTx, + opts: { + monitorId: number; + workspaceId: number; + startedAt: Date; + resolvedAt: Date | null; + }, +) { + return tx + .insert(incidentTable) + .values({ + monitorId: opts.monitorId, + workspaceId: opts.workspaceId, + startedAt: opts.startedAt, + resolvedAt: opts.resolvedAt, + }) + .returning() + .get(); +} + +describe("listMonitors incident enrichment", () => { + test("returns open incidents plus the latest, newest first", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-incident-window`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + regions: ["ams"], + }, + }); + + const minute = 60 * 1000; + const at = (agoMinutes: number) => + new Date(Date.now() - agoMinutes * minute); + // Unique (monitor_id, started_at) — each row needs a distinct start. + // The newest row is resolved and the open one is older, so the two + // branches of the filter are exercised independently. + const oldResolved = await insertIncidentRow(tx, { + monitorId: row.id, + workspaceId: teamCtx.workspace.id, + startedAt: at(50), + resolvedAt: at(45), + }); + const open = await insertIncidentRow(tx, { + monitorId: row.id, + workspaceId: teamCtx.workspace.id, + startedAt: at(40), + resolvedAt: null, + }); + const newestResolved = await insertIncidentRow(tx, { + monitorId: row.id, + workspaceId: teamCtx.workspace.id, + startedAt: at(20), + resolvedAt: at(15), + }); + + const { items } = await listMonitors({ + ctx, + input: { limit: 100, offset: 0, order: "desc" }, + }); + const mine = items.find((m) => m.id === row.id); + const ids = (mine?.incidents ?? []).map((i) => i.id); + + // Latest by startedAt, plus everything unresolved. The older resolved + // row is dropped rather than pulling the monitor's whole history. + expect(ids).toContain(newestResolved.id); + expect(ids).toContain(open.id); + expect(ids).not.toContain(oldResolved.id); + // `incidents[0]` drives the "Last Incident" column, so ordering matters. + expect(ids[0]).toBe(newestResolved.id); + }); + }); +}); + describe("getMonitorStatus", () => { test("throws NotFoundError for cross-workspace monitorId", async () => { await withTestTransaction(async (tx) => { diff --git a/packages/services/src/monitor/list.ts b/packages/services/src/monitor/list.ts index 77d14e5e..36e8e26e 100644 --- a/packages/services/src/monitor/list.ts +++ b/packages/services/src/monitor/list.ts @@ -5,8 +5,10 @@ import { db as defaultDb, desc, eq, + getTableColumns, inArray, isNull, + or, sql, } from "@openstatus/db"; import { @@ -53,6 +55,42 @@ export type ListMonitorsResult = { totalSize: number; }; +/** + * Every open incident for these monitors, plus each monitor's most recent one. + * Consumers read only the open count and the latest incident's date, so + * selecting the full history made `monitor.list` — prefetched by the sidebar on + * every dashboard route — scale with the workspace's lifetime incident count. + */ +function recentIncidents(db: DB, monitorIds: number[], workspaceId: number) { + const ranked = db + .select({ + ...getTableColumns(incidentTable), + // Alias avoids `rank` / `row_number`, which name SQLite window functions. + recencyRank: sql`row_number() over (partition by ${incidentTable.monitorId} order by ${incidentTable.startedAt} desc)`.as( + "recency_rank", + ), + }) + .from(incidentTable) + .where( + and( + inArray(incidentTable.monitorId, monitorIds), + // Scope to caller's workspace — defence-in-depth in case an + // incident.monitorId somehow points cross-workspace. The + // `incident.monitorId` FK doesn't enforce workspace ownership. + eq(incidentTable.workspaceId, workspaceId), + ), + ) + .as("ranked"); + + // Newest first so consumers reading `incidents[0]` get the latest incident; + // the unordered select this replaced left that row arbitrary. + return db + .select() + .from(ranked) + .where(or(eq(ranked.recencyRank, 1), isNull(ranked.resolvedAt))) + .orderBy(desc(ranked.startedAt)); +} + /** * Batched enrichment for a list of monitors — loads tags + incidents in * two IN queries (three if notifications / privateLocations are also @@ -90,19 +128,7 @@ async function enrichMonitorsBatch( ), ) .all(), - db - .select() - .from(incidentTable) - .where( - and( - inArray(incidentTable.monitorId, ids), - // Scope to caller's workspace — defence-in-depth in case an - // incident.monitorId somehow points cross-workspace. The - // `incident.monitorId` FK doesn't enforce workspace ownership. - eq(incidentTable.workspaceId, workspaceId), - ), - ) - .all(), + recentIncidents(db, ids, workspaceId).all(), include.notifications ? db .select({ diff --git a/packages/services/src/status-report/list.ts b/packages/services/src/status-report/list.ts index 15f59f9e..cb06c219 100644 --- a/packages/services/src/status-report/list.ts +++ b/packages/services/src/status-report/list.ts @@ -7,6 +7,8 @@ import { eq, gte, inArray, + ne, + or, sql, } from "@openstatus/db"; import { @@ -211,7 +213,10 @@ export async function listStatusReports(args: { const input = ListStatusReportsInput.parse(args.input); const db = ctx.db ?? defaultDb; - const conditions: SQL[] = [eq(statusReport.workspaceId, ctx.workspace.id)]; + // `or(...)` is typed `SQL | undefined`, and `and()` drops undefined entries. + const conditions: (SQL | undefined)[] = [ + eq(statusReport.workspaceId, ctx.workspace.id), + ]; if (input.statuses.length > 0) { conditions.push(inArray(statusReport.status, input.statuses)); } @@ -221,6 +226,14 @@ export async function listStatusReports(args: { if (input.period !== undefined) { conditions.push(gte(statusReport.createdAt, periodToSince(input.period))); } + if (input.activeOrClosedSince !== undefined) { + conditions.push( + or( + ne(statusReport.status, "resolved"), + gte(statusReport.updatedAt, input.activeOrClosedSince), + ), + ); + } const whereClause = and(...conditions); const rows = await db diff --git a/packages/services/src/status-report/schemas.ts b/packages/services/src/status-report/schemas.ts index 0536ac11..dfaa61a1 100644 --- a/packages/services/src/status-report/schemas.ts +++ b/packages/services/src/status-report/schemas.ts @@ -109,6 +109,13 @@ export const ListStatusReportsInput = z.object({ pageId: z.number().int().optional(), period: statusReportListPeriodSchema.optional(), order: z.enum(["asc", "desc"]).default("desc"), + /** + * Keep every unresolved report, plus those touched at or after this date. + * `updatedAt` is a safe proxy for the resolution time here: resolving writes + * the row, so it is always >= the resolving update's date. Lets a caller + * that only renders current state skip the full history. + */ + activeOrClosedSince: z.coerce.date().optional(), }); export type ListStatusReportsInput = z.infer;