From 5999fdf35244fb426af47ef49cf7862f12e4c5dd Mon Sep 17 00:00:00 2001
From: ahamSel <77988808+ahamSel@users.noreply.github.com>
Date: Thu, 6 Aug 2026 00:44:00 -0230
Subject: [PATCH 1/3] fix(activity): keep latest tooltips within viewport
(@ahamSel)
---
.../__tests__/elements/test-activity.spec.ts | 52 +++++++++++++++++++
frontend/src/ts/elements/test-activity.ts | 10 +++-
2 files changed, 60 insertions(+), 2 deletions(-)
create mode 100644 frontend/__tests__/elements/test-activity.spec.ts
diff --git a/frontend/__tests__/elements/test-activity.spec.ts b/frontend/__tests__/elements/test-activity.spec.ts
new file mode 100644
index 000000000000..58c353523e2c
--- /dev/null
+++ b/frontend/__tests__/elements/test-activity.spec.ts
@@ -0,0 +1,52 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { update } from "../../src/ts/elements/test-activity";
+import { TestActivityCalendar } from "../../src/ts/elements/test-activity-calendar";
+
+describe("test-activity.ts", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-06T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ document.body.replaceChildren();
+ });
+
+ it("aligns tooltips in the last week away from the right edge", () => {
+ const element = document.createElement("div");
+ element.className = "testActivity";
+ element.innerHTML = `
+
+
+
+
+ `;
+ document.body.append(element);
+
+ const calendar = new TestActivityCalendar(
+ [],
+ new Date("2026-08-06T12:00:00Z"),
+ 0,
+ );
+
+ update(element, calendar);
+
+ const days = Array.from(
+ element.querySelectorAll(".activity > div"),
+ );
+ const previousWeeks = days
+ .slice(0, -7)
+ .filter((day) => day.hasAttribute("aria-label"));
+ const lastWeek = days
+ .slice(-7)
+ .filter((day) => day.hasAttribute("aria-label"));
+
+ expect(previousWeeks.at(-1)).toHaveAttribute("data-balloon-pos", "up");
+ expect(lastWeek).not.toHaveLength(0);
+ for (const day of lastWeek) {
+ expect(day).toHaveAttribute("data-balloon-pos", "up-right");
+ }
+ });
+});
diff --git a/frontend/src/ts/elements/test-activity.ts b/frontend/src/ts/elements/test-activity.ts
index 7d2bae4b95de..112f2f014607 100644
--- a/frontend/src/ts/elements/test-activity.ts
+++ b/frontend/src/ts/elements/test-activity.ts
@@ -51,12 +51,18 @@ export function update(
}
}
- for (const day of calendar.getDays()) {
+ const days = calendar.getDays();
+ const lastWeekStart = days.length - 7;
+
+ for (const [index, day] of days.entries()) {
const elem = document.createElement("div");
elem.setAttribute("data-level", day.level);
if (day.label !== undefined) {
elem.setAttribute("aria-label", day.label);
- elem.setAttribute("data-balloon-pos", "up");
+ elem.setAttribute(
+ "data-balloon-pos",
+ index >= lastWeekStart ? "up-right" : "up",
+ );
}
container.appendChild(elem);
}
From d9cc0188961d4c22ef95c5a2bb43b4dd1cc7754f Mon Sep 17 00:00:00 2001
From: ahamSel <77988808+ahamSel@users.noreply.github.com>
Date: Thu, 6 Aug 2026 09:28:18 -0230
Subject: [PATCH 2/3] fix(activity): keep edge tooltips within viewport
(@ahamSel)
---
.../__tests__/elements/test-activity.spec.ts | 59 +++++++++++---
frontend/src/styles/test-activity.scss | 23 ++++++
frontend/src/ts/elements/test-activity.ts | 79 +++++++++++++++++--
3 files changed, 140 insertions(+), 21 deletions(-)
diff --git a/frontend/__tests__/elements/test-activity.spec.ts b/frontend/__tests__/elements/test-activity.spec.ts
index 58c353523e2c..335f1a78ec25 100644
--- a/frontend/__tests__/elements/test-activity.spec.ts
+++ b/frontend/__tests__/elements/test-activity.spec.ts
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { update } from "../../src/ts/elements/test-activity";
+import {
+ getTooltipHorizontalOffset,
+ update,
+} from "../../src/ts/elements/test-activity";
import { TestActivityCalendar } from "../../src/ts/elements/test-activity-calendar";
describe("test-activity.ts", () => {
@@ -14,7 +17,7 @@ describe("test-activity.ts", () => {
document.body.replaceChildren();
});
- it("aligns tooltips in the last week away from the right edge", () => {
+ it("keeps tooltip arrows centered above every activity day", () => {
const element = document.createElement("div");
element.className = "testActivity";
element.innerHTML = `
@@ -36,17 +39,47 @@ describe("test-activity.ts", () => {
const days = Array.from(
element.querySelectorAll(".activity > div"),
);
- const previousWeeks = days
- .slice(0, -7)
- .filter((day) => day.hasAttribute("aria-label"));
- const lastWeek = days
- .slice(-7)
- .filter((day) => day.hasAttribute("aria-label"));
-
- expect(previousWeeks.at(-1)).toHaveAttribute("data-balloon-pos", "up");
- expect(lastWeek).not.toHaveLength(0);
- for (const day of lastWeek) {
- expect(day).toHaveAttribute("data-balloon-pos", "up-right");
+ const labelledDays = days.filter((day) => day.hasAttribute("aria-label"));
+
+ expect(labelledDays).not.toHaveLength(0);
+ for (const day of labelledDays) {
+ expect(day).toHaveAttribute("data-balloon-pos", "up");
}
});
+
+ it.each([
+ {
+ description: "away from either edge",
+ triggerLeft: 140,
+ triggerWidth: 10,
+ tooltipWidth: 100,
+ viewportWidth: 300,
+ expected: 0,
+ },
+ {
+ description: "at the left edge",
+ triggerLeft: 2,
+ triggerWidth: 10,
+ tooltipWidth: 100,
+ viewportWidth: 300,
+ expected: 51,
+ },
+ {
+ description: "at the right edge",
+ triggerLeft: 288,
+ triggerWidth: 10,
+ tooltipWidth: 100,
+ viewportWidth: 300,
+ expected: -51,
+ },
+ ])("offsets the tooltip box $description", (testCase) => {
+ expect(
+ getTooltipHorizontalOffset(
+ testCase.triggerLeft,
+ testCase.triggerWidth,
+ testCase.tooltipWidth,
+ testCase.viewportWidth,
+ ),
+ ).toBe(testCase.expected);
+ });
});
diff --git a/frontend/src/styles/test-activity.scss b/frontend/src/styles/test-activity.scss
index 84d852c3b9c6..2bf70c4d44e6 100644
--- a/frontend/src/styles/test-activity.scss
+++ b/frontend/src/styles/test-activity.scss
@@ -115,6 +115,8 @@
}
.activity {
+ --activity-tooltip-offset: 0px;
+
grid-area: chart;
display: grid;
grid-auto-flow: column;
@@ -131,6 +133,27 @@
&[data-level="filler"]:hover {
border: none;
}
+
+ &[aria-label][data-balloon-pos="up"]::after {
+ font-size: clamp(0.75rem, calc(0.55rem + 1vw), 1rem);
+ line-height: 1.2;
+ max-width: calc(100vw - 1rem);
+ text-align: center;
+ transform: translate(
+ calc(-50% + var(--activity-tooltip-offset)),
+ var(--balloon-move)
+ );
+ white-space: normal;
+ width: max-content;
+ }
+
+ &[aria-label][data-balloon-pos="up"]:hover::after,
+ &[aria-label][data-balloon-pos="up"][data-balloon-visible]::after,
+ &[aria-label][data-balloon-pos="up"]:not(
+ [data-balloon-nofocus]
+ ):focus::after {
+ transform: translate(calc(-50% + var(--activity-tooltip-offset)), 0);
+ }
}
}
.legend {
diff --git a/frontend/src/ts/elements/test-activity.ts b/frontend/src/ts/elements/test-activity.ts
index 112f2f014607..29733fbea296 100644
--- a/frontend/src/ts/elements/test-activity.ts
+++ b/frontend/src/ts/elements/test-activity.ts
@@ -3,6 +3,74 @@ import {
TestActivityMonth,
} from "./test-activity-calendar";
+const tooltipViewportPadding = 8;
+
+export function getTooltipHorizontalOffset(
+ triggerLeft: number,
+ triggerWidth: number,
+ tooltipWidth: number,
+ viewportWidth: number,
+): number {
+ const availableWidth = Math.max(
+ 0,
+ viewportWidth - tooltipViewportPadding * 2,
+ );
+ const renderedTooltipWidth = Math.min(tooltipWidth, availableWidth);
+ const triggerCenter = triggerLeft + triggerWidth / 2;
+ const tooltipLeft = triggerCenter - renderedTooltipWidth / 2;
+ const tooltipRight = triggerCenter + renderedTooltipWidth / 2;
+
+ if (tooltipLeft < tooltipViewportPadding) {
+ return tooltipViewportPadding - tooltipLeft;
+ }
+
+ if (tooltipRight > viewportWidth - tooltipViewportPadding) {
+ return viewportWidth - tooltipViewportPadding - tooltipRight;
+ }
+
+ return 0;
+}
+
+function measureTooltipWidth(element: HTMLElement): number {
+ const label = element.getAttribute("aria-label");
+ if (label === null) {
+ return 0;
+ }
+
+ const tooltipStyle = window.getComputedStyle(element, "::after");
+ const measure = document.createElement("span");
+ measure.textContent = label;
+ Object.assign(measure.style, {
+ fontFamily: tooltipStyle.fontFamily,
+ fontSize: tooltipStyle.fontSize,
+ fontStyle: tooltipStyle.fontStyle,
+ fontWeight: tooltipStyle.fontWeight,
+ letterSpacing: tooltipStyle.letterSpacing,
+ paddingLeft: tooltipStyle.paddingLeft,
+ paddingRight: tooltipStyle.paddingRight,
+ position: "fixed",
+ visibility: "hidden",
+ whiteSpace: "nowrap",
+ });
+ document.body.appendChild(measure);
+ const width = measure.getBoundingClientRect().width;
+ measure.remove();
+
+ return width;
+}
+
+function positionTooltip(element: HTMLElement): void {
+ const triggerRect = element.getBoundingClientRect();
+ const offset = getTooltipHorizontalOffset(
+ triggerRect.left,
+ triggerRect.width,
+ measureTooltipWidth(element),
+ document.documentElement.clientWidth,
+ );
+
+ element.style.setProperty("--activity-tooltip-offset", `${offset}px`);
+}
+
export function init(
element: HTMLElement,
calendar?: TestActivityCalendar,
@@ -51,18 +119,13 @@ export function update(
}
}
- const days = calendar.getDays();
- const lastWeekStart = days.length - 7;
-
- for (const [index, day] of days.entries()) {
+ for (const day of calendar.getDays()) {
const elem = document.createElement("div");
elem.setAttribute("data-level", day.level);
if (day.label !== undefined) {
elem.setAttribute("aria-label", day.label);
- elem.setAttribute(
- "data-balloon-pos",
- index >= lastWeekStart ? "up-right" : "up",
- );
+ elem.setAttribute("data-balloon-pos", "up");
+ elem.addEventListener("pointerenter", () => positionTooltip(elem));
}
container.appendChild(elem);
}
From 37ebbbf2c39dc6c2dfe4a337a44ab4e4b0d83b76 Mon Sep 17 00:00:00 2001
From: ahamSel <77988808+ahamSel@users.noreply.github.com>
Date: Thu, 6 Aug 2026 09:35:46 -0230
Subject: [PATCH 3/3] chore(activity): remove non-behavioral tooltip test
(@ahamSel)
---
.../__tests__/elements/test-activity.spec.ts | 85 -------------------
frontend/src/ts/elements/test-activity.ts | 2 +-
2 files changed, 1 insertion(+), 86 deletions(-)
delete mode 100644 frontend/__tests__/elements/test-activity.spec.ts
diff --git a/frontend/__tests__/elements/test-activity.spec.ts b/frontend/__tests__/elements/test-activity.spec.ts
deleted file mode 100644
index 335f1a78ec25..000000000000
--- a/frontend/__tests__/elements/test-activity.spec.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-import {
- getTooltipHorizontalOffset,
- update,
-} from "../../src/ts/elements/test-activity";
-import { TestActivityCalendar } from "../../src/ts/elements/test-activity-calendar";
-
-describe("test-activity.ts", () => {
- beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-08-06T12:00:00Z"));
- });
-
- afterEach(() => {
- vi.useRealTimers();
- document.body.replaceChildren();
- });
-
- it("keeps tooltip arrows centered above every activity day", () => {
- const element = document.createElement("div");
- element.className = "testActivity";
- element.innerHTML = `
-
-
-
-
- `;
- document.body.append(element);
-
- const calendar = new TestActivityCalendar(
- [],
- new Date("2026-08-06T12:00:00Z"),
- 0,
- );
-
- update(element, calendar);
-
- const days = Array.from(
- element.querySelectorAll(".activity > div"),
- );
- const labelledDays = days.filter((day) => day.hasAttribute("aria-label"));
-
- expect(labelledDays).not.toHaveLength(0);
- for (const day of labelledDays) {
- expect(day).toHaveAttribute("data-balloon-pos", "up");
- }
- });
-
- it.each([
- {
- description: "away from either edge",
- triggerLeft: 140,
- triggerWidth: 10,
- tooltipWidth: 100,
- viewportWidth: 300,
- expected: 0,
- },
- {
- description: "at the left edge",
- triggerLeft: 2,
- triggerWidth: 10,
- tooltipWidth: 100,
- viewportWidth: 300,
- expected: 51,
- },
- {
- description: "at the right edge",
- triggerLeft: 288,
- triggerWidth: 10,
- tooltipWidth: 100,
- viewportWidth: 300,
- expected: -51,
- },
- ])("offsets the tooltip box $description", (testCase) => {
- expect(
- getTooltipHorizontalOffset(
- testCase.triggerLeft,
- testCase.triggerWidth,
- testCase.tooltipWidth,
- testCase.viewportWidth,
- ),
- ).toBe(testCase.expected);
- });
-});
diff --git a/frontend/src/ts/elements/test-activity.ts b/frontend/src/ts/elements/test-activity.ts
index 29733fbea296..f3e52eaf69c5 100644
--- a/frontend/src/ts/elements/test-activity.ts
+++ b/frontend/src/ts/elements/test-activity.ts
@@ -5,7 +5,7 @@ import {
const tooltipViewportPadding = 8;
-export function getTooltipHorizontalOffset(
+function getTooltipHorizontalOffset(
triggerLeft: number,
triggerWidth: number,
tooltipWidth: number,