Dashboard charts: drag across one to zoom #79
No files matched your search
@@ -7,7 +7,7 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Gauge as GaugeIcon, RefreshCw } from 'lucide-react';
|
||||
import { Gauge as GaugeIcon, RefreshCw, X } from 'lucide-react';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AUTO_REFRESH, useDashboardStore, type AutoRefresh } from '../stores/dashboardStore';
|
||||
@@ -71,6 +71,8 @@ export function PeriodSwitch({ loading = false }: { loading?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const setPreset = useDashboardStore((s) => s.setPreset);
|
||||
const zoomedFrom = useDashboardStore((s) => s.zoomedFrom);
|
||||
const resetZoom = useDashboardStore((s) => s.resetZoom);
|
||||
const every = useDashboardStore((s) => s.autoRefresh);
|
||||
const setEvery = useDashboardStore((s) => s.setAutoRefresh);
|
||||
const onRefresh = useDashboardStore((s) => s.bump);
|
||||
@@ -103,6 +105,21 @@ export function PeriodSwitch({ loading = false }: { loading?: boolean }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* inbuxa: zoomed in from a chart, the window shown, and the way back. */}
|
||||
{zoomedFrom && period.kind === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetZoom}
|
||||
title={t('cc.zoomReset', 'Back to {{period}}', {
|
||||
period: zoomedFrom.kind === 'preset' ? zoomedFrom.preset.toUpperCase() : '',
|
||||
})}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 font-mono text-[11px] font-semibold tracking-wider text-foreground transition-colors hover:bg-primary/20"
|
||||
>
|
||||
<span className="uppercase text-muted-foreground">{t('cc.zoomed', 'Zoom')}</span>
|
||||
{formatZoom(period.from, period.to)}
|
||||
<X className="h-3 w-3 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center rounded-lg border bg-background/60">
|
||||
<button
|
||||
type="button"
|
||||
@@ -209,3 +226,12 @@ export function DashNav({
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** inbuxa: a zoomed window, short: "5 Oct 14:00 – 17:00", or both dates when it spans days. */
|
||||
function formatZoom(from: Date, to: Date): string {
|
||||
const day = (d: Date) => d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||
const time = (d: Date) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return day(from) === day(to)
|
||||
? `${day(from)} ${time(from)} – ${time(to)}`
|
||||
: `${day(from)} ${time(from)} – ${day(to)} ${time(to)}`;
|
||||
}
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
formatValue,
|
||||
getBucketCount,
|
||||
seriesBucketValue,
|
||||
sliceAt,
|
||||
zoomWindow,
|
||||
} from '../helpers';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import { LATENCIES, summarize } from '../commandCenter';
|
||||
import { hrefFor, linkForMetrics } from '../links';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
@@ -74,11 +77,16 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
|
||||
const format = chart.valueFormat ?? 'number';
|
||||
const timing = format === 'duration';
|
||||
const buckets = getBucketCount(period);
|
||||
const zoomTo = useDashboardStore((s) => s.zoomTo);
|
||||
// Dragging across the chart: the slices it started and is now over.
|
||||
const [drag, setDrag] = useState<{ a: number; b: number } | null>(null);
|
||||
const canZoom = zoomWindow(from, to, buckets, 0, 1) !== null;
|
||||
|
||||
const data = useMemo(() => {
|
||||
const slices = bucketize(samples, from, to, buckets);
|
||||
return bucketTimestamps(from, to, buckets).map((ts, i) => {
|
||||
const point: Record<string, number | string | null> = { tick: formatTimeTick(ts, period) };
|
||||
// Charted by slice, so two slices that print alike ("14:00" on two days) stay apart.
|
||||
const point: Record<string, number | string | null> = { i, tick: formatTimeTick(ts, period) };
|
||||
for (const s of chart.series) point[s.label] = seriesBucketValue(s, slices[i]) ?? (timing ? null : 0);
|
||||
return point;
|
||||
});
|
||||
@@ -90,10 +98,10 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
|
||||
const peak = useMemo(() => {
|
||||
if (timing || chart.kind === 'bar' || chart.series.length === 0) return null;
|
||||
const keys = chart.stacked ? chart.series.map((s) => s.label) : [chart.series[0].label];
|
||||
let best: { tick: string; v: number } | null = null;
|
||||
let best: { i: number; v: number } | null = null;
|
||||
for (const p of data) {
|
||||
const v = keys.reduce((sum, k) => sum + Number(p[k] ?? 0), 0);
|
||||
if (v > 0 && (!best || v > best.v)) best = { tick: String(p.tick), v };
|
||||
if (v > 0 && (!best || v > best.v)) best = { i: Number(p.i), v };
|
||||
}
|
||||
return best;
|
||||
}, [data, chart.series, chart.stacked, chart.kind, timing]);
|
||||
@@ -105,7 +113,51 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
|
||||
const href = link && canViewObject(link.viewName) ? hrefFor(link) : null;
|
||||
const fmt = (v: number) => formatValue(v, format);
|
||||
|
||||
const common = { data, width, height: HEIGHT, margin: { top: 16, right: 24, left: 0, bottom: 0 } };
|
||||
const tickAt = (i: unknown) => String(data[Number(i)]?.tick ?? '');
|
||||
// Dragging is read off the pointer against the plot area (the chart's grid),
|
||||
// not off recharts' hover state, which runs a frame behind: a quick sweep
|
||||
// would end on the slice it started from.
|
||||
const band = chart.kind === 'bar';
|
||||
const sliceAtPointer = (clientX: number) => {
|
||||
const plot = ref.current?.querySelector('.recharts-cartesian-grid')?.getBoundingClientRect();
|
||||
return plot ? sliceAt(clientX, plot.left, plot.right, buckets, band) : null;
|
||||
};
|
||||
const dragRef = useRef<{ a: number; b: number } | null>(null);
|
||||
const setDragBoth = (d: { a: number; b: number } | null) => {
|
||||
dragRef.current = d;
|
||||
setDrag(d);
|
||||
};
|
||||
const zoomHandlers = canZoom
|
||||
? {
|
||||
onMouseDown: (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const i = sliceAtPointer(e.clientX);
|
||||
if (i !== null) setDragBoth({ a: i, b: i });
|
||||
},
|
||||
onMouseMove: (e: React.MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
const i = d ? sliceAtPointer(e.clientX) : null;
|
||||
if (d && i !== null && i !== d.b) setDragBoth({ a: d.a, b: i });
|
||||
},
|
||||
onMouseUp: (e: React.MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
const end = d ? (sliceAtPointer(e.clientX) ?? d.b) : null;
|
||||
// A click isn't a drag: only a sweep over two slices or more zooms.
|
||||
if (d && end !== null && d.a !== end) {
|
||||
const w = zoomWindow(from, to, buckets, d.a, end);
|
||||
if (w) zoomTo(w.from, w.to);
|
||||
}
|
||||
setDragBoth(null);
|
||||
},
|
||||
onMouseLeave: () => setDragBoth(null),
|
||||
}
|
||||
: {};
|
||||
const common = {
|
||||
data,
|
||||
width,
|
||||
height: HEIGHT,
|
||||
margin: { top: 16, right: 24, left: 0, bottom: 0 },
|
||||
};
|
||||
const chrome = (
|
||||
<>
|
||||
<defs>
|
||||
@@ -117,7 +169,7 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
<XAxis dataKey="tick" tick={AXIS} tickLine={false} axisLine={false} minTickGap={28} />
|
||||
<XAxis dataKey="i" tick={AXIS} tickLine={false} axisLine={false} minTickGap={28} tickFormatter={tickAt} />
|
||||
<YAxis tick={AXIS} tickLine={false} axisLine={false} width={56} tickFormatter={fmt} allowDecimals={timing} />
|
||||
{zones && (
|
||||
<>
|
||||
@@ -142,7 +194,7 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<div className="rounded-lg border bg-popover px-3 py-2 font-mono text-xs shadow-md">
|
||||
<div className="mb-1 text-muted-foreground">{label}</div>
|
||||
<div className="mb-1 text-muted-foreground">{tickAt(label)}</div>
|
||||
{payload.map((p) => (
|
||||
<div key={String(p.dataKey)} className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
|
||||
@@ -156,9 +208,19 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{drag && drag.a !== drag.b && (
|
||||
<ReferenceArea
|
||||
x1={Math.min(drag.a, drag.b)}
|
||||
x2={Math.max(drag.a, drag.b)}
|
||||
fill="var(--primary)"
|
||||
fillOpacity={0.12}
|
||||
stroke="var(--primary)"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
)}
|
||||
{peak && (
|
||||
<ReferenceDot
|
||||
x={peak.tick}
|
||||
x={peak.i}
|
||||
y={peak.v}
|
||||
r={4}
|
||||
fill="var(--card)"
|
||||
@@ -245,9 +307,20 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
|
||||
{timing && <span className="text-[10px] uppercase text-muted-foreground">{t('cc.avg', 'avg')}</span>}
|
||||
</span>
|
||||
))}
|
||||
{canZoom && (
|
||||
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
|
||||
{t('cc.dragToZoom', 'Drag to zoom')}
|
||||
</span>
|
||||
)}
|
||||
{chart.description && <span className="basis-full text-xs text-muted-foreground">{chart.description}</span>}
|
||||
</div>
|
||||
<div ref={ref} style={{ height: HEIGHT }}>
|
||||
<div
|
||||
ref={ref}
|
||||
// A press to drag focuses the chart; keep the ring for the keyboard only.
|
||||
className="[&_.recharts-surface:focus:not(:focus-visible)]:outline-none"
|
||||
style={{ height: HEIGHT, ...(canZoom ? { cursor: 'crosshair', userSelect: 'none' } : {}) }}
|
||||
{...zoomHandlers}
|
||||
>
|
||||
{width > 0 && (
|
||||
<Kind {...common}>
|
||||
{chrome}
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
|
||||
import type { Metric, MetricId, Period } from './types/metrics';
|
||||
import type { Card, Series, MetricFormat, Aggregate } from './types/schema';
|
||||
import { PRESET_MS, BUCKET_CONFIG, CUSTOM_BUCKET_COUNT, SPARKLINE_BUCKET_COUNT } from './types/metrics';
|
||||
import {
|
||||
PRESET_MS,
|
||||
BUCKET_CONFIG,
|
||||
CUSTOM_BUCKET_COUNT,
|
||||
HOUR_MS,
|
||||
MIN_ZOOM_MS,
|
||||
SPARKLINE_BUCKET_COUNT,
|
||||
} from './types/metrics';
|
||||
|
||||
export function metricToScalar(m: Metric): number {
|
||||
if (m['@type'] === 'Histogram') {
|
||||
@@ -82,7 +89,11 @@ export function periodKey(p: Period): string {
|
||||
}
|
||||
|
||||
export function getBucketCount(p: Period): number {
|
||||
if (p.kind === 'custom') return CUSTOM_BUCKET_COUNT;
|
||||
if (p.kind === 'custom') {
|
||||
// inbuxa: a slice per hour of the window, as history is sampled hourly.
|
||||
const hours = Math.round((p.to.getTime() - p.from.getTime()) / HOUR_MS);
|
||||
return Math.min(CUSTOM_BUCKET_COUNT, Math.max(MIN_ZOOM_MS / HOUR_MS, hours));
|
||||
}
|
||||
return BUCKET_CONFIG[p.preset];
|
||||
}
|
||||
|
||||
@@ -291,3 +302,44 @@ export function collectLiveMetricIds(cards: Card[] | undefined): Set<MetricId> {
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa: the window a drag across slices `a` to `b` of a chart zooms to: from
|
||||
* the start of the first slice to the end of the last. A window under three
|
||||
* hours widens around its middle, staying inside the chart's own. Null when
|
||||
* the chart's window is already that narrow, so there's nothing to zoom into.
|
||||
*/
|
||||
export function zoomWindow(
|
||||
from: Date,
|
||||
to: Date,
|
||||
buckets: number,
|
||||
a: number,
|
||||
b: number,
|
||||
): { from: Date; to: Date } | null {
|
||||
const start = from.getTime();
|
||||
const end = to.getTime();
|
||||
if (end - start <= MIN_ZOOM_MS || buckets <= 0) return null;
|
||||
const width = (end - start) / buckets;
|
||||
const lo = Math.max(0, Math.min(a, b));
|
||||
const hi = Math.min(buckets - 1, Math.max(a, b));
|
||||
let zFrom = start + lo * width;
|
||||
let zTo = start + (hi + 1) * width;
|
||||
if (zTo - zFrom < MIN_ZOOM_MS) {
|
||||
const mid = (zFrom + zTo) / 2;
|
||||
zFrom = Math.max(start, Math.min(end - MIN_ZOOM_MS, mid - MIN_ZOOM_MS / 2));
|
||||
zTo = zFrom + MIN_ZOOM_MS;
|
||||
}
|
||||
return { from: new Date(Math.round(zFrom)), to: new Date(Math.round(zTo)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa: which of `buckets` slices a pointer at `x` is over, on a plot from
|
||||
* `left` to `right`. Lines and areas put the slices on points, first at the
|
||||
* left edge and last at the right; bars put each in a band of its own.
|
||||
*/
|
||||
export function sliceAt(x: number, left: number, right: number, buckets: number, band: boolean): number | null {
|
||||
if (buckets <= 0 || right <= left) return null;
|
||||
const f = Math.min(1, Math.max(0, (x - left) / (right - left)));
|
||||
const i = band ? Math.floor(f * buckets) : Math.round(f * (buckets - 1));
|
||||
return Math.min(buckets - 1, i);
|
||||
}
|
||||
@@ -18,6 +18,14 @@ interface DashboardState {
|
||||
period: Period;
|
||||
setPeriod: (period: Period) => void;
|
||||
setPreset: (preset: PresetKey) => void;
|
||||
/**
|
||||
* inbuxa: the period a chart was zoomed from, while zoomed. Zooming makes the
|
||||
* period a custom window; only this one is remembered, so a reload is back
|
||||
* at the preset rather than in an old window.
|
||||
*/
|
||||
zoomedFrom: Period | null;
|
||||
zoomTo: (from: Date, to: Date) => void;
|
||||
resetZoom: () => void;
|
||||
/** INBUXA: minutes between automatic refreshes of the dashboards; 0 is off. */
|
||||
autoRefresh: AutoRefresh;
|
||||
setAutoRefresh: (minutes: AutoRefresh) => void;
|
||||
@@ -31,8 +39,12 @@ export const useDashboardStore = create<DashboardState>()(
|
||||
(set) => ({
|
||||
period: { kind: 'preset', preset: '24h' } as Period,
|
||||
|
||||
setPeriod: (period) => set({ period }),
|
||||
setPreset: (preset) => set({ period: { kind: 'preset', preset } }),
|
||||
setPeriod: (period) => set({ period, zoomedFrom: null }),
|
||||
setPreset: (preset) => set({ period: { kind: 'preset', preset }, zoomedFrom: null }),
|
||||
zoomedFrom: null,
|
||||
zoomTo: (from, to) =>
|
||||
set((s) => ({ period: { kind: 'custom', from, to }, zoomedFrom: s.zoomedFrom ?? s.period })),
|
||||
resetZoom: () => set((s) => (s.zoomedFrom ? { period: s.zoomedFrom, zoomedFrom: null } : {})),
|
||||
autoRefresh: 5,
|
||||
setAutoRefresh: (autoRefresh) => set({ autoRefresh }),
|
||||
tick: 0,
|
||||
@@ -40,7 +52,7 @@ export const useDashboardStore = create<DashboardState>()(
|
||||
}),
|
||||
{
|
||||
name: 'dashboard.period',
|
||||
partialize: (state) => ({ period: state.period, autoRefresh: state.autoRefresh }),
|
||||
partialize: (state) => ({ period: state.zoomedFrom ?? state.period, autoRefresh: state.autoRefresh }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -49,4 +49,8 @@ export const BUCKET_CONFIG: Record<PresetKey, number> = {
|
||||
};
|
||||
|
||||
export const CUSTOM_BUCKET_COUNT = 60;
|
||||
// inbuxa: history is sampled hourly unless set otherwise (`metricsCollectionInterval`),
|
||||
// so a zoomed window gets a slice per hour, 3 to 60 of them, and is never under 3 hours.
|
||||
export const HOUR_MS = 3_600_000;
|
||||
export const MIN_ZOOM_MS = 3 * HOUR_MS;
|
||||
export const SPARKLINE_BUCKET_COUNT = 20;
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createJSONStorage } from 'zustand/middleware';
|
||||
import { getBucketCount, sliceAt, zoomWindow } from './helpers';
|
||||
import { useDashboardStore } from './stores/dashboardStore';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const FROM = new Date('2026-10-05T00:00:00Z');
|
||||
const at = (h: number) => new Date(FROM.getTime() + h * HOUR);
|
||||
|
||||
describe('zoomWindow', () => {
|
||||
it('runs from the start of the first slice to the end of the last, either way round', () => {
|
||||
// 24 h in 48 slices of 30 min: slices 20 to 31 are 10:00 to 16:00.
|
||||
expect(zoomWindow(FROM, at(24), 48, 20, 31)).toEqual({ from: at(10), to: at(16) });
|
||||
expect(zoomWindow(FROM, at(24), 48, 31, 20)).toEqual({ from: at(10), to: at(16) });
|
||||
});
|
||||
|
||||
it('widens a window under three hours around its middle', () => {
|
||||
// Slices 20 and 21: 10:00 to 11:00, widened to 09:00 to 12:00.
|
||||
expect(zoomWindow(FROM, at(24), 48, 20, 21)).toEqual({ from: at(9), to: at(12) });
|
||||
});
|
||||
|
||||
it('keeps a widened window inside the chart', () => {
|
||||
expect(zoomWindow(FROM, at(24), 48, 0, 1)).toEqual({ from: at(0), to: at(3) });
|
||||
expect(zoomWindow(FROM, at(24), 48, 46, 47)).toEqual({ from: at(21), to: at(24) });
|
||||
});
|
||||
|
||||
it('has nothing to zoom into once the chart is three hours or less', () => {
|
||||
expect(zoomWindow(FROM, at(3), 3, 0, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sliceAt', () => {
|
||||
// A plot from x=100 to x=500, 5 slices.
|
||||
it('puts line and area slices on points, edge to edge', () => {
|
||||
expect(sliceAt(100, 100, 500, 5, false)).toBe(0);
|
||||
expect(sliceAt(300, 100, 500, 5, false)).toBe(2);
|
||||
expect(sliceAt(500, 100, 500, 5, false)).toBe(4);
|
||||
expect(sliceAt(160, 100, 500, 5, false)).toBe(1);
|
||||
});
|
||||
|
||||
it('puts bar slices in bands', () => {
|
||||
expect(sliceAt(100, 100, 500, 5, true)).toBe(0);
|
||||
expect(sliceAt(179, 100, 500, 5, true)).toBe(0);
|
||||
expect(sliceAt(181, 100, 500, 5, true)).toBe(1);
|
||||
expect(sliceAt(500, 100, 500, 5, true)).toBe(4);
|
||||
});
|
||||
|
||||
it('holds a pointer past either edge to the end slice', () => {
|
||||
expect(sliceAt(20, 100, 500, 5, false)).toBe(0);
|
||||
expect(sliceAt(900, 100, 500, 5, true)).toBe(4);
|
||||
});
|
||||
|
||||
it('has no slice on an empty plot', () => {
|
||||
expect(sliceAt(300, 100, 100, 5, false)).toBeNull();
|
||||
expect(sliceAt(300, 100, 500, 0, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBucketCount for a zoomed window', () => {
|
||||
const custom = (h: number) => ({ kind: 'custom' as const, from: FROM, to: at(h) });
|
||||
|
||||
it('gives a slice per hour', () => {
|
||||
expect(getBucketCount(custom(6))).toBe(6);
|
||||
expect(getBucketCount(custom(40))).toBe(40);
|
||||
});
|
||||
|
||||
it('keeps between 3 and 60 slices', () => {
|
||||
expect(getBucketCount(custom(2))).toBe(3);
|
||||
expect(getBucketCount(custom(24 * 20))).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zooming the dashboard period', () => {
|
||||
// In memory: Node's own `localStorage` global, empty without a storage file, hides happy-dom's.
|
||||
const saved = new Map<string, string>();
|
||||
beforeAll(() =>
|
||||
useDashboardStore.persist.setOptions({
|
||||
storage: createJSONStorage(() => ({
|
||||
getItem: (k) => saved.get(k) ?? null,
|
||||
setItem: (k, v) => void saved.set(k, v),
|
||||
removeItem: (k) => void saved.delete(k),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
beforeEach(() => useDashboardStore.getState().setPreset('7d'));
|
||||
|
||||
it('remembers the preset it zoomed from, and goes back to it', () => {
|
||||
useDashboardStore.getState().zoomTo(at(10), at(16));
|
||||
expect(useDashboardStore.getState().period).toEqual({ kind: 'custom', from: at(10), to: at(16) });
|
||||
expect(useDashboardStore.getState().zoomedFrom).toEqual({ kind: 'preset', preset: '7d' });
|
||||
|
||||
// Zooming again from inside a zoom still goes back to the preset.
|
||||
useDashboardStore.getState().zoomTo(at(11), at(14));
|
||||
expect(useDashboardStore.getState().zoomedFrom).toEqual({ kind: 'preset', preset: '7d' });
|
||||
|
||||
useDashboardStore.getState().resetZoom();
|
||||
expect(useDashboardStore.getState().period).toEqual({ kind: 'preset', preset: '7d' });
|
||||
expect(useDashboardStore.getState().zoomedFrom).toBeNull();
|
||||
});
|
||||
|
||||
it('ends the zoom when a preset is picked', () => {
|
||||
useDashboardStore.getState().zoomTo(at(10), at(16));
|
||||
useDashboardStore.getState().setPreset('30d');
|
||||
expect(useDashboardStore.getState().period).toEqual({ kind: 'preset', preset: '30d' });
|
||||
expect(useDashboardStore.getState().zoomedFrom).toBeNull();
|
||||
});
|
||||
|
||||
it('saves the preset, not the zoomed window', () => {
|
||||
useDashboardStore.getState().zoomTo(at(10), at(16));
|
||||
const stored = JSON.parse(saved.get('dashboard.period') ?? '{}');
|
||||
expect(stored.state.period).toEqual({ kind: 'preset', preset: '7d' });
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -1951,7 +1951,10 @@
|
||||
},
|
||||
"now": "now",
|
||||
"avgZone": "average · {{zone}}",
|
||||
"average": "average"
|
||||
"average": "average",
|
||||
"zoomReset": "Back to {{period}}",
|
||||
"zoomed": "Zoom",
|
||||
"dragToZoom": "Drag to zoom"
|
||||
},
|
||||
"commands": {
|
||||
"actionHint": "Server action",
|
||||
|
||||
Reference in new issue
Block a user