Files
Checkmate/client/src/Pages/Dashboard/Components/CardPrimitives.tsx
T
gorkem-bwl e4718b814f refactor(client): uniform card widths, shared primitives, clearer titles
Four widths (4/6/8/12) put four different card sizes on one page, and the
4-vs-6 difference read as inconsistency rather than intent. There are now
two: every card is half-width and pairs down the page, and only Monitor
status is full-width because it lays six counts across a single row.

Currently down is half-width too. Seven columns would crush the monitor
name there, so its rows are now two lines — name and downtime lead, with
type, status code and the sparkline in a quieter line beneath. No
information is lost.

This collapses the layout logic: two halves fill a row exactly and a full
card fills its own, so nothing is ever stretched to close a gap. The only
remaining decision is a lone trailing half, promoted to full so the page
does not end ragged. Verified across the catalogue, the non-admin subset,
the defaults and single-card cases: no mid-page gaps anywhere.

Componentization — there was real duplication:

- StatLine was defined twice and MetricLine was a third copy of the same
  thing. All three are now CardStatLine.
- A label + value + bar row appeared five times across the breakdown and
  ranking cards. It is now CardMeterRow.
- Monitors by type and by group were the same card over different keys,
  so they share a BreakdownCard shell. Between them they drop from 232
  lines to 83.

Titles, checked against what each card actually renders:

- Checks on schedule -> Check queue. It reports queue jobs and workers,
  not the checks themselves.
- Maintenance -> Maintenance windows, Team -> Team members. Bare nouns
  read as navigation rather than as what the card reports.
- The picker no longer borrows the sidebar's group labels. The Logs page
  hosts logs, queue and diagnostics tabs, so "Logs" misdescribed the
  three cards under it; that group is now "System". Checkmate server
  keeps its name — it does report on the server process.

Picker buttons: Reset to defaults moves to the left, away from Done,
since a destructive action should not sit next to the one people reach
for by reflex. Done becomes the primary contained button. Dialog gains an
optional cancelSx to allow that placement.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 10:03:59 -04:00

239 lines
4.7 KiB
TypeScript

import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import type { ReactNode } from "react";
/** Thin enough to read as a proportion indicator, not a progress bar. */
const BAR_HEIGHT = 4;
/**
* A proportional bar. `value` and `max` share whatever unit the caller uses;
* percentages come in as 0-100, not as the API's 0-1 fractions.
*/
export const CardBar = ({
value,
max = 100,
color,
}: {
value: number;
max?: number;
color: string;
}) => {
const theme = useTheme();
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
return (
<Box
width="100%"
height={BAR_HEIGHT}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{ overflow: "hidden" }}
>
<Box
width={`${ratio * 100}%`}
height="100%"
bgcolor={color}
sx={{ transition: "width 0.3s ease" }}
/>
</Box>
);
};
/** A clickable list row. Rows link to the entity they describe. */
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
const theme = useTheme();
return (
<Stack
{...(to ? { component: RouterLink, to } : {})}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
px={theme.spacing(LAYOUT.XS)}
py={theme.spacing(LAYOUT.XS)}
mx={`-${theme.spacing(LAYOUT.XS)}`}
borderRadius={theme.shape.borderRadius}
color="inherit"
sx={{
textDecoration: "none",
...(to && {
"&:hover": { backgroundColor: theme.palette.action.hover },
}),
}}
>
{children}
</Stack>
);
};
/** Name plus optional secondary line, truncating rather than wrapping. */
export const CardRowLabel = ({
primary,
secondary,
}: {
primary: string;
secondary?: string | null;
}) => {
const theme = useTheme();
return (
<Stack
minWidth={0}
flex={1}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
noWrap
>
{primary}
</Typography>
{secondary && (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
noWrap
>
{secondary}
</Typography>
)}
</Stack>
);
};
/** A single large figure with a caption — the shape of the stat cards. */
export const CardFigure = ({
value,
caption,
color,
}: {
value: string;
caption?: string;
color?: string;
}) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={300}
color={color ?? theme.palette.text.primary}
lineHeight={1.1}
>
{value}
</Typography>
{caption && (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{caption}
</Typography>
)}
</Stack>
);
};
/**
* A label with its value on the right — the shape almost every stat card
* repeats. `color` highlights the value alone, leaving the label quiet.
*/
export const CardStatLine = ({
label,
value,
color,
}: {
label: string;
value: string;
color?: string;
}) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={color ?? theme.palette.text.primary}
noWrap
>
{value}
</Typography>
</Stack>
);
};
/**
* A CardStatLine with a proportional bar beneath it — the ranking and
* breakdown row. `max` defaults to a percentage scale; pass the largest value
* in the set to make bars relative to each other instead.
*
* `label` may be a node so callers can put a "N down" flag beside the name.
*/
export const CardMeterRow = ({
label,
value,
max,
color,
trailing,
}: {
label: ReactNode;
value: number;
max?: number;
color: string;
trailing?: ReactNode;
}) => {
const theme = useTheme();
return (
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
{typeof label === "string" ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
noWrap
>
{label}
</Typography>
) : (
label
)}
{trailing}
</Stack>
<CardBar
value={value}
max={max}
color={color}
/>
</Stack>
);
};