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
+1
View File
@@ -291,6 +291,7 @@ def read_flows(controller: FlowControllerDep) -> Any:
paused=controller.is_paused(name), paused=controller.is_paused(name),
quarantined=controller.is_quarantined(name), quarantined=controller.is_quarantined(name),
version=definition.version, version=definition.version,
updated_at=controller.store.updated_at(name),
) )
) )
return FlowsPublic(data=summaries, count=len(summaries)) return FlowsPublic(data=summaries, count=len(summaries))
+49 -1
View File
@@ -18,6 +18,7 @@ nobody has published yet, which no panel can be shown.
from __future__ import annotations from __future__ import annotations
import threading import threading
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
@@ -25,7 +26,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator
from fluksio.flow.messages import DType, qualify from fluksio.flow.messages import DType, qualify
from fluksio.flow.schemas import FlowDef, _validate_name from fluksio.flow.schemas import FlowDef, _validate_name
from fluksio.flow.store import FlowStore, StaleVersion from fluksio.flow.store import FlowStore, StaleVersion, _updated_at
#: Sibling of the shared-node library, and likewise not a flow. #: Sibling of the shared-node library, and likewise not a flow.
DASHBOARD_DIR = "_dashboards" DASHBOARD_DIR = "_dashboards"
@@ -177,6 +178,18 @@ class Placement(BaseModel):
h: int = 2 h: int = 2
class WidgetFootprint(Placement):
"""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.
"""
type: WidgetType
class WidgetDef(BaseModel): class WidgetDef(BaseModel):
"""One tile: what it shows or does, and where it sits. """One tile: what it shows or does, and where it sits.
@@ -517,6 +530,25 @@ class DashboardDef(BaseModel):
"""Every message a bound setting reads. Empty for a static dashboard.""" """Every message a bound setting reads. Empty for a static dashboard."""
return [s.message for s in self.settings.values() if s.message] return [s.message for s in self.settings.values() if s.message]
@property
def footprint(self) -> list[WidgetFootprint]:
"""Every widget's shape, at the width the dashboard is arranged for.
``lg`` is that arrangement; the narrower breakpoints are derived from
it, and a widget nobody has placed falls back to the grid defaults —
the same order a panel resolves a layout in.
"""
shapes = []
for widget in self.widgets:
at = (
widget.layout.get("lg")
or widget.layout.get("md")
or widget.layout.get("sm")
or Placement()
)
shapes.append(WidgetFootprint(type=widget.type, **at.model_dump()))
return shapes
class DashboardSummary(BaseModel): class DashboardSummary(BaseModel):
"""A dashboard in a list, without its contents.""" """A dashboard in a list, without its contents."""
@@ -530,6 +562,14 @@ class DashboardSummary(BaseModel):
has_draft: bool = False has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read. #: Of the working copy, so publishing from a list needs no second read.
version: int = 1 version: int = 1
#: The grid `footprint` is placed in — see ``DashboardDef.columns``.
columns: int = 12
#: Every widget's shape, so a list can draw the dashboard's outline
#: without a document read per tile.
footprint: list[WidgetFootprint] = Field(default_factory=list)
#: When the working copy was last written, so a list can be ordered by
#: what was worked on rather than by how often it has been saved.
updated_at: datetime | None = None
class DashboardsPublic(BaseModel): class DashboardsPublic(BaseModel):
@@ -591,6 +631,9 @@ class DashboardStore:
widget_count=len(defn.widgets), widget_count=len(defn.widgets),
has_draft=defn.has_draft, has_draft=defn.has_draft,
version=defn.version, version=defn.version,
columns=defn.columns,
footprint=defn.footprint,
updated_at=self.updated_at(name),
) )
) )
return summaries return summaries
@@ -606,6 +649,10 @@ class DashboardStore:
"""Are there unpublished changes to this dashboard?""" """Are there unpublished changes to this dashboard?"""
return self._draft_file(name).exists() return self._draft_file(name).exists()
def updated_at(self, name: str) -> datetime | None:
"""When this dashboard was last written, draft or published."""
return _updated_at([self._file(name), self._draft_file(name)])
def read(self, name: str, draft: bool = False) -> DashboardDef: def read(self, name: str, draft: bool = False) -> DashboardDef:
"""The published dashboard, or with ``draft`` the working copy.""" """The published dashboard, or with ``draft`` the working copy."""
path = self._draft_file(name) if draft else self._file(name) path = self._draft_file(name) if draft else self._file(name)
@@ -915,5 +962,6 @@ __all__ = [
"Placement", "Placement",
"SettingDef", "SettingDef",
"WidgetDef", "WidgetDef",
"WidgetFootprint",
"default_dashboard", "default_dashboard",
] ]
+4
View File
@@ -7,6 +7,7 @@ A flow is structure plus code: this module is the structure. Node logic for
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
@@ -366,6 +367,9 @@ class FlowSummary(BaseModel):
quarantined: bool = False quarantined: bool = False
#: Of the working copy, so publishing from a list needs no second read. #: Of the working copy, so publishing from a list needs no second read.
version: int = 1 version: int = 1
#: When the working copy was last written, so a list can be ordered by
#: what was worked on rather than by how often it has been saved.
updated_at: datetime | None = None
class FlowsPublic(BaseModel): class FlowsPublic(BaseModel):
+28
View File
@@ -19,6 +19,8 @@ import logging
import shutil import shutil
import subprocess import subprocess
import threading import threading
from collections.abc import Iterable
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from fluksio.flow.schemas import FlowDef from fluksio.flow.schemas import FlowDef
@@ -84,6 +86,20 @@ class StaleVersion(ValueError):
return f"Flow '{self.name}' has changed since you loaded it" return f"Flow '{self.name}' has changed since you loaded it"
def _updated_at(paths: Iterable[Path]) -> datetime | None:
"""When the newest of these files was written, or ``None`` if none exist.
A file's mtime, not the commit that recorded it: every write here commits
immediately, so the two are the same instant, and `git log -1 -- <path>`
would walk the history back to the last commit touching that path — 130ms
for a document nobody has edited in a week, against a `stat` for all of
them. Slowest for exactly the stalest documents is the wrong shape for a
list endpoint.
"""
stamps = [path.stat().st_mtime for path in paths if path.exists()]
return datetime.fromtimestamp(max(stamps), UTC) if stamps else None
def _same_content(left: FlowDef, right: FlowDef) -> bool: def _same_content(left: FlowDef, right: FlowDef) -> bool:
"""Equal but for the version counter, which the server owns.""" """Equal but for the version counter, which the server owns."""
return left.model_copy(update={"version": 0}) == right.model_copy( return left.model_copy(update={"version": 0}) == right.model_copy(
@@ -386,6 +402,18 @@ class FlowStore:
drafts = self._draft_nodes_dir(name) drafts = self._draft_nodes_dir(name)
return drafts.exists() and any(drafts.glob("*.py")) return drafts.exists() and any(drafts.glob("*.py"))
def updated_at(self, name: str) -> datetime | None:
"""When this flow was last written — its document or any node's source.
Node code is saved without touching the flow document, so a flow whose
last change was to a node body would otherwise look untouched.
"""
return _updated_at(
[self._flow_file(name), self._draft_file(name)]
+ list((self._flow_dir(name) / "nodes").glob("*.py"))
+ list(self._draft_nodes_dir(name).glob("*.py"))
)
def read_flow(self, name: str, draft: bool = False) -> FlowDef: def read_flow(self, name: str, draft: bool = False) -> FlowDef:
"""The published flow, or with ``draft`` the working copy.""" """The published flow, or with ``draft`` the working copy."""
if draft: if draft:
+50
View File
@@ -1,11 +1,14 @@
"""Dashboards: documents beside the flows, and the values their widgets move.""" """Dashboards: documents beside the flows, and the values their widgets move."""
from datetime import UTC, datetime
import pytest import pytest
from fluksio.flow.dashboards import ( from fluksio.flow.dashboards import (
DashboardDef, DashboardDef,
DashboardNotFound, DashboardNotFound,
DashboardStore, DashboardStore,
Placement,
SettingDef, SettingDef,
WidgetDef, WidgetDef,
default_dashboard, default_dashboard,
@@ -389,6 +392,53 @@ def test_a_querying_chart_keeps_no_ring():
assert widget.history_points == 0 assert widget.history_points == 0
def test_a_summary_carries_the_footprint_of_the_document(store: DashboardStore):
"""The mosaic draws from the list, so the shapes have to survive it."""
saved = store.write(
DashboardDef(
name="house",
columns=8,
widgets=[
WidgetDef(
id="temp",
type="chart",
layout={"lg": Placement(x=2, y=1, w=6, h=4)},
),
# Only a narrower breakpoint, and none at all: both resolve the
# way a panel resolves them.
WidgetDef(id="power", type="gauge", layout={"md": Placement(y=5, w=2)}),
WidgetDef(id="lamp", type="switch"),
],
)
)
(summary,) = store.list()
assert summary.columns == saved.columns
assert [(f.type, f.x, f.y, f.w, f.h) for f in summary.footprint] == [
("chart", 2, 1, 6, 4),
("gauge", 0, 5, 2, 2),
("switch", 0, 0, 3, 2),
]
def test_a_summary_carries_when_the_working_copy_was_written(store: DashboardStore):
published = store.write(default_dashboard("house"))
document = store.root / "house" / "dashboard.json"
assert store.list()[0].updated_at == datetime.fromtimestamp(
document.stat().st_mtime, UTC
)
# An unpublished edit is the working copy, so it is what the time is of.
store.write_draft(published.model_copy(update={"title": "Kitchen"}), 1)
draft = store.root / "house" / "dashboard.draft.json"
assert store.list()[0].updated_at == datetime.fromtimestamp(
draft.stat().st_mtime, UTC
)
def test_a_dashboard_is_cut_into_a_sane_number_of_columns(): def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
for columns in (0, 49): for columns in (0, 49):
with pytest.raises(ValueError): with pytest.raises(ValueError):
+20
View File
@@ -1,4 +1,6 @@
import os
import subprocess import subprocess
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -56,6 +58,24 @@ def test_saving_unchanged_content_does_nothing(store: FlowStore):
assert commit_count(store) == commits assert commit_count(store) == commits
def test_a_flow_is_modified_when_only_its_node_code_is(store: FlowStore):
"""Node code is saved without touching the flow document."""
store.write_flow(a_flow())
# Backdated so the answer can only have come from the node file.
os.utime(store.root / "heating" / "flow.json", (0, 0))
store.write_node_source(
"heating", "sensor", "def process():\n return {'a': 1}\n"
)
updated = store.updated_at("heating")
assert updated is not None and updated > datetime.fromtimestamp(0, UTC)
def test_a_flow_that_is_not_there_has_no_modified_time(store: FlowStore):
assert store.updated_at("nope") is None
def test_missing_flow_is_reported(store: FlowStore): def test_missing_flow_is_reported(store: FlowStore):
with pytest.raises(FlowNotFound): with pytest.raises(FlowNotFound):
store.read_flow("nope") store.read_flow("nope")
+75
View File
@@ -575,6 +575,30 @@ export const DashboardSummarySchema = {
type: 'integer', type: 'integer',
title: 'Version', title: 'Version',
default: 1 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', type: 'object',
@@ -1303,6 +1327,18 @@ export const FlowSummarySchema = {
type: 'integer', type: 'integer',
title: 'Version', title: 'Version',
default: 1 default: 1
},
updated_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Updated At'
} }
}, },
type: 'object', 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.` \`\`COLOR_DTYPES\`\` records and the check below holds a binding to.`
} as const; } 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 = { export const WorkerInfoSchema = {
properties: { properties: {
name: { name: {
+20
View File
@@ -158,6 +158,9 @@ export type DashboardSummary = {
widget_count?: number; widget_count?: number;
has_draft?: boolean; has_draft?: boolean;
version?: number; version?: number;
columns?: number;
footprint?: Array<WidgetFootprint>;
updated_at?: (string | null);
}; };
export type DeadLetter = { export type DeadLetter = {
@@ -408,6 +411,7 @@ export type FlowSummary = {
paused?: boolean; paused?: boolean;
quarantined?: boolean; quarantined?: boolean;
version?: number; version?: number;
updated_at?: (string | null);
}; };
export type fluksio__api__routes__dashboards__PublishRequest = { 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'; 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 = { export type WorkerInfo = {
name: string; name: string;
labels?: Array<(string)>; labels?: Array<(string)>;
@@ -1,31 +1,13 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { LayoutDashboard } from "lucide-react" import { LayoutDashboard } from "lucide-react"
import type { import type { DashboardSummary, WidgetFootprint } from "@/client"
DashboardDef_Output,
DashboardSummary,
Placement,
WidgetDef,
} from "@/client"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
/** Columns a dashboard is cut into when its document does not say. */ /** Columns a dashboard is cut into when its document does not say. */
const DEFAULT_COLUMNS = 12 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. */ /** Widgets that draw a shape, and widgets that are controls. The rest read out. */
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"]) const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
const INPUT = new Set([ const INPUT = new Set([
@@ -44,20 +26,7 @@ const shade = (type: string) =>
? "bg-primary/45" ? "bg-primary/45"
: "bg-primary/20" : "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 = { type Block = {
id: string
type: string type: string
x: number x: number
y: number y: number
@@ -65,18 +34,17 @@ type Block = {
h: number h: number
} }
/** The dashboard's widgets as one grid. */ /** The dashboard's widget shapes as one grid. */
function blocksOf(dashboard: DashboardDef_Output): { function blocksOf(footprint: WidgetFootprint[]): {
blocks: Block[] blocks: Block[]
rows: number rows: number
} { } {
const blocks: Block[] = [] const blocks: Block[] = []
let rows = 0 let rows = 0
for (const widget of dashboard.widgets ?? []) { for (const shape of footprint) {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget) const { x = 0, y = 0, w = 3, h = 2 } = shape
blocks.push({ blocks.push({
id: widget.id, type: shape.type,
type: widget.type,
x: Math.max(0, x), x: Math.max(0, x),
y: Math.max(0, y), y: Math.max(0, y),
w: Math.max(1, w), 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 * 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 * 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 * 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 }) { function Footprint({ dashboard }: { dashboard: DashboardSummary }) {
const { blocks, rows } = blocksOf(dashboard) const { blocks, rows } = blocksOf(dashboard.footprint ?? [])
const columns = dashboard.columns || DEFAULT_COLUMNS const columns = dashboard.columns || DEFAULT_COLUMNS
// Before the editor could place things, every widget was written at 0,0; // Before the editor could place things, every widget was written at 0,0;
// honouring that would pile the whole page onto one cell. // honouring that would pile the whole page onto one cell.
@@ -123,11 +91,11 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
}, minmax(0, 1fr))`, }, minmax(0, 1fr))`,
}} }}
> >
{blocks.map((block) => { {blocks.map((block, index) => {
const width = Math.min(columns, block.w) const width = Math.min(columns, block.w)
return ( return (
<span <span
key={block.id} key={index}
className={cn("rounded-[2px]", shade(block.type))} className={cn("rounded-[2px]", shade(block.type))}
style={ style={
placed placed
@@ -144,30 +112,15 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
) )
} }
/** /** One dashboard in the mosaic. */
* 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.
*/
function Tile({ function Tile({
dashboard, dashboard,
preview,
className, className,
}: { }: {
dashboard: DashboardSummary 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. */ /** What the layout needs of it — a width, in the scrolling strip. */
className?: string 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 ( return (
<Link <Link
to="/dashboards/$name" to="/dashboards/$name"
@@ -178,15 +131,7 @@ function Tile({
className, className,
)} )}
> >
{preview && isPending ? ( <Footprint dashboard={dashboard} />
<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>
)}
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="truncate text-sm font-medium"> <span className="truncate text-sm font-medium">
{dashboard.title || dashboard.name} {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. * Which of two documents was worked on more recently.
* *
* ponytail: neither `FlowSummary` nor `DashboardSummary` carries a modified * Both summaries carry the modified time of the working copy, so this is the
* time, so this reads the two things that come close — an unpublished edit is * order it says it is. The name only settles a tie, which is two documents
* the one someone has open, and a higher version counter has been saved more * written in the same instant — what a seed does.
* often. An `updated_at` on both summaries is what would make it exact.
*/ */
export function byRecency< 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 { >(a: T, b: T): number {
return ( return modifiedAt(b) - modifiedAt(a) || a.name.localeCompare(b.name)
Number(b.has_draft ?? false) - Number(a.has_draft ?? false) ||
(b.version ?? 0) - (a.version ?? 0) ||
a.name.localeCompare(b.name)
)
} }
/** /**
@@ -273,13 +217,8 @@ export function DashboardMosaic({
return ( return (
<div className={container}> <div className={container}>
{dashboards.map((dashboard, index) => ( {dashboards.map((dashboard) => (
<Tile <Tile key={dashboard.name} dashboard={dashboard} className={tile} />
key={dashboard.name}
dashboard={dashboard}
preview={index < PREVIEWS}
className={tile}
/>
))} ))}
</div> </div>
) )