fix: keep the two-step on chart points on touch

A point and a calendar cell are small targets; one tap that opened the
day opened days by accident. On a touch screen the first tap shows the
value again and the tooltip's calm 44 px row opens the day, on every
day-linked chart and calendar alike. A date in a list still opens in one
tap; a click on a fine pointer still opens directly. The tooltip keeps
not gliding into place.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:04:54 +02:00
1 parent fee6088e08
commit 8bcb3e6301
25 files changed
+213 -79

No files matched your search

+20 -6
View File
@@ -13,11 +13,13 @@ import { expect, test } from "./setup/test";
*
* The day view first reached six metric pages; the recovery page beside them
* offered no day at all. These journeys walk the surfaces that came after,
* each through the same door: a click on a point (or a calendar cell) opens
* the day beside the page, and on a touch screen a tap does the same as a
* bottom sheet, the tapped value on top. The dashed line (or outline) marks
* the open day, the row of dots and the caption sit under every chart, and
* the data table's dates are the keyboard way to the same days.
* each through the same doors: on a fine pointer a click on a point (or a
* calendar cell) opens the day beside the page; on a touch screen the first
* tap shows the value and the tooltip's "View the whole day" opens it as a
* bottom sheet, the tapped value on top. A date in a list opens in one tap.
* The dashed line (or outline) marks the open day, the row of dots and the
* caption sit under every chart, and the data table's dates are the
* keyboard way to the same days.
*
* - recovery (the page the beta reported), sleep, a lab marker, a chart
* with its comparison period on and a dashboard chart: line charts;
@@ -41,8 +43,14 @@ async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
const x = box.x + box.width * 0.8;
const y = box.y + box.height / 2;
if (isPhone(testInfo)) {
// One tap, the same door as the click: no tooltip step in between.
await page.touchscreen.tap(x, y);
const open = page.locator('[data-slot="chart-tooltip-open-day"]:visible');
await expect(open).toBeVisible();
// A tap alone never opens a sheet: a point is a small target.
await expect(panel(page)).toHaveCount(0);
// The button is a calm, full 44 px row.
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await open.tap();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
} else {
await page.mouse.move(x, y);
@@ -143,6 +151,12 @@ test.describe("every chart drawn in days opens its days", () => {
const day = await cell.getAttribute("data-day");
if (isPhone(testInfo)) {
await cell.tap();
const open = page.locator(
'[data-slot="heatmap-tooltip"] [data-slot="chart-tooltip-open-day"]',
);
await expect(open).toBeVisible();
await expect(panel(page)).toHaveCount(0);
await open.tap();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
} else {
await cell.click();
+10 -7
View File
@@ -15,9 +15,8 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
* day, what the history does, and where no door is.
*
* - a chart point opens the day beside the chart (desktop);
* - on a touch screen a tap on a point opens the day at once, as a bottom
* sheet with the tapped value on top (the maintainer's call on the beta:
* the two-step tooltip broke the flow);
* - on a touch screen the first tap shows the value and the tooltip offers
* the day, the second opens it as a bottom sheet;
* - a date in a list opens the day while the row keeps its own target;
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
* the future is dropped;
@@ -25,7 +24,7 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
* - the dashboard's today area offers no door; its charts below do.
*
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
* `chart-plot[data-day-links]`, `day-prev` /
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
* `day-next` / `day-close`.
*/
@@ -151,7 +150,7 @@ test.describe("the day view", () => {
await expect(page).toHaveURL(/\/mood$/);
});
test("on a phone a tap on a point opens its day at once", async ({
test("on a phone the first tap shows the value, the second opens the day", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
@@ -162,11 +161,15 @@ test.describe("the day view", () => {
await plot.scrollIntoViewIfNeeded();
await expect(plot).toBeVisible();
const box = (await plot.boundingBox())!;
// One tap, the same door as a click on a desktop: the day opens as a
// bottom sheet with the tapped value on top.
await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
const open = page.locator('[data-slot="chart-tooltip-open-day"]');
await expect(open).toBeVisible();
// A tap alone never opens a sheet.
await expect(panel(page)).toHaveCount(0);
await open.tap();
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
// The tapped value heads the day.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "Erfassen",
"inTimelineShort": "Zeitleiste",
"askCoachPrompt": "Was war am {date}?",
"chartHintTouch": "Antippen eines Punkts öffnet den Tag.",
"chartHintTouch": "Antippen zeigt den Wert.",
"chartHintBar": "Klick auf einen Balken öffnet den Tag.",
"chartHintTouchBar": "Antippen eines Balkens öffnet den Tag.",
"chartHintTouchBar": "Antippen zeigt die Werte.",
"chartHintCell": "Klick auf einen Tag öffnet ihn.",
"chartHintTouchCell": "Antippen eines Tages öffnet ihn.",
"chartHintTouchCell": "Antippen zeigt den Wert des Tages.",
"coachChips": "Tage in dieser Antwort",
"description": "Alles, was deine Einträge für diesen Tag enthalten.",
"openDocument": "Dokument öffnen",
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "Log",
"inTimelineShort": "Timeline",
"askCoachPrompt": "What happened on {date}?",
"chartHintTouch": "Tap a point to open its day.",
"chartHintTouch": "Tap a point to see its value.",
"chartHintBar": "Click a bar to open its day.",
"chartHintTouchBar": "Tap a bar to open its day.",
"chartHintTouchBar": "Tap a bar to see its values.",
"chartHintCell": "Click a day to open it.",
"chartHintTouchCell": "Tap a day to open it.",
"chartHintTouchCell": "Tap a day to see its value.",
"coachChips": "Days in this answer",
"description": "Everything your records hold for this day.",
"openDocument": "Open document",
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "Registrar",
"inTimelineShort": "Cronología",
"askCoachPrompt": "¿Qué pasó el {date}?",
"chartHintTouch": "Toca un punto para abrir su día.",
"chartHintTouch": "Toca un punto para ver su valor.",
"chartHintBar": "Haz clic en una barra para abrir su día.",
"chartHintTouchBar": "Toca una barra para abrir su día.",
"chartHintTouchBar": "Toca una barra para ver sus valores.",
"chartHintCell": "Haz clic en un día para abrirlo.",
"chartHintTouchCell": "Toca un día para abrirlo.",
"chartHintTouchCell": "Toca un día para ver su valor.",
"coachChips": "Días en esta respuesta",
"description": "Todo lo que tus registros contienen para este día.",
"openDocument": "Abrir documento",
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "Enregistrer",
"inTimelineShort": "Chronologie",
"askCoachPrompt": "Que s’est-il passé le {date} ?",
"chartHintTouch": "Touchez un point pour ouvrir son jour.",
"chartHintTouch": "Touchez un point pour voir sa valeur.",
"chartHintBar": "Cliquez sur une barre pour ouvrir son jour.",
"chartHintTouchBar": "Touchez une barre pour ouvrir son jour.",
"chartHintTouchBar": "Touchez une barre pour voir ses valeurs.",
"chartHintCell": "Cliquez sur un jour pour l’ouvrir.",
"chartHintTouchCell": "Touchez un jour pour l’ouvrir.",
"chartHintTouchCell": "Touchez un jour pour voir sa valeur.",
"coachChips": "Jours dans cette réponse",
"description": "Tout ce que vos entrées contiennent pour ce jour.",
"openDocument": "Ouvrir le document",
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "Registra",
"inTimelineShort": "Cronologia",
"askCoachPrompt": "Cosa è successo il {date}?",
"chartHintTouch": "Tocca un punto per aprire il suo giorno.",
"chartHintTouch": "Tocca un punto per vederne il valore.",
"chartHintBar": "Fai clic su una barra per aprire il suo giorno.",
"chartHintTouchBar": "Tocca una barra per aprire il suo giorno.",
"chartHintTouchBar": "Tocca una barra per vederne i valori.",
"chartHintCell": "Fai clic su un giorno per aprirlo.",
"chartHintTouchCell": "Tocca un giorno per aprirlo.",
"chartHintTouchCell": "Tocca un giorno per vederne il valore.",
"coachChips": "Giorni in questa risposta",
"description": "Tutto ciò che le tue voci contengono per questo giorno.",
"openDocument": "Apri documento",
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "기록",
"inTimelineShort": "타임라인",
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
"chartHintTouch": "점을 탭하면 그날이 열려요.",
"chartHintTouch": "점을 탭하면 값이 보여요.",
"chartHintBar": "막대를 클릭하면 그날이 열려요.",
"chartHintTouchBar": "막대를 탭하면 그날이 열려요.",
"chartHintTouchBar": "막대를 탭하면 값이 보여요.",
"chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.",
"chartHintTouchCell": "날짜 칸을 탭하면 그날이 열려요.",
"chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.",
"coachChips": "이 답변에 쓰인 날",
"description": "이 날에 대한 모든 기록이에요.",
"openDocument": "문서 열기",
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "Zapisz",
"inTimelineShort": "Oś czasu",
"askCoachPrompt": "Co się działo {date}?",
"chartHintTouch": "Dotknij punktu, aby otworzyć jego dzień.",
"chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.",
"chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.",
"chartHintTouchBar": "Dotknij słupka, aby otworzyć jego dzień.",
"chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.",
"chartHintCell": "Kliknij dzień, aby go otworzyć.",
"chartHintTouchCell": "Dotknij dnia, aby go otworzyć.",
"chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.",
"coachChips": "Dni w tej odpowiedzi",
"description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.",
"openDocument": "Otwórz dokument",
+5
View File
@@ -2404,6 +2404,11 @@ export function HealthChart({
: dateLabel
}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint
? dayLinkSource?.indexOf(hoverPoint)
: undefined,
)}
/>
);
}}
@@ -644,8 +644,18 @@ export function MedicationComplianceChart({
delta,
},
];
const hoverPoint = payload[0]?.payload;
return (
<RichChartTooltip active label={dateLabel} rows={rows} />
<RichChartTooltip
active
label={dateLabel}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint
? chartData.indexOf(hoverPoint)
: undefined,
)}
/>
);
}}
/>
+10 -1
View File
@@ -1015,7 +1015,16 @@ export function MoodChart({
}
}
if (rows.length === 0) return null;
return <RichChartTooltip active label={dateLabel} rows={rows} />;
return (
<RichChartTooltip
active
label={dateLabel}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
)}
/>
);
}}
/>
<Line
@@ -155,6 +155,7 @@ export function NutrientDailyBarChart({
active
label={fmt.dateShortSmartCalendar(point.day)}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
@@ -239,6 +239,7 @@ export function CustomMetricChart({
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
+18 -2
View File
@@ -24,7 +24,7 @@
* it surfaces a "log your morning temperature" hint rather than a flat dot.
*/
import { useMemo } from "react";
import { useMemo, type ReactNode } from "react";
import { Thermometer } from "lucide-react";
import {
CartesianGrid,
@@ -288,7 +288,20 @@ export function BbtChart({
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={<BbtTooltip />}
content={(props) => {
const payload = props.payload as unknown as
{ payload: BbtPoint }[] | undefined;
const point = payload?.[0]?.payload;
return (
<BbtTooltip
active={props.active}
payload={payload}
action={chartDays.tooltipAction(
point ? points.indexOf(point) : undefined,
)}
/>
);
}}
/>
{ovulationMs != null ? (
<ReferenceLine
@@ -374,6 +387,8 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
export function BbtTooltip(props: {
active?: boolean;
payload?: { payload: BbtPoint }[];
/** v1.42 — the tooltip's way to the day on a touch screen. */
action?: ReactNode;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -407,6 +422,7 @@ export function BbtTooltip(props: {
{t(`cycle.ovulationTest.${p.ovulationTest}`)}
</p>
) : null}
{props.action ?? null}
</div>
);
}
@@ -9,8 +9,9 @@ import { describe, expect, it } from "vitest";
* The day view arrived on six metric pages and nowhere else, and the recovery
* page beside them offered no day at all: nothing noticed, because every check
* proved one chart. This guard walks `src/app` and `src/components` and holds
* three kinds of surface to the same doors (a click or a tap opens the day,
* the dashed line through the open day, the row of day dots, the caption):
* three kinds of surface to the same doors (a click opens the day, on touch
* the tooltip's "View the whole day", the dashed line through the open day,
* the row of day dots, the caption):
*
* A. a mount of a chart whose doors are a prop (`HealthChart`, its dynamic
* wrapper, `MoodChart`, the adherence chart, the nutrient bar chart)
@@ -20,7 +21,7 @@ import { describe, expect, it } from "vitest";
* B. a file that draws its own Recharts chart over a date axis (an
* `<XAxis>` keyed on a date-shaped field, or `scale="time"`) calls
* `useChartDayLinks` and renders its parts (`ChartDayFooter`,
* `OPEN_DAY_LINE`, the tooltip's props);
* `OPEN_DAY_LINE`, the tooltip's props and its touch action);
* C. a calendar heatmap (anything laid out by `heatmapDays`) calls
* `useHeatmapDay`.
*
@@ -195,14 +196,15 @@ function findMissingDoors(): {
/\buseChartDayLinks\(/.test(text) &&
/\bChartDayFooter\b/.test(text) &&
/\bOPEN_DAY_LINE\b/.test(text) &&
/\.tooltipProps\b/.test(text);
/\.tooltipProps\b/.test(text) &&
/\.tooltipAction\(/.test(text);
if (wired) {
doors.charts += 1;
doors.chartFiles.push(file);
} else if (!listed) {
missing.push({
file,
why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props)",
why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props and touch action)",
});
}
}
+45 -22
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { useCallback, useMemo, useRef, type ReactNode } from "react";
import type { DateKey } from "@/lib/day/contract";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -8,6 +8,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
ChartDayCaption,
DayRug,
TooltipDayAction,
useCoarsePointer,
type ChartDayAxis,
type ChartDayMark,
@@ -25,11 +26,13 @@ import { useTodayKey } from "./use-today-key";
* button, the dashed line through the open day and the row of dots under the
* axis behave the same on every page:
*
* - a click, or a tap, on the plot opens the day of the point under it.
* On a fine pointer hovering still shows the tooltip; on a touch screen
* the tap opens the day at once, and the day puts the tapped value at
* its top. A scroll that grazes the chart is a pan, not a tap, and opens
* nothing;
* - fine pointer: a click on the plot opens the day of the point under the
* cursor; hovering shows the tooltip as before;
* - touch: a tap shows the value and pins the tooltip, whose button
* ("View the whole day", a calm 44 px row) opens the day. Two steps on
* purpose (the maintainer's call, after trying one on the beta): a
* point is a small target, and one tap that opened a sheet would open
* days by accident;
* - the open day is marked with `OPEN_DAY_LINE` (a dashed vertical line)
* at `openIndex`;
* - `ChartDayFooter` draws the row of day dots and the one-line caption.
@@ -91,12 +94,19 @@ export interface ChartDayLinks {
onChartClick:
((state: ChartClickState | null | undefined) => void) | undefined;
/**
* Spread on `<Tooltip>`. On touch, and under reduced motion, the tooltip
* does not glide: Recharts slides it from where it last was for 400 ms,
* which on a tap drew a box sweeping across the chart while the day was
* already opening.
* Spread on `<Tooltip>`: on touch it stays where the tap put it and takes
* taps. It does not glide there either: Recharts slides the tooltip from
* where it last was for 400 ms, which turned "View the whole day" into a
* moving target under the finger. Reduced motion stops the glide on every
* pointer.
*/
tooltipProps: { isAnimationActive?: boolean };
tooltipProps: {
trigger: "click" | "hover";
wrapperStyle?: React.CSSProperties;
isAnimationActive?: boolean;
};
/** The tooltip's button for point `index`, on a touch screen only. */
tooltipAction: (index: number | undefined) => ReactNode | undefined;
}
export function useChartDayLinks({
@@ -134,16 +144,17 @@ export function useChartDayLinks({
return at === -1 ? undefined : at;
}, [enabled, openKey, days]);
const onChartClick = enabled
? (state: ChartClickState | null | undefined) => {
if (!state) return;
const index = indexOfClick
? indexOfClick(state)
: Number(state.activeTooltipIndex);
if (index === null || !Number.isInteger(index)) return;
open(index);
}
: undefined;
const onChartClick =
enabled && !coarse
? (state: ChartClickState | null | undefined) => {
if (!state) return;
const index = indexOfClick
? indexOfClick(state)
: Number(state.activeTooltipIndex);
if (index === null || !Number.isInteger(index)) return;
open(index);
}
: undefined;
return {
active: enabled,
@@ -159,7 +170,19 @@ export function useChartDayLinks({
plotClassName: enabled && !coarse ? "cursor-pointer" : "",
onChartClick,
tooltipProps:
coarse || prefersReducedMotion() ? { isAnimationActive: false } : {},
enabled && coarse
? {
trigger: "click",
wrapperStyle: { pointerEvents: "auto", zIndex: 20 },
isAnimationActive: false,
}
: prefersReducedMotion()
? { trigger: "hover", isAnimationActive: false }
: { trigger: "hover" },
tooltipAction: (index) =>
enabled && coarse && index !== undefined && canOpen(index) ? (
<TooltipDayAction onOpen={() => open(index)} />
) : undefined,
};
}
+27 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useSyncExternalStore } from "react";
import { ArrowRight } from "lucide-react";
import type { DateKey } from "@/lib/day/contract";
import { dateOnlyKey } from "@/lib/tz/date-only";
@@ -15,10 +16,12 @@ import { useTodayKey } from "./use-today-key";
/**
* The day view's entry points on a value chart.
*
* - A click, or a tap, on the plot opens the day of the point under it,
* with that value at the top of the day. Hovering on a fine pointer
* keeps showing the tooltip; a scroll that grazes the chart is a pan,
* not a tap, and opens nothing.
* - On a fine pointer, a click on the plot opens the day of the point under
* the cursor; hovering keeps showing the tooltip as before.
* - On a touch screen a tap shows the value, as it always did, and the
* tooltip carries "View the whole day". Two steps on purpose: a point is
* a small target, and one tap that opened a sheet would open days by
* accident. A date in a list is a bigger target and opens in one tap.
* - Under the axis, a quiet row of dots marks the days that hold anything,
* a ring the days with a notable observation. Decoration for the eye; the
* keyboard and screen-reader way to a day is the chart's data table.
@@ -57,6 +60,26 @@ export function chartPointDayKey(timestamp: number): DateKey {
return dateOnlyKey(new Date(timestamp));
}
/** The tooltip's way to the day on a touch screen. */
export function TooltipDayAction({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslations();
return (
<button
type="button"
data-slot="chart-tooltip-open-day"
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
onPointerDown={(event) => event.stopPropagation()}
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-2.5 flex min-h-11 w-full items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("day.viewWholeDay")}
<ArrowRight className="size-4" aria-hidden="true" />
</button>
);
}
interface RugPoint {
timestamp: number;
}
+22 -10
View File
@@ -1,10 +1,10 @@
"use client";
import { useEffect, useState, type RefObject } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import type { DateKey } from "@/lib/day/contract";
import { useCoarsePointer } from "./chart-day";
import { TooltipDayAction, useCoarsePointer } from "./chart-day";
import { openDay, useOpenDay } from "./day-layer-controller";
import { isOpenableDay } from "./day-url";
import { useTodayKey } from "./use-today-key";
@@ -13,10 +13,11 @@ import { useTodayKey } from "./use-today-key";
* The day view's doors on a calendar heatmap (mood, intake), the cell twin of
* `useChartDayLinks`:
*
* - a click, or a tap, on a cell opens its day; hovering a cell on a fine
* pointer shows its tooltip. A tap on a cell with nothing to open (no
* entry, or a day still ahead) pins its tooltip instead, so the cell
* still says what it is; a tap outside or Escape clears it;
* - fine pointer: hovering a cell shows its tooltip, a click opens its day;
* - touch: a tap pins the tooltip, and the tooltip's button ("View the
* whole day", the same calm 44 px row every chart's tooltip carries)
* opens it. Two steps, as on a chart: a cell is a small target. A tap
* outside or Escape clears the tooltip;
* - the open day's cell carries a dashed outline, the heatmap's form of
* the dashed line a chart draws through it (`OPEN_DAY_CELL`);
* - the keyboard and screen-reader way to a day is the heatmap's data
@@ -39,6 +40,8 @@ interface HeatmapTooltip {
text: string;
/** A touch tooltip stays after the finger lifts. */
pinned?: boolean;
/** The day a pinned tooltip offers to open. */
day?: DateKey;
}
export interface HeatmapCellInput {
@@ -51,6 +54,9 @@ export interface HeatmapCellInput {
export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
const [tooltip, setTooltip] = useState<HeatmapTooltip | null>(null);
// The pointer type of the last press decides whether the click that
// follows opens the day (mouse, pen) or only pinned the tooltip (touch).
const lastPointer = useRef<string>("mouse");
const today = useTodayKey();
const openKey = useOpenDay();
const coarse = useCoarsePointer();
@@ -104,17 +110,18 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
setTooltip((prev) => (prev?.pinned ? prev : null));
},
onPointerDown: (e: React.PointerEvent) => {
// A touch on a cell with a day to open opens it (the click below);
// any other cell pins its tooltip so the tap still answers.
if (e.pointerType !== "touch" || openable) return;
lastPointer.current = e.pointerType;
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: cell.describe(),
pinned: true,
day: openable ? cell.dateKey : undefined,
});
},
onClick: () => {
if (lastPointer.current === "touch") return;
if (!openable) return;
open(cell.dateKey);
},
@@ -129,7 +136,9 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
const tooltipNode = tooltip ? (
<div
data-slot="heatmap-tooltip"
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md"
className={`bg-popover text-popover-foreground border-border fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md ${
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
}`}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
@@ -141,6 +150,9 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
}}
>
{tooltip.text}
{tooltip.day ? (
<TooltipDayAction onOpen={() => open(tooltip.day as DateKey)} />
) : null}
</div>
) : null;
@@ -216,6 +216,7 @@ export function MoodDimensionTrends({
active
label={fmt.dateShortSmartCalendar(String(row.date))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(row))}
/>
);
}}
@@ -469,6 +469,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
const row = payload[0]?.payload as
Record<string, number | string> | undefined;
const label = row?.label;
const rowIndex = row ? data.indexOf(row) : -1;
const napCount =
typeof row?.napCount === "number" ? row.napCount : 0;
// The footer's first line is the NIGHT. The nap is listed
@@ -549,6 +550,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
)}
</div>
)}
{chartDays.tooltipAction(
rowIndex === -1 ? undefined : rowIndex,
)}
</div>
);
}}
@@ -406,6 +406,7 @@ export function LabBiomarkerChart({
active
label={labShortDate(point.timestamp)}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
@@ -378,11 +378,18 @@ export function EfficacyChart({
color: item.color ?? "var(--chart-1)",
};
});
const index =
typeof x === "number"
? displaySeries.findIndex((p) => p.t === x)
: -1;
return (
<RichChartTooltip
active
label={typeof x === "number" ? fmt.date(new Date(x)) : ""}
rows={rows}
action={chartDays.tooltipAction(
index === -1 ? undefined : index,
)}
/>
);
}}
@@ -326,6 +326,7 @@ export function DoseStrengthCurve({
active
label={fmt.dateShortSmart(new Date(point.t))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(point))}
/>
);
}}
@@ -183,6 +183,7 @@ export function AssessmentHistoryChart({
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rowsOut}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}