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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
This commit is contained in:
2026-09-06 15:26:48 +02:00
co-authored by Claude Opus 5
parent 8cb843eb25
commit ff612623a1
9 changed files with 271 additions and 86 deletions
+75
View File
@@ -575,6 +575,30 @@ export const DashboardSummarySchema = {
type: 'integer',
title: 'Version',
default: 1
},
columns: {
type: 'integer',
title: 'Columns',
default: 12
},
footprint: {
items: {
'$ref': '#/components/schemas/WidgetFootprint'
},
type: 'array',
title: 'Footprint'
},
updated_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Updated At'
}
},
type: 'object',
@@ -1303,6 +1327,18 @@ export const FlowSummarySchema = {
type: 'integer',
title: 'Version',
default: 1
},
updated_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Updated At'
}
},
type: 'object',
@@ -3834,6 +3870,45 @@ The first two are a \`\`list\`\` message, the third a \`\`str\`\`, which is what
\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.`
} as const;
export const WidgetFootprintSchema = {
properties: {
x: {
type: 'integer',
title: 'X',
default: 0
},
y: {
type: 'integer',
title: 'Y',
default: 0
},
w: {
type: 'integer',
title: 'W',
default: 3
},
h: {
type: 'integer',
title: 'H',
default: 2
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
title: 'Type'
}
},
type: 'object',
required: ['type'],
title: 'WidgetFootprint',
description: `A widget reduced to the shape it draws: what it is, and where it sits.
The whole of what sketching a dashboard's outline needs — a mosaic tile
shades a block by kind and puts it in the grid, and reads nothing else. So
no id (the drawing has no use for one), no title, no config, and one
placement rather than the layout's breakpoint per screen size.`
} as const;
export const WorkerInfoSchema = {
properties: {
name: {
+20
View File
@@ -158,6 +158,9 @@ export type DashboardSummary = {
widget_count?: number;
has_draft?: boolean;
version?: number;
columns?: number;
footprint?: Array<WidgetFootprint>;
updated_at?: (string | null);
};
export type DeadLetter = {
@@ -408,6 +411,7 @@ export type FlowSummary = {
paused?: boolean;
quarantined?: boolean;
version?: number;
updated_at?: (string | null);
};
export type fluksio__api__routes__dashboards__PublishRequest = {
@@ -1314,6 +1318,22 @@ export type WidgetDef = {
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
/**
* A widget reduced to the shape it draws: what it is, and where it sits.
*
* The whole of what sketching a dashboard's outline needs — a mosaic tile
* shades a block by kind and puts it in the grid, and reads nothing else. So
* no id (the drawing has no use for one), no title, no config, and one
* placement rather than the layout's breakpoint per screen size.
*/
export type WidgetFootprint = {
x?: number;
y?: number;
w?: number;
h?: number;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
};
export type WorkerInfo = {
name: string;
labels?: Array<(string)>;
@@ -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>
)