feat: refine widget interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent a8a7545f40
commit 842e86012d
107 files changed
+3144 -1233

No files matched your search

@@ -9,7 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards } from "./helpers";
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
@@ -48,6 +48,11 @@ export default function SpeedtestTrackerWidget({
const averages = options.showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
);
const compactSections = getCompactSections(height, {
latest: Boolean(latest),
chart: hasChart,
averages: Boolean(averages),
});
if (displayMode === "advanced") {
return (
@@ -85,13 +90,13 @@ export default function SpeedtestTrackerWidget({
return (
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{hasStatSection && (
{(compactSections.latest || compactSections.averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{averages && height >= 170 && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{hasChart && (
{compactSections.chart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
</div>
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
import { combineSpeedtestDashboards, mergeStats } from "./helpers";
import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
ping: { avg: average, min: average, max: average },
@@ -26,3 +26,16 @@ describe("speedtest dashboard aggregation", () => {
expect(combineSpeedtestDashboards(dashboards).latestResult?.id).toBe(2);
});
});
describe("getCompactSections", () => {
const available = { latest: true, chart: true, averages: true };
it("prioritizes the latest result in a short widget", () => {
expect(getCompactSections(180, available)).toEqual({ latest: true, chart: false, averages: false });
});
it("reveals history before averages as height grows", () => {
expect(getCompactSections(300, available)).toEqual({ latest: true, chart: true, averages: false });
expect(getCompactSections(400, available)).toEqual({ latest: true, chart: true, averages: true });
});
});
@@ -75,3 +75,21 @@ export const formatStatsSpeed = (band: SpeedtestTrackerStats["download"]): strin
if (band.avg_bits != null) return formatBitsPerSec(band.avg_bits);
return formatBitsPerSec(band.avg * 8);
};
type CompactSection = "latest" | "chart" | "averages";
export const getCompactSections = (
height: number,
available: Record<CompactSection, boolean>,
): Record<CompactSection, boolean> => {
const budget = height < 240 ? 1 : height < 360 ? 2 : 3;
const visible = new Set(
(["latest", "chart", "averages"] as const).filter((section) => available[section]).slice(0, budget),
);
return {
latest: visible.has("latest"),
chart: visible.has("chart"),
averages: visible.has("averages"),
};
};
@@ -26,7 +26,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
<Stack gap={6} h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<SectionLabel>{t("latestResult")}</SectionLabel>
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
{timestamp}
</Text>
</Group>
@@ -3,7 +3,7 @@ import { Text } from "@mantine/core";
export function SectionLabel({ children }: { children: ReactNode }) {
return (
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
<Text size="xs" fw={600} c="dimmed">
{children}
</Text>
);
@@ -1,6 +1,6 @@
"use client";
import { Card, Flex, Text } from "@mantine/core";
import { Box, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -21,34 +21,37 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
const hideLabel = height > 0 && height <= 38;
return (
<Card
ref={ref}
p={compact ? "xs" : "sm"}
radius={board.itemRadius}
bg={`var(--mantine-color-${color}-light)`}
h="100%"
style={{ flex: 1 }}
>
<Flex
<Tooltip label={`${label}: ${value}`} withArrow>
<Box
ref={ref}
p={compact ? "xs" : "sm"}
bg={`var(--mantine-color-${color}-light)`}
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
style={{ flex: 1, borderRadius: `var(--mantine-radius-${board.itemRadius})`, minWidth: 0 }}
>
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1}>
{value}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" lh={1.3}>
{label}
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
style={{ minWidth: 0 }}
>
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}>
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%">
{value}
</Text>
)}
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" lh={1.3} truncate w="100%">
{label}
</Text>
)}
{hideLabel && <VisuallyHidden>{label}</VisuallyHidden>}
</Flex>
</Flex>
</Flex>
</Card>
</Box>
</Tooltip>
);
}