Give both summaries a modified time, and the dashboard list a footprint

Home's "recently modified" order was a proxy — drafts first, then the
version counter, then the name. Both summaries now carry `updated_at`,
read as the mtime of the working copy: every write in the store commits
immediately, so a file's mtime is its commit time, and `git log -1 --
<path>` costs ~990ms across this instance's 15 documents (it walks the
history back to the last commit touching each one, so it is slowest for
the stalest) against ~1.8ms for the stats. No cache needed.

`DashboardSummary` also carries `footprint`: each widget as its type plus
the placement a panel resolves, which is the whole of what the mosaic
draws. That drops the document fetch per tile and with it the cap of
eight, past which tiles showed a name and nothing else. Verified against
this instance's 8 dashboards: the blocks are identical to what the old
client-side derivation produced.
This commit is contained in:
2026-09-06 15:26:48 +02:00
parent 8c097d9222
commit fa5ce4f694
9 changed files with 271 additions and 86 deletions
@@ -1,31 +1,13 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { LayoutDashboard } from "lucide-react"
import type {
DashboardDef_Output,
DashboardSummary,
Placement,
WidgetDef,
} from "@/client"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import type { DashboardSummary, WidgetFootprint } from "@/client"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
/** Columns a dashboard is cut into when its document does not say. */
const DEFAULT_COLUMNS = 12
/**
* How many tiles are worth a request of their own.
*
* ponytail: the list endpoint carries no placements, so a footprint means
* reading that dashboard's document — cheap for the handful an instance
* has, and shared with the editor's own cache. The ceiling is an instance
* with dozens: the tiles past this show their name and nothing else, and the
* fix would be a stored footprint on `DashboardSummary`.
*/
const PREVIEWS = 8
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
const INPUT = new Set([
@@ -44,20 +26,7 @@ const shade = (type: string) =>
? "bg-primary/45"
: "bg-primary/20"
/**
* Where a widget sits, at the width a panel is arranged for.
*
* The same three-line fallback as `Dashboard/DashboardView`, written out again
* rather than imported: that module pulls the whole dashboard chunk, and this
* draws a schematic on a screen that shows no dashboards.
*/
const placement = (widget: WidgetDef): Placement => {
const layout = (widget.layout ?? {}) as Record<string, Placement>
return layout.lg ?? layout.md ?? layout.sm ?? {}
}
type Block = {
id: string
type: string
x: number
y: number
@@ -65,18 +34,17 @@ type Block = {
h: number
}
/** The dashboard's widgets as one grid. */
function blocksOf(dashboard: DashboardDef_Output): {
/** The dashboard's widget shapes as one grid. */
function blocksOf(footprint: WidgetFootprint[]): {
blocks: Block[]
rows: number
} {
const blocks: Block[] = []
let rows = 0
for (const widget of dashboard.widgets ?? []) {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
for (const shape of footprint) {
const { x = 0, y = 0, w = 3, h = 2 } = shape
blocks.push({
id: widget.id,
type: widget.type,
type: shape.type,
x: Math.max(0, x),
y: Math.max(0, y),
w: Math.max(1, w),
@@ -94,10 +62,10 @@ function blocksOf(dashboard: DashboardDef_Output): {
* A footprint rather than a live render. Nothing here subscribes to a message
* or reads a value — recognising "the one with the big chart on the left" is
* the whole job, and it has to cost nothing on a screen that is not the
* dashboard.
* dashboard. The summary carries the shapes, so it costs no request either.
*/
function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
const { blocks, rows } = blocksOf(dashboard)
function Footprint({ dashboard }: { dashboard: DashboardSummary }) {
const { blocks, rows } = blocksOf(dashboard.footprint ?? [])
const columns = dashboard.columns || DEFAULT_COLUMNS
// Before the editor could place things, every widget was written at 0,0;
// honouring that would pile the whole page onto one cell.
@@ -123,11 +91,11 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
}, minmax(0, 1fr))`,
}}
>
{blocks.map((block) => {
{blocks.map((block, index) => {
const width = Math.min(columns, block.w)
return (
<span
key={block.id}
key={index}
className={cn("rounded-[2px]", shade(block.type))}
style={
placed
@@ -144,30 +112,15 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
)
}
/**
* One dashboard in the mosaic.
*
* Its own query, so the tiles fill in as their documents arrive instead of the
* whole panel waiting for the slowest of them.
*/
/** One dashboard in the mosaic. */
function Tile({
dashboard,
preview,
className,
}: {
dashboard: DashboardSummary
/** Read the document for a footprint, or settle for the name alone. */
preview: boolean
/** What the layout needs of it — a width, in the scrolling strip. */
className?: string
}) {
// The working copy, which is what the list itself is a summary of, so the
// preview shows what an editor would open rather than the last publish.
const { data, isPending } = useQuery({
...dashboardQueryOptions(dashboard.name, true),
enabled: preview,
})
return (
<Link
to="/dashboards/$name"
@@ -178,15 +131,7 @@ function Tile({
className,
)}
>
{preview && isPending ? (
<Skeleton className="aspect-video rounded-sm" />
) : data ? (
<Footprint dashboard={data} />
) : (
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
<LayoutDashboard className="size-5 text-muted-foreground" />
</div>
)}
<Footprint dashboard={dashboard} />
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{dashboard.title || dashboard.name}
@@ -201,22 +146,21 @@ function Tile({
)
}
/** When the working copy was last written, or the epoch for a summary without one. */
const modifiedAt = (summary: { updated_at?: string | null }) =>
summary.updated_at ? Date.parse(summary.updated_at) : 0
/**
* Which of two documents was worked on more recently.
*
* ponytail: neither `FlowSummary` nor `DashboardSummary` carries a modified
* time, so this reads the two things that come close — an unpublished edit is
* the one someone has open, and a higher version counter has been saved more
* often. An `updated_at` on both summaries is what would make it exact.
* Both summaries carry the modified time of the working copy, so this is the
* order it says it is. The name only settles a tie, which is two documents
* written in the same instant — what a seed does.
*/
export function byRecency<
T extends { name: string; has_draft?: boolean; version?: number },
T extends { name: string; updated_at?: string | null },
>(a: T, b: T): number {
return (
Number(b.has_draft ?? false) - Number(a.has_draft ?? false) ||
(b.version ?? 0) - (a.version ?? 0) ||
a.name.localeCompare(b.name)
)
return modifiedAt(b) - modifiedAt(a) || a.name.localeCompare(b.name)
}
/**
@@ -273,13 +217,8 @@ export function DashboardMosaic({
return (
<div className={container}>
{dashboards.map((dashboard, index) => (
<Tile
key={dashboard.name}
dashboard={dashboard}
preview={index < PREVIEWS}
className={tile}
/>
{dashboards.map((dashboard) => (
<Tile key={dashboard.name} dashboard={dashboard} className={tile} />
))}
</div>
)