Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,9 +974,9 @@ in both themes.
The y axis is not zero-based: `niceDomain()` rounds outward from the data, with 2.5 in its
progression so a 5-to-26 spread does not step by 10.

Each dot is a plain SVG `<a>`, not `next/link`. Below ~560px the chart scrolls inside
`overflow-x-auto`. The card renders when empty, unlike `ServiceDueCard`: missing pressures or an
average depth are something the diver can fix.
Each dot is a plain SVG `<a>`, not `next/link`. Below 560px the chart narrows its viewBox rather
than scrolling (`fittedChartWidth`). The card renders when empty, unlike `ServiceDueCard`: missing
pressures or an average depth are something the diver can fix.

## The chart windows to All/Year/Month, but scales itself from the whole series

Expand Down Expand Up @@ -1004,8 +1004,8 @@ point: the same state drives the dot's enlarge-and-brighten, so dot and card can
It is positioned in percentages of the chart box (the SVG scales uniformly in a wrapper of its own
size) through the `style` prop, an inline attribute the CSP allows
(`style-src-attr 'unsafe-inline'`). It flips to stay inside the box — below the dot in the top
third, edge-aligned within 18% of either side — because `overflow-x: auto` on the scroll container
computes `overflow-y` to `auto` and clips.
third, edge-aligned within 18% of either side — and `useKeepInside` pulls in whatever a phone-width
chart still leaves past an edge, where it would scroll the page sideways.

Each dot has an invisible `r=7` hit circle with `fill="transparent"`, not `fill="none"`, which takes
no pointer events. The card is `pointer-events-none` so it cannot steal the hover, has no accessible
Expand Down Expand Up @@ -1050,8 +1050,7 @@ maps the cursor to an instant on the profile's millisecond axis once and each ch
own sample with `nearestSampleIndex`. Readouts are real readings, never interpolations.

`tooltipVerticalAnchor` pins the card to the plot's top or bottom edge, whichever keeps it off the
readings: card height depends on channel count, so offsetting from a point overflows the clipping
scroll container.
readings: card height depends on channel count, so offsetting from a point overflows the chart.

Keyboard scrubbing is out of scope. The `aria-label` uses `formatDurationHoursMinutes`, not `MM:SS`.
`--pressure` is a third theme-stable token in `globals.css`, violet.
Expand Down Expand Up @@ -1923,8 +1922,9 @@ The anchor is the start of a day with diving in it, so `periodRange`, `periodLab
`availablePeriods`, `stepPeriod` and `resolveAnchor` live in `lib/chart-period.ts`.
`GET /user/dive-activity` returns one row per day and `activityBars` sums into months and years
client-side, so scope switches need no request (reasoning under _"Dives-per-day is counted in
Python"_ in `opendiving-api/DECISIONS.md`). `MAX_X_LABELS` is 20 for years, 12 for months, 31 for
days.
Python"_ in `opendiving-api/DECISIONS.md`). `X_LABEL_SPACING` gives a label 33 units for years, 26
for months and 21 for days: twenty years, all twelve months and every day at the design width, fewer
on a phone.

## Every chart control names its own card, because the two cards draw the same row

Expand Down Expand Up @@ -1974,11 +1974,10 @@ label size reads as a rendering fault.

## The two chart cards stack, and gas leads - both measured, not assumed

Each plot carries `min-w-[560px]`, which keeps twelve month labels and a y axis legible.
`lg:grid-cols-2` on the dashboard's `max-w-6xl` gives 482px (546px at `max-w-7xl`), and three things
break: both charts clip and grow a horizontal scrollbar, the axis text halves (16.6px to 8.6px,
since the svg scales uniformly), and the gas header goes from 50px to 114px as its toggle and
stepper drop below the description. Clearing all three needs about 1220px.
Below 560px each plot narrows its viewBox instead of shrinking, so its axis text stops at 8.6px
(`fittedChartWidth`). `lg:grid-cols-2` on the dashboard's `max-w-6xl` gives 482px (546px at
`max-w-7xl`), and two things break: the axis text halves (16.6px to 8.6px), and the gas header goes
from 50px to 114px as its toggle and stepper drop below the description.
`RecentDivesCard`/`RecentTripsCard` pair up fine below: their content reflows instead of scaling.

Gas consumption leads because it can change how you dive tomorrow; activity records what already
Expand Down
9 changes: 4 additions & 5 deletions src/components/dashboard/dashboard-page-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,10 @@ export function DashboardPageFrame({
already happened - and it's the harder-won number, since it needs dives that
recorded pressures and an average depth.

Stacked, not side by side, and that was measured rather than assumed: each
plot needs 560px to keep twelve month labels legible, and a two-column grid
gives it 482px even on a widened page. Both charts clip, their axis text
halves, and the gas card's header doubles in height when its controls can no
longer share a line with its description. See DECISIONS.md. */}
Stacked, not side by side, and that was measured rather than assumed: a
two-column grid gives each plot 482px even on a widened page, where their
axis text halves and the gas card's header doubles in height when its
controls can no longer share a line with its description. See DECISIONS.md. */}
{hasDives && <GasUseCard />}
{hasDives && <DiveActivityCard />}

Expand Down
5 changes: 3 additions & 2 deletions src/components/dives/chart-skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton";
* summary figures over a `720 x 240` SVG drawn at `w-full h-auto`, so the
* placeholder reserves the same `3:1` box - a card that collapsed to a spinner
* and then grew back to chart height was most of what made arriving at the
* dashboard feel jumpy.
* dashboard feel jumpy. Below 560px the charts keep the height they have there
* (see `fittedChartWidth`), and so does this.
*
* `legend` covers the one difference between them: the gas chart carries a
* `text-xs` legend under its plot (it doubles as the control for which series
Expand All @@ -29,7 +30,7 @@ export function ChartSkeleton({
</div>
))}
</div>
<Skeleton className="aspect-[3/1] w-full" />
<Skeleton className="aspect-[3/1] min-h-[calc(560px/3)] w-full" />
{legend && <Skeleton className="mt-2 h-4 w-64" />}
</div>
);
Expand Down
102 changes: 102 additions & 0 deletions src/components/dives/dive-activity-chart.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render } from "@testing-library/react";

import { DiveActivityChart } from "./dive-activity-chart";
import type { DiveActivityPoint } from "@/lib/api/dive-stats";
import type { ChartScope } from "@/lib/chart-period";
import { activityBars, barCeiling } from "@/lib/dive-activity";
import {
overlappingLabels,
sidewaysScrollers,
withinSides,
} from "@/test/chart-layout";

// Load-bearing, as in `dive-profile-chart.browser.test.tsx`, whose first test
// fails without it: without the app's Tailwind a `min-w-*` class computes to
// nothing, so a chart wider than its container would pass everything below.
import "@/app/globals.css";

// Twenty-five years of diving, and a 2025 with a dive in every month and on
// every day of August: the widest each scope's axis gets.
const POINTS: DiveActivityPoint[] = [
...Array.from({ length: 25 }, (_, index) => ({
year: 2002 + index,
month: 6,
day: 15,
dives: 1 + (index % 4),
})),
...Array.from({ length: 12 }, (_, index) => ({
year: 2025,
month: index + 1,
day: 3,
dives: 2,
})),
...Array.from({ length: 31 }, (_, index) => ({
year: 2025,
month: 8,
day: index + 1,
dives: 1 + (index % 3),
})),
];

const ANCHOR = Date.UTC(2025, 7, 10);

// The chart's width on the dashboard at a 375 px and a 320 px viewport, and at
// 1024 px.
const WIDTHS = { phone: 293, smallPhone: 238, desktop: 910 };
const SCOPES: ChartScope[] = ["all", "year", "month"];

const CASES = Object.entries(WIDTHS).flatMap(([name, width]) =>
SCOPES.map((scope) => [name, scope, width] as const),
);

function renderAt(width: number, scope: ChartScope) {
const { container } = render(
<div style={{ width }}>
<DiveActivityChart
bars={activityBars(POINTS, scope, ANCHOR)}
ceiling={barCeiling(POINTS, scope)}
scope={scope}
/>
</div>,
);
const frame = container.firstElementChild as HTMLElement;
const svg = frame.querySelector("svg") as SVGSVGElement;
return { frame, svg };
}

describe("the dive activity chart's layout", () => {
it.each(CASES)(
"draws no label over another at %s width, scope %s",
(_, scope, width) => {
const { svg } = renderAt(width, scope);

expect(overlappingLabels(svg)).toEqual([]);
},
);

it.each(CASES)(
"fits %s width without scrolling sideways, scope %s",
(_, scope, width) => {
const { frame, svg } = renderAt(width, scope);

expect(sidewaysScrollers(svg, frame)).toEqual([]);
},
);

it.each(SCOPES)(
"keeps the hover card inside a phone-width chart, scope %s",
(scope) => {
const { frame, svg } = renderAt(WIDTHS.smallPhone, scope);
const columns = [...svg.querySelectorAll('rect[fill="transparent"]')];

for (const column of [columns[0], columns[columns.length - 1]]) {
fireEvent.mouseEnter(column);
const card = frame.querySelector('[role="presentation"]') as Element;

expect(withinSides(card, frame)).toBe(true);
fireEvent.mouseLeave(column);
}
},
);
});
Loading
Loading