From 8e4dc8590874ec5ea0d3ae75be3fb449689bdd5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 00:33:08 +0000 Subject: [PATCH 1/8] feat(activity): fit the heatmap to narrow panes and pin the day header Day cells are a fixed 12px so the graph card is the same height at every width; the week area is measured and buildContributionGrid keeps only the trailing weeks that fit, so a phone or narrow split shows recent weeks instead of scrolling sideways. Under @max-md/u-list the legend drops its words, the stats read as two columns, chips shorten, and row text wraps instead of truncating; touch devices get 44px row targets. Virtua positions rows absolutely, so a zero-height sticky slot at the head of the scroller repeats the day header for the first visible row. Co-authored-by: teo --- .../activity/components/action-graph.test.tsx | 78 +++++++++++++++ .../activity/components/action-graph.tsx | 95 ++++++++++++------- .../components/activity-timeline-row.tsx | 4 +- .../activity/components/top-entities.tsx | 2 +- .../activity/core/contribution-grid.test.ts | 69 +++++++++++++- .../activity/core/contribution-grid.ts | 52 ++++++++-- .../features/activity/core/feed-rows.test.ts | 45 ++++++++- .../src/features/activity/core/feed-rows.ts | 17 ++++ .../activity/views/my-activity-view.test.tsx | 87 +++++++++++++++-- .../activity/views/my-activity-view.tsx | 45 ++++++++- docs/AGENT_GUIDE/surfaces.md | 9 ++ 11 files changed, 449 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/features/activity/components/action-graph.test.tsx diff --git a/apps/web/src/features/activity/components/action-graph.test.tsx b/apps/web/src/features/activity/components/action-graph.test.tsx new file mode 100644 index 00000000000..6aedb23d081 --- /dev/null +++ b/apps/web/src/features/activity/components/action-graph.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, render } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { placeholderOverview } from '../core/placeholder-overview'; +import { ActionGraph } from './action-graph'; + +// jsdom has no ResizeObserver and no layout; `weekAreaPx` is what the graph's +// week area measures. +const layout = { weekAreaPx: 0 }; + +beforeEach(() => { + layout.weekAreaPx = 0; + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ + width: layout.weekAreaPx, + height: 0, + top: 0, + left: 0, + right: layout.weekAreaPx, + bottom: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) + ); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const overview = placeholderOverview(new Date('2026-09-06T12:00:00Z')); +const days = (root: ParentNode) => + root.querySelectorAll('[data-activity-day]').length; + +describe('ActionGraph', () => { + it('shows the trailing weeks that fit the measured week area', () => { + // 20 columns of 12px with 3px gaps: 20 * 15 - 3. + layout.weekAreaPx = 297; + const { container } = render(() => ); + expect(days(container)).toBe(20 * 7); + expect( + container.querySelector('[data-activity-heatmap-weeks]')?.className + ).toContain('h-[102px]'); + }); + + it('shows the whole year when the week area is wide enough', () => { + layout.weekAreaPx = 900; + const { container } = render(() => ); + expect(days(container)).toBeGreaterThan(300); + expect(days(container) % 7).toBe(0); + }); + + it('paints no cells before it has been measured', () => { + const { container } = render(() => ); + expect(days(container)).toBe(0); + expect( + container.querySelector('[data-activity-heatmap-weeks]')?.className + ).toContain('h-[102px]'); + }); + + it('lets a caller fix the column count, in skeleton mode too', () => { + const { container } = render(() => ( + + )); + expect(days(container)).toBe(12 * 7); + expect(container.textContent).not.toContain('('); + }); +}); diff --git a/apps/web/src/features/activity/components/action-graph.tsx b/apps/web/src/features/activity/components/action-graph.tsx index 954a0e8129a..2e740349606 100644 --- a/apps/web/src/features/activity/components/action-graph.tsx +++ b/apps/web/src/features/activity/components/action-graph.tsx @@ -1,6 +1,7 @@ +import { createElementSize } from '@solid-primitives/resize-observer'; import { cn, Layer, Tooltip } from '@ui'; import { format } from 'date-fns'; -import { createMemo, For, type JSX, Show } from 'solid-js'; +import { createMemo, createSignal, For, type JSX, Show } from 'solid-js'; import { match } from 'ts-pattern'; import { OVERVIEW_TZ, parseOverviewDate } from '../core/activity-dates'; import { @@ -14,12 +15,21 @@ import { buildContributionGrid, type ContributionDay, type ContributionWeek, + weeksThatFit, } from '../core/contribution-grid'; import type { ActivityOverview } from '../core/event'; import type { ActivityIntensity } from '../core/intensity'; const WEEKDAY_LABELS = ['', 'M', '', 'W', '', 'F', '']; +// Cell geometry in px, kept in step with HEATMAP_CELL_PX / HEATMAP_GAP_PX / +// HEATMAP_HEIGHT_PX in core/contribution-grid.ts. Pixels rather than rem so +// the "how many weeks fit" arithmetic holds when Dynamic Type scales the root +// font size on touch devices. +const CELL_CLASS = 'size-[12px]'; +const COLUMN_GAP_CLASS = 'gap-[3px]'; +const HEATMAP_HEIGHT_CLASS = 'h-[102px]'; + function dateLabel(date: string): string { return format(parseOverviewDate(date), 'EEE, MMM d, yyyy', { in: OVERVIEW_TZ, @@ -51,12 +61,23 @@ function dayStat(date: string | null): string { * With `skeleton`, the same layout renders shimmer placeholders in place of * the numbers and day cells. Pass a `placeholderOverview` so the geometry * matches the card that replaces it. + * + * Day cells are a fixed size, so the card is the same height at every width + * and a narrow card shows the most recent weeks that fit instead of + * scrolling sideways. The count comes from measuring the week area; + * `maxWeeks` overrides the measurement. */ export function ActionGraph(props: { overview: ActivityOverview; skeleton?: boolean; + maxWeeks?: number; }) { - const grid = createMemo(() => buildContributionGrid(props.overview)); + const [weekArea, setWeekArea] = createSignal(); + const weekAreaSize = createElementSize(weekArea); + const maxWeeks = () => props.maxWeeks ?? weeksThatFit(weekAreaSize.width); + const grid = createMemo(() => + buildContributionGrid(props.overview, { maxWeeks: maxWeeks() }) + ); const monthLabels = createMemo( () => new Map( @@ -86,6 +107,7 @@ export function ActionGraph(props: { weeks={grid().weeks} monthLabels={monthLabels()} skeleton={skeleton()} + weekAreaRef={setWeekArea} /> @@ -128,13 +150,13 @@ function ActionGraphHeader(props: { total: number; skeleton: boolean }) { function IntensityLegend() { return (
- Fewer + Fewer {(level) => ( )} - More + More
); } @@ -143,19 +165,22 @@ function ContributionHeatmap(props: { weeks: ContributionWeek[]; monthLabels: Map; skeleton: boolean; + weekAreaRef: (element: HTMLDivElement) => void; }) { return ( -
-
- - - {(_, index) => ( - - )} - - -
- +
+ + + {(_, index) => } + + +
+ +
{(week) => } @@ -169,12 +194,15 @@ function ContributionHeatmap(props: { function WeekdayGutter() { return ( -
+
{(label) => ( - - {label} - + {label} )}
@@ -183,7 +211,7 @@ function WeekdayGutter() { function HeatmapWeek(props: { week: ContributionWeek; skeleton: boolean }) { return ( - + {(day) => } @@ -202,7 +230,7 @@ function MonthLetter(props: { label?: string }) { function DaySquare(props: { day: ContributionDay | null; skeleton: boolean }) { const day = props.day; if (!day) { - return ; + return ; } const label = actionLabel(day); @@ -213,14 +241,17 @@ function DaySquare(props: { day: ContributionDay | null; skeleton: boolean }) { } > - {props.children} -
+
{props.children}
); } function WeekRow(props: { class?: string; children?: JSX.Element }) { return ( -
+
{props.children}
); @@ -281,7 +306,7 @@ function WeekRow(props: { class?: string; children?: JSX.Element }) { function ActionGraphStats(props: { stats: ActivityStats; skeleton: boolean }) { return ( -
+
+
{props.label}
-
+
}> {props.value} diff --git a/apps/web/src/features/activity/components/activity-timeline-row.tsx b/apps/web/src/features/activity/components/activity-timeline-row.tsx index 2a3f1c27b21..34e7615a458 100644 --- a/apps/web/src/features/activity/components/activity-timeline-row.tsx +++ b/apps/web/src/features/activity/components/activity-timeline-row.tsx @@ -111,7 +111,9 @@ export function ActivityTimelineRow(props: { {...props.rowProps} class={cn( 'flex min-w-0 flex-1 items-center whitespace-nowrap rounded-lg hover:bg-hover/30', - props.compact ? 'min-h-8 gap-1 px-1' : 'min-h-10 gap-1.5 px-2' + props.compact + ? 'min-h-8 gap-1 px-1' + : 'min-h-10 gap-1.5 px-2 touch:min-h-11' )} > diff --git a/apps/web/src/features/activity/components/top-entities.tsx b/apps/web/src/features/activity/components/top-entities.tsx index 496ab1d9140..874bcdf0c2b 100644 --- a/apps/web/src/features/activity/components/top-entities.tsx +++ b/apps/web/src/features/activity/components/top-entities.tsx @@ -49,7 +49,7 @@ export function TopEntityChip(props: { {display().icon()} - + {display().name()} diff --git a/apps/web/src/features/activity/core/contribution-grid.test.ts b/apps/web/src/features/activity/core/contribution-grid.test.ts index d4a685c7732..60a4dbe40c4 100644 --- a/apps/web/src/features/activity/core/contribution-grid.test.ts +++ b/apps/web/src/features/activity/core/contribution-grid.test.ts @@ -1,7 +1,53 @@ import { describe, expect, it } from 'vitest'; -import { buildContributionGrid } from './contribution-grid'; +import { + buildContributionGrid, + HEATMAP_CELL_PX, + HEATMAP_GAP_PX, + HEATMAP_HEIGHT_PX, + weeksThatFit, +} from './contribution-grid'; describe('buildContributionGrid', () => { + describe('maxWeeks', () => { + // 2025-09-07 (Sunday) through 2026-09-06: 52 full weeks. + const year = { from: '2025-09-07', to: '2026-09-06', days: [] }; + + it('keeps every column when unset', () => { + expect(buildContributionGrid(year).weeks).toHaveLength(52); + }); + + it('keeps the trailing columns and re-anchors the first visible month', () => { + const grid = buildContributionGrid(year, { maxWeeks: 20 }); + expect(grid.weeks).toHaveLength(20); + expect(grid.weeks[0]?.[0]?.date).toBe('2026-04-19'); + expect(grid.weeks[19]?.[6]?.date).toBe('2026-09-05'); + expect( + grid.monthLabels.map(({ label, weekIndex }) => [label, weekIndex]) + ).toEqual([ + ['Apr', 0], + ['May', 1], + ['Jun', 6], + ['Jul', 10], + ['Aug', 14], + ['Sep', 19], + ]); + }); + + it('is a no-op when more columns fit than exist', () => { + expect(buildContributionGrid(year, { maxWeeks: 60 }).weeks).toHaveLength( + 52 + ); + }); + + it('treats zero, negative and fractional counts as whole columns', () => { + expect(buildContributionGrid(year, { maxWeeks: 0 }).weeks).toEqual([]); + expect(buildContributionGrid(year, { maxWeeks: -3 }).weeks).toEqual([]); + expect(buildContributionGrid(year, { maxWeeks: 2.9 }).weeks).toHaveLength( + 2 + ); + }); + }); + it('omits leading and trailing weeks that are not a full Sunday–Saturday', () => { const grid = buildContributionGrid({ from: '2026-08-19', @@ -78,3 +124,24 @@ describe('buildContributionGrid', () => { ).toEqual({ weeks: [], monthLabels: [] }); }); }); + +describe('weeksThatFit', () => { + const column = HEATMAP_CELL_PX + HEATMAP_GAP_PX; + + it('counts whole columns, sharing the trailing gap', () => { + expect(weeksThatFit(HEATMAP_CELL_PX)).toBe(1); + expect(weeksThatFit(HEATMAP_CELL_PX - 1)).toBe(0); + expect(weeksThatFit(20 * column - HEATMAP_GAP_PX)).toBe(20); + expect(weeksThatFit(20 * column - HEATMAP_GAP_PX - 1)).toBe(19); + expect(weeksThatFit(1000)).toBeGreaterThanOrEqual(53); + }); + + it('fits nothing before measurement or when hidden', () => { + expect(weeksThatFit(null)).toBe(0); + expect(weeksThatFit(0)).toBe(0); + }); + + it('exports a height equal to seven cells and six gaps', () => { + expect(HEATMAP_HEIGHT_PX).toBe(102); + }); +}); diff --git a/apps/web/src/features/activity/core/contribution-grid.ts b/apps/web/src/features/activity/core/contribution-grid.ts index 1c98e386f00..d57fa0a83e2 100644 --- a/apps/web/src/features/activity/core/contribution-grid.ts +++ b/apps/web/src/features/activity/core/contribution-grid.ts @@ -32,6 +32,26 @@ export type ContributionGrid = { monthLabels: ContributionMonthLabel[]; }; +/** Edge of one day cell in the heatmap, in CSS pixels. */ +export const HEATMAP_CELL_PX = 12; +/** Gap between day cells and between week columns, in CSS pixels. */ +export const HEATMAP_GAP_PX = 3; +/** Seven fixed-size cells and their six gaps: the heatmap's constant height. */ +export const HEATMAP_HEIGHT_PX = 7 * HEATMAP_CELL_PX + 6 * HEATMAP_GAP_PX; + +/** + * How many fixed-size week columns fit side by side in `width` pixels. + * `null` (not measured yet) and zero (hidden) both read as room for none, + * so an unmeasured heatmap paints no cells rather than a year that + * overflows; the measurement lands before first paint. + */ +export function weeksThatFit(width: number | null): number { + if (width === null || width <= 0) return 0; + return Math.floor( + (width + HEATMAP_GAP_PX) / (HEATMAP_CELL_PX + HEATMAP_GAP_PX) + ); +} + function labelMonth(day: ContributionDay): string { return format(parseOverviewDate(day.date), 'MMM', { in: OVERVIEW_TZ }); } @@ -40,17 +60,34 @@ function isInWindow(day: Date, from: Date, to: Date): boolean { return !isBefore(day, from) && isBefore(day, to); } +/** The trailing `maxWeeks` columns, or every column when unset. */ +function trailingWeeks( + weeks: ContributionWeek[], + maxWeeks: number | undefined +): ContributionWeek[] { + if (maxWeeks === undefined) return weeks; + const keep = Math.max(0, Math.floor(maxWeeks)); + return keep >= weeks.length ? weeks : weeks.slice(weeks.length - keep); +} + /** * Sunday-first weeks that sit entirely inside the window. Leading and * trailing stub columns (days outside `[from, to)`) are omitted, matching * Cursor's heatmap. Dates stay in UTC so they never pick up a second * viewer-time-zone conversion. + * + * `maxWeeks` keeps only the most recent columns, for a card too narrow to + * show the year. Month labels are computed on the kept columns so the first + * visible week is still anchored. */ -export function buildContributionGrid(overview: { - from: string; - to: string; - days: Array<{ date: string; count: number }>; -}): ContributionGrid { +export function buildContributionGrid( + overview: { + from: string; + to: string; + days: Array<{ date: string; count: number }>; + }, + options: { maxWeeks?: number } = {} +): ContributionGrid { const from = parseOverviewDate(overview.from); const to = parseOverviewDate(overview.to); if (!isValid(from) || !isValid(to) || !isBefore(from, to)) { @@ -59,7 +96,7 @@ export function buildContributionGrid(overview: { const counts = new Map(overview.days.map((day) => [day.date, day.count])); const max = Math.max(0, ...overview.days.map((day) => day.count)); - const weeks: ContributionWeek[] = []; + const allWeeks: ContributionWeek[] = []; for (const weekStart of eachWeekOfInterval( { start: from, end: addDays(to, -1) }, @@ -75,10 +112,11 @@ export function buildContributionGrid(overview: { return { date, count, intensity: intensityLevel(count, max) }; }); if (week.every((day) => day !== null)) { - weeks.push(week); + allWeeks.push(week); } } + const weeks = trailingWeeks(allWeeks, options.maxWeeks); const monthLabels: ContributionMonthLabel[] = []; for (const [weekIndex, week] of weeks.entries()) { const visibleDays = week.filter( diff --git a/apps/web/src/features/activity/core/feed-rows.test.ts b/apps/web/src/features/activity/core/feed-rows.test.ts index 678fabdad82..f240b69041e 100644 --- a/apps/web/src/features/activity/core/feed-rows.test.ts +++ b/apps/web/src/features/activity/core/feed-rows.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import { decodeActivityEvent } from '../queries/decode'; import { createdEvent, editedEvent } from '../queries/fixtures'; import type { FeedEntry } from './collapse-runs'; -import { flattenFeed, reuseRows, shouldFetchMore } from './feed-rows'; +import { + type FeedRow, + flattenFeed, + pinnedDayLabel, + reuseRows, + shouldFetchMore, +} from './feed-rows'; const created: FeedEntry = { kind: 'single', @@ -146,6 +152,43 @@ describe('reuseRows', () => { }); }); +describe('pinnedDayLabel', () => { + const rows: FeedRow[] = [ + { kind: 'overview' }, + ...flattenFeed( + [ + { key: 'today', label: 'Today', entries: [created, edited] }, + { key: 'yesterday', label: 'Yesterday', entries: [created] }, + ], + { hasMore: true } + ), + ]; + + it.each([ + // [startIndex, expected] + [0, undefined], + [1, 'Today'], + [2, 'Today'], + [3, 'Today'], + [4, 'Yesterday'], + [5, 'Yesterday'], + [6, 'Yesterday'], + [99, 'Yesterday'], + ])('start index %i -> %s', (startIndex, expected) => { + expect(pinnedDayLabel(rows, startIndex)).toBe(expected); + }); + + it('pins nothing while the feed has no day rows', () => { + expect( + pinnedDayLabel( + [{ kind: 'overview' }, { kind: 'status', status: 'loading' }], + 1 + ) + ).toBeUndefined(); + expect(pinnedDayLabel([], 0)).toBeUndefined(); + }); +}); + describe('shouldFetchMore', () => { it.each([ // [scrollSize, viewportSize, offset, expected] diff --git a/apps/web/src/features/activity/core/feed-rows.ts b/apps/web/src/features/activity/core/feed-rows.ts index 2a3c88cef92..3386b03f57a 100644 --- a/apps/web/src/features/activity/core/feed-rows.ts +++ b/apps/web/src/features/activity/core/feed-rows.ts @@ -79,6 +79,23 @@ export function reuseRows(previous: FeedRow[], next: FeedRow[]): FeedRow[] { }); } +/** + * The day header that governs the row at `startIndex` (the first row under + * the top edge of the scroller): the nearest `day` row at or before it. + * `undefined` while the overview is still at the top, so nothing pins over + * the graph. + */ +export function pinnedDayLabel( + rows: readonly FeedRow[], + startIndex: number +): string | undefined { + for (let index = Math.min(startIndex, rows.length - 1); index >= 0; index--) { + const row = rows[index]; + if (row?.kind === 'day') return row.label; + } + return undefined; +} + /** Floor for the near-bottom threshold so tiny viewports still page. */ const MIN_FETCH_THRESHOLD = 100; diff --git a/apps/web/src/features/activity/views/my-activity-view.test.tsx b/apps/web/src/features/activity/views/my-activity-view.test.tsx index bfb1604deb1..28f2bade5f3 100644 --- a/apps/web/src/features/activity/views/my-activity-view.test.tsx +++ b/apps/web/src/features/activity/views/my-activity-view.test.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import type { JSX } from 'solid-js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ActivityContextProvider } from '../context/activity-context'; import { placeholderOverview } from '../core/placeholder-overview'; import { @@ -18,11 +18,46 @@ vi.mock('@components/app/split-layout/components/SplitHeader', () => ({ })); // jsdom has no layout, so the virtualizer renders every row and exposes a -// fake handle plus its scroll callback so tests can drive paging. -const virtual = vi.hoisted(() => ({ - onScroll: undefined as ((offset: number) => void) | undefined, - handle: { scrollSize: 3000, viewportSize: 800, scrollOffset: 0 }, -})); +// fake handle plus its scroll callback so tests can drive paging. The fake +// treats every row as `rowPx` tall for `findItemIndex`. +const virtual = vi.hoisted(() => { + const rowPx = 100; + return { + rowPx, + onScroll: undefined as ((offset: number) => void) | undefined, + handle: { + scrollSize: 3000, + viewportSize: 800, + scrollOffset: 0, + findItemIndex: (offset: number) => Math.floor(offset / rowPx), + }, + }; +}); + +// jsdom has neither ResizeObserver nor layout. The graph measures its week +// area to decide how many columns fit, so give it a desktop-width answer. +const WEEK_AREA_PX = 900; +beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: WEEK_AREA_PX, + height: 0, + top: 0, + left: 0, + right: WEEK_AREA_PX, + bottom: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }); +}); vi.mock('virtua/solid', async () => { const { For } = await import('solid-js'); @@ -63,7 +98,11 @@ vi.mock('@service-storage/websocket', () => ({ createWebSocketJob: () => Promise.reject(new Error('no websocket in tests')), })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -88,6 +127,7 @@ describe('MyActivityView', () => { const skeletonDays = skeleton.querySelectorAll( '[data-activity-day]' ).length; + // 900px of week area fits 60 columns, more than the placeholder year has. expect(skeletonDays).toBeGreaterThan(300); expect(container.textContent).not.toContain('Loading activity overview'); expect(container.textContent).toContain('Loading…'); @@ -247,6 +287,39 @@ describe('MyActivityView', () => { expect(visible[2]?.textContent).not.toContain('times'); }); + it('pins the day header of the first visible row once the overview scrolls away', () => { + const { container, graphql } = renderView(); + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + graphql.latest('MyActivity').resolve( + feedPage([ + { + ...createdEvent, + id: 'c-today', + occurredAt: new Date().toISOString(), + }, + { ...editedEvent, id: 'e-yesterday', occurredAt: yesterday }, + ]) + ); + // Rows: overview, Today, entry, Yesterday, entry. + const pinned = () => container.querySelector('[data-activity-pinned-day]'); + const scroll = virtual.onScroll; + if (!scroll) throw new Error('virtualizer did not register onScroll'); + + expect(pinned()).toBeNull(); + + scroll(virtual.rowPx * 1); + expect(pinned()?.textContent).toBe('Today'); + + scroll(virtual.rowPx * 2); + expect(pinned()?.textContent).toBe('Today'); + + scroll(virtual.rowPx * 3); + expect(pinned()?.textContent).toBe('Yesterday'); + + scroll(0); + expect(pinned()).toBeNull(); + }); + it('asks the host to open the row entity', () => { const { onOpen, graphql } = renderView(); graphql.latest('MyActivity').resolve(feedPage([createdEvent])); diff --git a/apps/web/src/features/activity/views/my-activity-view.tsx b/apps/web/src/features/activity/views/my-activity-view.tsx index 3787a8ef31b..fa3873ee68e 100644 --- a/apps/web/src/features/activity/views/my-activity-view.tsx +++ b/apps/web/src/features/activity/views/my-activity-view.tsx @@ -1,8 +1,10 @@ import { SoupSectionHeader } from '@app/features/next-soup/soup-view/section-header'; import { SplitHeaderLeft } from '@components/app/split-layout/components/SplitHeader'; import { StaticMarkdownContext } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; +import { createElementSize } from '@solid-primitives/resize-observer'; import { createEffect, + createMemo, createSignal, For, type JSX, @@ -24,6 +26,7 @@ import { entryHead, type FeedEntry } from '../core/collapse-runs'; import type { ActivityTopEntity } from '../core/event'; import { type FeedRow, + pinnedDayLabel, type RailEnds, shouldFetchMore, } from '../core/feed-rows'; @@ -59,6 +62,15 @@ function FeedStatus(props: { children: JSX.Element }) { * The user's own activity, newest first, as one virtualized list with the * overview card as its first row. Scrolling near the end fetches the next * page. Reads `ActivityContext`; the host decides what a row click opens. + * + * Rows are absolutely positioned by the virtualizer, so a day header cannot + * stick on its own. Instead a zero-height sticky slot at the head of the + * scroller repeats the day header that governs the first visible row. + * + * On full-frame touch devices the split header floats over the content, so + * an in-scroll spacer (measured, then handed to virtua as `startMargin`, the + * `SoupList` pattern) rests the list below it and the pinned header sticks + * under it; a matching spacer clears the bottom toolbar. */ export function MyActivityView(props: { onOpen: (target: OpenEntityTarget) => void; @@ -66,8 +78,15 @@ export function MyActivityView(props: { const context = useActivityContext(); const state = createMyActivityState(context); const [scroller, setScroller] = createSignal(); + const [topSpacer, setTopSpacer] = createSignal(); + const topSpacerSize = createElementSize(topSpacer); + const [startIndex, setStartIndex] = createSignal(0); let handle: VirtualizerHandle | undefined; + const pinnedDay = createMemo(() => + pinnedDayLabel(state.rows(), startIndex()) + ); + const fetchMoreIfNearEnd = (offset: number) => { if (!handle) return; if ( @@ -81,6 +100,11 @@ export function MyActivityView(props: { } }; + const onScroll = (offset: number) => { + if (handle) setStartIndex(handle.findItemIndex(offset)); + fetchMoreIfNearEnd(offset); + }; + // A page that does not fill the viewport never scrolls, so re-check once // virtua has laid out the new rows. createEffect( @@ -99,6 +123,20 @@ export function MyActivityView(props: {
+ + diff --git a/docs/AGENT_GUIDE/surfaces.md b/docs/AGENT_GUIDE/surfaces.md index 5c927948b57..aaaa07603ba 100644 --- a/docs/AGENT_GUIDE/surfaces.md +++ b/docs/AGENT_GUIDE/surfaces.md @@ -132,6 +132,15 @@ are in the DOM, and scrolling near the bottom fetches the next page automaticall `Loading…` tail appears while it lands). If a page fails, the tail reads `Couldn't load more.` with a `Retry` button and automatic paging stops until it is pressed. There is no `Show more` button. +Once the heatmap card scrolls away, the day header for the topmost visible row stays pinned at +the top of the list (`[data-activity-pinned-day]`, a non-interactive copy), so a snapshot taken +mid-scroll shows that label twice at most. The heatmap always shows the whole year, including +the partial first and current weeks: its cells shrink from 12px to 8px as the pane narrows, and +below that (a phone) the week area scrolls sideways with the month letters, opened on the newest +week and with no visible scrollbar. Under ~672px the four stats read as a two-column grid; under +~448px (a phone) the legend drops its `Fewer`/`More` words, each stat stacks its label over its +value, and chips shorten. Rows stay on one line at every width. On touch devices the list rests +below the floating page title and above the bottom toolbar. ## Home — `/app/component/home` From a48a6cf0620a61b1f23899739f6b7bfd029b6647 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 00:49:56 +0000 Subject: [PATCH 2/8] fix(activity): rest the feed under the mobile chrome and keep the time with its middot On full-frame touch devices the split header floats over the content and a toolbar covers the bottom, so the list scrolled under both. Follow SoupList: a measured in-scroll spacer becomes virtua's startMargin, the pinned day header sticks below the inset, and a bottom spacer clears the toolbar. The separator and relative time wrap as one unit, the stats grid kicks in under 2xl so the card height is deterministic in a mid-width split. Co-authored-by: teo --- apps/web/src/features/activity/components/action-graph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/features/activity/components/action-graph.tsx b/apps/web/src/features/activity/components/action-graph.tsx index 2e740349606..52eb72b797a 100644 --- a/apps/web/src/features/activity/components/action-graph.tsx +++ b/apps/web/src/features/activity/components/action-graph.tsx @@ -306,7 +306,7 @@ function WeekRow(props: { class?: string; children?: JSX.Element }) { function ActionGraphStats(props: { stats: ActivityStats; skeleton: boolean }) { return ( -
+
Date: Mon, 7 Sep 2026 15:50:12 +0000 Subject: [PATCH 3/8] fix(activity): keep the whole year on the board at every width and light the current week Co-authored-by: teo --- .../activity/components/action-graph.test.tsx | 48 ++-- .../activity/components/action-graph.tsx | 152 +++++++------ .../activity/core/contribution-grid.test.ts | 209 +++++++++++------- .../activity/core/contribution-grid.ts | 150 ++++++++----- 4 files changed, 351 insertions(+), 208 deletions(-) diff --git a/apps/web/src/features/activity/components/action-graph.test.tsx b/apps/web/src/features/activity/components/action-graph.test.tsx index 6aedb23d081..d5dd166aa6e 100644 --- a/apps/web/src/features/activity/components/action-graph.test.tsx +++ b/apps/web/src/features/activity/components/action-graph.test.tsx @@ -38,41 +38,53 @@ afterEach(() => { vi.unstubAllGlobals(); }); -const overview = placeholderOverview(new Date('2026-09-06T12:00:00Z')); +// Monday 2026-09-07: the window opens on a Tuesday and ends mid-week, so the +// year spans 53 columns including both partial weeks. +const overview = placeholderOverview(new Date('2026-09-07T12:00:00Z')); const days = (root: ParentNode) => root.querySelectorAll('[data-activity-day]').length; +const heatmapStyle = (root: ParentNode) => + (root.querySelector('[data-activity-heatmap]') as HTMLElement).style; describe('ActionGraph', () => { - it('shows the trailing weeks that fit the measured week area', () => { - // 20 columns of 12px with 3px gaps: 20 * 15 - 3. - layout.weekAreaPx = 297; + it('renders every week of the year including the partial ones', () => { + layout.weekAreaPx = 900; const { container } = render(() => ); - expect(days(container)).toBe(20 * 7); + expect(days(container)).toBe(365); expect( - container.querySelector('[data-activity-heatmap-weeks]')?.className - ).toContain('h-[102px]'); + container.querySelectorAll('[data-activity-heatmap-weeks] > div > div') + ).toHaveLength(53); }); - it('shows the whole year when the week area is wide enough', () => { + it('takes the full cell size in a wide pane', () => { layout.weekAreaPx = 900; const { container } = render(() => ); - expect(days(container)).toBeGreaterThan(300); - expect(days(container) % 7).toBe(0); + expect(heatmapStyle(container).getPropertyValue('--heatmap-cell')).toBe( + '12px' + ); + expect(heatmapStyle(container).getPropertyValue('--heatmap-gap')).toBe( + '3px' + ); }); - it('paints no cells before it has been measured', () => { + it('shrinks the cells in a narrow pane and keeps every week', () => { + layout.weekAreaPx = 336; const { container } = render(() => ); - expect(days(container)).toBe(0); - expect( - container.querySelector('[data-activity-heatmap-weeks]')?.className - ).toContain('h-[102px]'); + expect(days(container)).toBe(365); + expect(heatmapStyle(container).getPropertyValue('--heatmap-cell')).toBe( + '8px' + ); + expect(heatmapStyle(container).getPropertyValue('--heatmap-gap')).toBe( + '2px' + ); }); - it('lets a caller fix the column count, in skeleton mode too', () => { + it('renders the skeleton with the same columns and no numbers', () => { + layout.weekAreaPx = 900; const { container } = render(() => ( - + )); - expect(days(container)).toBe(12 * 7); + expect(days(container)).toBe(365); expect(container.textContent).not.toContain('('); }); }); diff --git a/apps/web/src/features/activity/components/action-graph.tsx b/apps/web/src/features/activity/components/action-graph.tsx index 52eb72b797a..9f75f114a2c 100644 --- a/apps/web/src/features/activity/components/action-graph.tsx +++ b/apps/web/src/features/activity/components/action-graph.tsx @@ -1,7 +1,14 @@ import { createElementSize } from '@solid-primitives/resize-observer'; import { cn, Layer, Tooltip } from '@ui'; import { format } from 'date-fns'; -import { createMemo, createSignal, For, type JSX, Show } from 'solid-js'; +import { + createEffect, + createMemo, + createSignal, + For, + onCleanup, + Show, +} from 'solid-js'; import { match } from 'ts-pattern'; import { OVERVIEW_TZ, parseOverviewDate } from '../core/activity-dates'; import { @@ -15,20 +22,20 @@ import { buildContributionGrid, type ContributionDay, type ContributionWeek, - weeksThatFit, + type HeatmapGeometry, + heatmapGeometry, } from '../core/contribution-grid'; import type { ActivityOverview } from '../core/event'; import type { ActivityIntensity } from '../core/intensity'; const WEEKDAY_LABELS = ['', 'M', '', 'W', '', 'F', '']; -// Cell geometry in px, kept in step with HEATMAP_CELL_PX / HEATMAP_GAP_PX / -// HEATMAP_HEIGHT_PX in core/contribution-grid.ts. Pixels rather than rem so -// the "how many weeks fit" arithmetic holds when Dynamic Type scales the root -// font size on touch devices. -const CELL_CLASS = 'size-[12px]'; -const COLUMN_GAP_CLASS = 'gap-[3px]'; -const HEATMAP_HEIGHT_CLASS = 'h-[102px]'; +// Cell geometry arrives as CSS variables from `heatmapGeometry`, in px rather +// than rem so the fit holds when Dynamic Type scales the root font size. +const CELL_CLASS = 'size-(--heatmap-cell)'; +const COLUMN_CLASS = 'w-(--heatmap-cell)'; +const GAP_CLASS = 'gap-(--heatmap-gap)'; +const MONTH_ROW_CLASS = 'mb-1 h-3'; function dateLabel(date: string): string { return format(parseOverviewDate(date), 'EEE, MMM d, yyyy', { @@ -62,21 +69,19 @@ function dayStat(date: string | null): string { * the numbers and day cells. Pass a `placeholderOverview` so the geometry * matches the card that replaces it. * - * Day cells are a fixed size, so the card is the same height at every width - * and a narrow card shows the most recent weeks that fit instead of - * scrolling sideways. The count comes from measuring the week area; - * `maxWeeks` overrides the measurement. + * The whole year is always on the board. Cells shrink with the pane down to + * `HEATMAP_MIN_CELL`, and below that the week area scrolls sideways, opened + * on the newest week. The geometry comes from measuring the week area. */ export function ActionGraph(props: { overview: ActivityOverview; skeleton?: boolean; - maxWeeks?: number; }) { const [weekArea, setWeekArea] = createSignal(); const weekAreaSize = createElementSize(weekArea); - const maxWeeks = () => props.maxWeeks ?? weeksThatFit(weekAreaSize.width); - const grid = createMemo(() => - buildContributionGrid(props.overview, { maxWeeks: maxWeeks() }) + const grid = createMemo(() => buildContributionGrid(props.overview)); + const geometry = createMemo(() => + heatmapGeometry(weekAreaSize.width, grid().weeks.length) ); const monthLabels = createMemo( () => @@ -106,6 +111,7 @@ export function ActionGraph(props: { @@ -161,31 +167,57 @@ function IntensityLegend() { ); } +/** + * Month letters ride inside each week column so they scroll with the weeks. + * The week area is measured for the geometry and, when the year still does + * not fit at the smallest cell, scrolls sideways from the newest week. + */ function ContributionHeatmap(props: { weeks: ContributionWeek[]; monthLabels: Map; + geometry: HeatmapGeometry; skeleton: boolean; weekAreaRef: (element: HTMLDivElement) => void; }) { + let weekArea: HTMLDivElement | undefined; + + createEffect(() => { + if (!props.geometry.overflows || !weekArea) return; + const element = weekArea; + const frame = requestAnimationFrame(() => { + element.scrollLeft = element.scrollWidth; + }); + onCleanup(() => cancelAnimationFrame(frame)); + }); + return ( -
- - - {(_, index) => } - - -
- -
- - - {(week) => } - - +
+ +
{ + weekArea = element; + props.weekAreaRef(element); + }} + class="min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + data-activity-heatmap-weeks + > +
+ + {(week, index) => ( + + )} +
@@ -197,33 +229,42 @@ function WeekdayGutter() {
+ {(label) => ( - {label} + + {label} + )}
); } -function HeatmapWeek(props: { week: ContributionWeek; skeleton: boolean }) { +function HeatmapWeek(props: { + week: ContributionWeek; + monthLabel?: string; + skeleton: boolean; +}) { return ( - +
+ + {props.monthLabel} + {(day) => } - - ); -} - -function MonthLetter(props: { label?: string }) { - return ( - - {props.label} - +
); } @@ -289,21 +330,6 @@ function IntensitySwatch(props: { ); } -/** One week column, exactly one cell wide. */ -function WeekColumn(props: { class?: string; children?: JSX.Element }) { - return ( -
{props.children}
- ); -} - -function WeekRow(props: { class?: string; children?: JSX.Element }) { - return ( -
- {props.children} -
- ); -} - function ActionGraphStats(props: { stats: ActivityStats; skeleton: boolean }) { return (
diff --git a/apps/web/src/features/activity/core/contribution-grid.test.ts b/apps/web/src/features/activity/core/contribution-grid.test.ts index 60a4dbe40c4..05652d3b35c 100644 --- a/apps/web/src/features/activity/core/contribution-grid.test.ts +++ b/apps/web/src/features/activity/core/contribution-grid.test.ts @@ -1,87 +1,84 @@ import { describe, expect, it } from 'vitest'; import { buildContributionGrid, - HEATMAP_CELL_PX, - HEATMAP_GAP_PX, - HEATMAP_HEIGHT_PX, - weeksThatFit, + HEATMAP_MAX_CELL, + HEATMAP_MIN_CELL, + heatmapGeometry, } from './contribution-grid'; +import { placeholderOverview } from './placeholder-overview'; describe('buildContributionGrid', () => { - describe('maxWeeks', () => { - // 2025-09-07 (Sunday) through 2026-09-06: 52 full weeks. - const year = { from: '2025-09-07', to: '2026-09-06', days: [] }; + // 2025-09-07 (Sunday) through 2026-09-06: 52 full weeks. + const year = { from: '2025-09-07', to: '2026-09-06', days: [] }; - it('keeps every column when unset', () => { - expect(buildContributionGrid(year).weeks).toHaveLength(52); - }); - - it('keeps the trailing columns and re-anchors the first visible month', () => { - const grid = buildContributionGrid(year, { maxWeeks: 20 }); - expect(grid.weeks).toHaveLength(20); - expect(grid.weeks[0]?.[0]?.date).toBe('2026-04-19'); - expect(grid.weeks[19]?.[6]?.date).toBe('2026-09-05'); - expect( - grid.monthLabels.map(({ label, weekIndex }) => [label, weekIndex]) - ).toEqual([ - ['Apr', 0], - ['May', 1], - ['Jun', 6], - ['Jul', 10], - ['Aug', 14], - ['Sep', 19], - ]); - }); - - it('is a no-op when more columns fit than exist', () => { - expect(buildContributionGrid(year, { maxWeeks: 60 }).weeks).toHaveLength( - 52 - ); - }); - - it('treats zero, negative and fractional counts as whole columns', () => { - expect(buildContributionGrid(year, { maxWeeks: 0 }).weeks).toEqual([]); - expect(buildContributionGrid(year, { maxWeeks: -3 }).weeks).toEqual([]); - expect(buildContributionGrid(year, { maxWeeks: 2.9 }).weeks).toHaveLength( - 2 - ); - }); + it('keeps every column of a whole-week window', () => { + expect(buildContributionGrid(year).weeks).toHaveLength(52); }); - it('omits leading and trailing weeks that are not a full Sunday–Saturday', () => { + it('keeps partial first and last weeks with the outside days null', () => { + // Wednesday 2026-08-19 through Sunday 2026-08-30 (to is exclusive). const grid = buildContributionGrid({ from: '2026-08-19', to: '2026-08-31', days: [ { date: '2026-08-19', count: 2 }, { date: '2026-08-23', count: 8 }, + { date: '2026-08-30', count: 5 }, ], }); - expect(grid.weeks).toHaveLength(1); - expect(grid.weeks[0].map((day) => day?.date)).toEqual([ - '2026-08-23', - '2026-08-24', - '2026-08-25', - '2026-08-26', - '2026-08-27', - '2026-08-28', - '2026-08-29', + expect(grid.weeks).toHaveLength(3); + expect(grid.weeks[0].map((day) => day?.date ?? null)).toEqual([ + null, + null, + null, + '2026-08-19', + '2026-08-20', + '2026-08-21', + '2026-08-22', + ]); + expect(grid.weeks[2].map((day) => day?.date ?? null)).toEqual([ + '2026-08-30', + null, + null, + null, + null, + null, + null, ]); + expect(grid.weeks[2][0]?.count).toBe(5); }); - it('returns no columns when the window contains no full week', () => { + it('shows a window shorter than a week as one partial column', () => { const grid = buildContributionGrid({ from: '2026-08-19', to: '2026-08-24', - days: [ - { date: '2026-08-19', count: 2 }, - { date: '2026-08-23', count: 8 }, - ], + days: [{ date: '2026-08-23', count: 8 }], }); - expect(grid.weeks).toEqual([]); - expect(grid.monthLabels).toEqual([]); + expect(grid.weeks).toHaveLength(2); + expect(grid.weeks[1][0]?.count).toBe(8); + expect(grid.weeks[1].slice(1)).toEqual([ + null, + null, + null, + null, + null, + null, + ]); + }); + + it('gives the placeholder the same columns as the overview it stands in for', () => { + const placeholder = placeholderOverview(new Date('2026-09-07T12:00:00Z')); + const real = { ...placeholder, days: [{ date: '2026-09-06', count: 90 }] }; + const placeholderGrid = buildContributionGrid(placeholder); + const realGrid = buildContributionGrid(real); + + expect(placeholderGrid.weeks).toHaveLength(realGrid.weeks.length); + expect(realGrid.weeks.at(-1)?.[0]).toMatchObject({ + date: '2026-09-06', + count: 90, + }); }); it('fills missing API dates with zero and derives relative intensity', () => { @@ -102,16 +99,34 @@ describe('buildContributionGrid', () => { ]); }); - it('anchors the current month and each later month to a week column', () => { + it('anchors the first week and each first-of-month to a column', () => { + // Sunday 2026-01-04 opens the window; Feb 1 and Mar 1 are Sundays too. const grid = buildContributionGrid({ - from: '2026-01-30', + from: '2026-01-07', to: '2026-03-03', days: [], }); expect( grid.monthLabels.map(({ label, weekIndex }) => [label, weekIndex]) - ).toEqual([['Feb', 0]]); + ).toEqual([ + ['Jan', 0], + ['Feb', 4], + ['Mar', 8], + ]); + }); + + it('drops the first-week anchor when the next column starts a month', () => { + // Sunday 2026-02-01 sits in the second column of a window opening 2026-01-28. + const grid = buildContributionGrid({ + from: '2026-01-28', + to: '2026-02-20', + days: [], + }); + + expect( + grid.monthLabels.map(({ label, weekIndex }) => [label, weekIndex]) + ).toEqual([['Feb', 1]]); }); it('returns no columns for an invalid or empty window', () => { @@ -125,23 +140,69 @@ describe('buildContributionGrid', () => { }); }); -describe('weeksThatFit', () => { - const column = HEATMAP_CELL_PX + HEATMAP_GAP_PX; +describe('heatmapGeometry', () => { + const columns = 53; - it('counts whole columns, sharing the trailing gap', () => { - expect(weeksThatFit(HEATMAP_CELL_PX)).toBe(1); - expect(weeksThatFit(HEATMAP_CELL_PX - 1)).toBe(0); - expect(weeksThatFit(20 * column - HEATMAP_GAP_PX)).toBe(20); - expect(weeksThatFit(20 * column - HEATMAP_GAP_PX - 1)).toBe(19); - expect(weeksThatFit(1000)).toBeGreaterThanOrEqual(53); + it('takes the full size before measurement', () => { + expect(heatmapGeometry(null, columns)).toEqual({ + cell: HEATMAP_MAX_CELL, + gap: 3, + width: 53 * 12 + 52 * 3, + height: 7 * 12 + 6 * 3, + overflows: false, + }); }); - it('fits nothing before measurement or when hidden', () => { - expect(weeksThatFit(null)).toBe(0); - expect(weeksThatFit(0)).toBe(0); + it('caps the cell at the full size in a wide pane', () => { + const wide = heatmapGeometry(1000, columns); + expect(wide).toMatchObject({ cell: 12, gap: 3, overflows: false }); + expect(wide.width).toBe(792); + expect(wide.height).toBe(102); }); - it('exports a height equal to seven cells and six gaps', () => { - expect(HEATMAP_HEIGHT_PX).toBe(102); + it('shrinks the cell at the wide gap while ten pixels still fit', () => { + // 53 * 11 + 52 * 3 = 739. + expect(heatmapGeometry(740, columns)).toMatchObject({ + cell: 11, + gap: 3, + width: 739, + overflows: false, + }); + }); + + it('tightens the gap once the cell would fall under ten pixels', () => { + // At gap 3, 640 fits a 9px cell; at gap 2 it fits 10, capped to 9: 53 * 9 + 52 * 2 = 581. + expect(heatmapGeometry(640, columns)).toMatchObject({ + cell: 9, + gap: 2, + width: 581, + height: 75, + overflows: false, + }); + }); + + it('stops at the minimum cell and overflows below it', () => { + // 53 * 8 + 52 * 2 = 528. + expect(heatmapGeometry(528, columns)).toMatchObject({ + cell: HEATMAP_MIN_CELL, + gap: 2, + width: 528, + overflows: false, + }); + expect(heatmapGeometry(336, columns)).toMatchObject({ + cell: HEATMAP_MIN_CELL, + gap: 2, + width: 528, + height: 68, + overflows: true, + }); + }); + + it('has no width for an empty grid', () => { + expect(heatmapGeometry(300, 0)).toMatchObject({ + cell: HEATMAP_MAX_CELL, + width: 0, + overflows: false, + }); }); }); diff --git a/apps/web/src/features/activity/core/contribution-grid.ts b/apps/web/src/features/activity/core/contribution-grid.ts index d57fa0a83e2..dd0697fc72e 100644 --- a/apps/web/src/features/activity/core/contribution-grid.ts +++ b/apps/web/src/features/activity/core/contribution-grid.ts @@ -32,23 +32,82 @@ export type ContributionGrid = { monthLabels: ContributionMonthLabel[]; }; -/** Edge of one day cell in the heatmap, in CSS pixels. */ -export const HEATMAP_CELL_PX = 12; -/** Gap between day cells and between week columns, in CSS pixels. */ -export const HEATMAP_GAP_PX = 3; -/** Seven fixed-size cells and their six gaps: the heatmap's constant height. */ -export const HEATMAP_HEIGHT_PX = 7 * HEATMAP_CELL_PX + 6 * HEATMAP_GAP_PX; +/** Pixel geometry of the heatmap for a measured week-area width. */ +export type HeatmapGeometry = { + /** Edge of one day cell. */ + cell: number; + /** Between day cells and between week columns. */ + gap: number; + /** `columns * cell + (columns - 1) * gap`. */ + width: number; + /** `7 * cell + 6 * gap`. */ + height: number; + /** The columns need more than the measured width: the area scrolls sideways. */ + overflows: boolean; +}; + +/** Cell edge when the pane has room. */ +export const HEATMAP_MAX_CELL = 12; +/** Cell edge below which the area scrolls instead of shrinking further. */ +export const HEATMAP_MIN_CELL = 8; + +const WIDE_GAP = 3; +const TIGHT_GAP = 2; +/** Smallest cell that still reads at the wide gap. */ +const WIDE_GAP_MIN_CELL = 10; + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +function fitCell(width: number, columns: number, gap: number): number { + return Math.floor((width - (columns - 1) * gap) / columns); +} + +function geometry( + cell: number, + gap: number, + columns: number, + measuredWidth: number | null +): HeatmapGeometry { + const width = columns * cell + Math.max(0, columns - 1) * gap; + return { + cell, + gap, + width, + height: 7 * cell + 6 * gap, + overflows: measuredWidth !== null && width > measuredWidth, + }; +} /** - * How many fixed-size week columns fit side by side in `width` pixels. - * `null` (not measured yet) and zero (hidden) both read as room for none, - * so an unmeasured heatmap paints no cells rather than a year that - * overflows; the measurement lands before first paint. + * Size the year to the pane. Cells are 12px with 3px gaps when they fit, + * shrink to 10px at that gap, then to 8px at 2px gaps, and below that the + * area scrolls sideways at 8px. Unmeasured (`null`) or empty grids take the + * full size so the first paint has the final shape at a wide pane. */ -export function weeksThatFit(width: number | null): number { - if (width === null || width <= 0) return 0; - return Math.floor( - (width + HEATMAP_GAP_PX) / (HEATMAP_CELL_PX + HEATMAP_GAP_PX) +export function heatmapGeometry( + measuredWidth: number | null, + columns: number +): HeatmapGeometry { + if (measuredWidth === null || columns <= 0) { + return geometry(HEATMAP_MAX_CELL, WIDE_GAP, columns, null); + } + const wide = fitCell(measuredWidth, columns, WIDE_GAP); + if (wide >= WIDE_GAP_MIN_CELL) { + return geometry( + clamp(wide, WIDE_GAP_MIN_CELL, HEATMAP_MAX_CELL), + WIDE_GAP, + columns, + measuredWidth + ); + } + const tight = fitCell(measuredWidth, columns, TIGHT_GAP); + return geometry( + clamp(tight, HEATMAP_MIN_CELL, WIDE_GAP_MIN_CELL - 1), + TIGHT_GAP, + columns, + measuredWidth ); } @@ -60,34 +119,18 @@ function isInWindow(day: Date, from: Date, to: Date): boolean { return !isBefore(day, from) && isBefore(day, to); } -/** The trailing `maxWeeks` columns, or every column when unset. */ -function trailingWeeks( - weeks: ContributionWeek[], - maxWeeks: number | undefined -): ContributionWeek[] { - if (maxWeeks === undefined) return weeks; - const keep = Math.max(0, Math.floor(maxWeeks)); - return keep >= weeks.length ? weeks : weeks.slice(weeks.length - keep); -} - /** - * Sunday-first weeks that sit entirely inside the window. Leading and - * trailing stub columns (days outside `[from, to)`) are omitted, matching - * Cursor's heatmap. Dates stay in UTC so they never pick up a second + * Sunday-first week columns covering the window. The first and last weeks + * are usually partial and stay, like GitHub's board, with the days outside + * `[from, to)` left `null`; dropping them would hide the current week until + * Saturday. Dates stay in UTC so they never pick up a second * viewer-time-zone conversion. - * - * `maxWeeks` keeps only the most recent columns, for a card too narrow to - * show the year. Month labels are computed on the kept columns so the first - * visible week is still anchored. */ -export function buildContributionGrid( - overview: { - from: string; - to: string; - days: Array<{ date: string; count: number }>; - }, - options: { maxWeeks?: number } = {} -): ContributionGrid { +export function buildContributionGrid(overview: { + from: string; + to: string; + days: Array<{ date: string; count: number }>; +}): ContributionGrid { const from = parseOverviewDate(overview.from); const to = parseOverviewDate(overview.to); if (!isValid(from) || !isValid(to) || !isBefore(from, to)) { @@ -96,27 +139,25 @@ export function buildContributionGrid( const counts = new Map(overview.days.map((day) => [day.date, day.count])); const max = Math.max(0, ...overview.days.map((day) => day.count)); - const allWeeks: ContributionWeek[] = []; + const weeks: ContributionWeek[] = []; for (const weekStart of eachWeekOfInterval( { start: from, end: addDays(to, -1) }, { weekStartsOn: 0, in: OVERVIEW_TZ } )) { - const week = eachDayOfInterval( - { start: weekStart, end: addDays(weekStart, 6) }, - { in: OVERVIEW_TZ } - ).map((day): ContributionDay | null => { - if (!isInWindow(day, from, to)) return null; - const date = formatOverviewDate(day); - const count = counts.get(date) ?? 0; - return { date, count, intensity: intensityLevel(count, max) }; - }); - if (week.every((day) => day !== null)) { - allWeeks.push(week); - } + weeks.push( + eachDayOfInterval( + { start: weekStart, end: addDays(weekStart, 6) }, + { in: OVERVIEW_TZ } + ).map((day): ContributionDay | null => { + if (!isInWindow(day, from, to)) return null; + const date = formatOverviewDate(day); + const count = counts.get(date) ?? 0; + return { date, count, intensity: intensityLevel(count, max) }; + }) + ); } - const weeks = trailingWeeks(allWeeks, options.maxWeeks); const monthLabels: ContributionMonthLabel[] = []; for (const [weekIndex, week] of weeks.entries()) { const visibleDays = week.filter( @@ -130,6 +171,9 @@ export function buildContributionGrid( monthLabels.push({ label: labelMonth(labelDay), weekIndex }); } } + if (monthLabels[0]?.weekIndex === 0 && monthLabels[1]?.weekIndex === 1) { + monthLabels.shift(); + } return { weeks, monthLabels }; } From d673d45846e8d8a5b4adb0c88a393fa735929d29 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 04:20:50 +0000 Subject: [PATCH 4/8] fix(activity): keep the panned weeks in view when the heatmap pane resizes, anchoring to the newest week only on entering overflow Co-authored-by: teo --- .../activity/components/action-graph.tsx | 47 +++++++++++++--- .../activity/core/contribution-grid.test.ts | 55 +++++++++++++++++++ .../activity/core/contribution-grid.ts | 33 +++++++++++ 3 files changed, 127 insertions(+), 8 deletions(-) diff --git a/apps/web/src/features/activity/components/action-graph.tsx b/apps/web/src/features/activity/components/action-graph.tsx index 9f75f114a2c..298cfdc873c 100644 --- a/apps/web/src/features/activity/components/action-graph.tsx +++ b/apps/web/src/features/activity/components/action-graph.tsx @@ -6,6 +6,7 @@ import { createMemo, createSignal, For, + on, onCleanup, Show, } from 'solid-js'; @@ -24,6 +25,8 @@ import { type ContributionWeek, type HeatmapGeometry, heatmapGeometry, + scrollLeftAtWeeksFromEnd, + weeksFromEnd, } from '../core/contribution-grid'; import type { ActivityOverview } from '../core/event'; import type { ActivityIntensity } from '../core/intensity'; @@ -180,15 +183,42 @@ function ContributionHeatmap(props: { weekAreaRef: (element: HTMLDivElement) => void; }) { let weekArea: HTMLDivElement | undefined; + // Where the user has panned to, in weeks from the newest week; undefined + // until the area first overflows. Kept in weeks so a resize that changes + // the cell size restores the same weeks rather than the same pixels. + let panned: number | undefined; - createEffect(() => { - if (!props.geometry.overflows || !weekArea) return; - const element = weekArea; - const frame = requestAnimationFrame(() => { - element.scrollLeft = element.scrollWidth; - }); - onCleanup(() => cancelAnimationFrame(frame)); - }); + const rememberPan = () => { + if (weekArea && props.geometry.overflows) { + panned = weeksFromEnd(weekArea, props.geometry); + } + }; + + createEffect( + on( + () => props.geometry, + (geometry) => { + if (!weekArea) return; + if (!geometry.overflows) { + panned = undefined; + return; + } + const element = weekArea; + // Entering overflow opens on the newest week; later geometry changes + // (a pane drag, a rotation) keep the weeks the user was looking at. + const target = panned ?? 0; + const frame = requestAnimationFrame(() => { + element.scrollLeft = scrollLeftAtWeeksFromEnd( + target, + element, + geometry + ); + panned = target; + }); + onCleanup(() => cancelAnimationFrame(frame)); + } + ) + ); return (
diff --git a/apps/web/src/features/activity/core/contribution-grid.test.ts b/apps/web/src/features/activity/core/contribution-grid.test.ts index 05652d3b35c..dd83b78d36a 100644 --- a/apps/web/src/features/activity/core/contribution-grid.test.ts +++ b/apps/web/src/features/activity/core/contribution-grid.test.ts @@ -4,6 +4,8 @@ import { HEATMAP_MAX_CELL, HEATMAP_MIN_CELL, heatmapGeometry, + scrollLeftAtWeeksFromEnd, + weeksFromEnd, } from './contribution-grid'; import { placeholderOverview } from './placeholder-overview'; @@ -206,3 +208,56 @@ describe('heatmapGeometry', () => { }); }); }); + +describe('week-anchored scroll position', () => { + // 53 columns at 8px cells / 2px gaps on a 300px-wide phone area. + const phone = heatmapGeometry(300, 53); + const area = { scrollWidth: phone.width, clientWidth: 300 }; + + it('reads the newest week at the right edge as zero', () => { + const atEnd = area.scrollWidth - area.clientWidth; + expect(weeksFromEnd({ ...area, scrollLeft: atEnd }, phone)).toBe(0); + // Over-scroll past the end still reads as the newest week. + expect(weeksFromEnd({ ...area, scrollLeft: atEnd + 40 }, phone)).toBe(0); + }); + + it('counts panned distance in week columns, not pixels', () => { + const pitch = phone.cell + phone.gap; + const atEnd = area.scrollWidth - area.clientWidth; + expect( + weeksFromEnd({ ...area, scrollLeft: atEnd - 12 * pitch }, phone) + ).toBe(12); + }); + + it('opens on the newest week at zero and clamps at the oldest', () => { + expect(scrollLeftAtWeeksFromEnd(0, area, phone)).toBe( + area.scrollWidth - area.clientWidth + ); + expect(scrollLeftAtWeeksFromEnd(1000, area, phone)).toBe(0); + }); + + it('lands on the same weeks after the pane changes size', () => { + const pitch = phone.cell + phone.gap; + const panned = { + ...area, + scrollLeft: area.scrollWidth - area.clientWidth - 8 * pitch, + }; + const weeks = weeksFromEnd(panned, phone); + expect(weeks).toBe(8); + + const wider = heatmapGeometry(380, 53); + expect(wider.overflows).toBe(true); + const after = { scrollWidth: wider.width, clientWidth: 380 }; + const restored = scrollLeftAtWeeksFromEnd(weeks, after, wider); + expect(weeksFromEnd({ ...after, scrollLeft: restored }, wider)).toBeCloseTo( + 8 + ); + }); + + it('clamps at the oldest week when a wider pane shows more than was hidden', () => { + const oldest = weeksFromEnd({ ...area, scrollLeft: 0 }, phone); + const wider = heatmapGeometry(380, 53); + const after = { scrollWidth: wider.width, clientWidth: 380 }; + expect(scrollLeftAtWeeksFromEnd(oldest, after, wider)).toBe(0); + }); +}); diff --git a/apps/web/src/features/activity/core/contribution-grid.ts b/apps/web/src/features/activity/core/contribution-grid.ts index dd0697fc72e..d7192c4b223 100644 --- a/apps/web/src/features/activity/core/contribution-grid.ts +++ b/apps/web/src/features/activity/core/contribution-grid.ts @@ -111,6 +111,39 @@ export function heatmapGeometry( ); } +/** The scrollable extent of the week area, as the DOM reports it. */ +export type ScrollExtent = { + scrollLeft: number; + scrollWidth: number; + clientWidth: number; +}; + +/** + * How far the week area is panned from the newest week, in week columns + * (0 = the newest week is at the right edge). Measured in weeks rather than + * pixels so the position survives the cells changing size. + */ +export function weeksFromEnd( + area: ScrollExtent, + geometry: HeatmapGeometry +): number { + const pitch = geometry.cell + geometry.gap; + return Math.max( + 0, + (area.scrollWidth - area.clientWidth - area.scrollLeft) / pitch + ); +} + +/** The `scrollLeft` that puts the week area `weeks` columns from the newest week. */ +export function scrollLeftAtWeeksFromEnd( + weeks: number, + area: Pick, + geometry: HeatmapGeometry +): number { + const pitch = geometry.cell + geometry.gap; + return Math.max(0, area.scrollWidth - area.clientWidth - weeks * pitch); +} + function labelMonth(day: ContributionDay): string { return format(parseOverviewDate(day.date), 'MMM', { in: OVERVIEW_TZ }); } From 02a2dd66539a7b92c439d57d564a580d43246176 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 13:55:56 +0000 Subject: [PATCH 5/8] fix(activity): span the card with the heatmap again in wide panes by opening the seams between weeks Co-authored-by: teo --- .../activity/components/action-graph.test.tsx | 18 ++++---- .../activity/components/action-graph.tsx | 12 ++++-- .../activity/core/contribution-grid.test.ts | 41 ++++++++++--------- .../activity/core/contribution-grid.ts | 40 ++++++++++++------ docs/AGENT_GUIDE/surfaces.md | 3 +- 5 files changed, 70 insertions(+), 44 deletions(-) diff --git a/apps/web/src/features/activity/components/action-graph.test.tsx b/apps/web/src/features/activity/components/action-graph.test.tsx index d5dd166aa6e..a1c53860dd4 100644 --- a/apps/web/src/features/activity/components/action-graph.test.tsx +++ b/apps/web/src/features/activity/components/action-graph.test.tsx @@ -56,15 +56,14 @@ describe('ActionGraph', () => { ).toHaveLength(53); }); - it('takes the full cell size in a wide pane', () => { - layout.weekAreaPx = 900; + it('takes the full cell size in a wide pane and spans it with the seams', () => { + layout.weekAreaPx = 1002; const { container } = render(() => ); - expect(heatmapStyle(container).getPropertyValue('--heatmap-cell')).toBe( - '12px' - ); - expect(heatmapStyle(container).getPropertyValue('--heatmap-gap')).toBe( - '3px' - ); + const style = heatmapStyle(container); + expect(style.getPropertyValue('--heatmap-cell')).toBe('14px'); + expect(style.getPropertyValue('--heatmap-gap')).toBe('3px'); + // (1002 - 53 * 14) / 52 = 5px between the week columns. + expect(style.getPropertyValue('--heatmap-column-gap')).toBe('5px'); }); it('shrinks the cells in a narrow pane and keeps every week', () => { @@ -77,6 +76,9 @@ describe('ActionGraph', () => { expect(heatmapStyle(container).getPropertyValue('--heatmap-gap')).toBe( '2px' ); + expect( + heatmapStyle(container).getPropertyValue('--heatmap-column-gap') + ).toBe('2px'); }); it('renders the skeleton with the same columns and no numbers', () => { diff --git a/apps/web/src/features/activity/components/action-graph.tsx b/apps/web/src/features/activity/components/action-graph.tsx index 298cfdc873c..23b6d8cb260 100644 --- a/apps/web/src/features/activity/components/action-graph.tsx +++ b/apps/web/src/features/activity/components/action-graph.tsx @@ -38,6 +38,7 @@ const WEEKDAY_LABELS = ['', 'M', '', 'W', '', 'F', '']; const CELL_CLASS = 'size-(--heatmap-cell)'; const COLUMN_CLASS = 'w-(--heatmap-cell)'; const GAP_CLASS = 'gap-(--heatmap-gap)'; +const COLUMN_GAP_CLASS = 'gap-(--heatmap-column-gap)'; const MONTH_ROW_CLASS = 'mb-1 h-3'; function dateLabel(date: string): string { @@ -72,9 +73,11 @@ function dayStat(date: string | null): string { * the numbers and day cells. Pass a `placeholderOverview` so the geometry * matches the card that replaces it. * - * The whole year is always on the board. Cells shrink with the pane down to - * `HEATMAP_MIN_CELL`, and below that the week area scrolls sideways, opened - * on the newest week. The geometry comes from measuring the week area. + * The whole year is always on the board and spans the card: in a wide pane + * the leftover width opens the seams between weeks, cells shrink with the + * pane down to `HEATMAP_MIN_CELL`, and below that the week area scrolls + * sideways, opened on the newest week. The geometry comes from measuring + * the week area. */ export function ActionGraph(props: { overview: ActivityOverview; @@ -226,6 +229,7 @@ function ContributionHeatmap(props: { style={{ '--heatmap-cell': `${props.geometry.cell}px`, '--heatmap-gap': `${props.geometry.gap}px`, + '--heatmap-column-gap': `${props.geometry.columnGap}px`, }} data-activity-heatmap > @@ -239,7 +243,7 @@ function ContributionHeatmap(props: { onScroll={rememberPan} data-activity-heatmap-weeks > -
+
{(week, index) => ( { expect(heatmapGeometry(null, columns)).toEqual({ cell: HEATMAP_MAX_CELL, gap: 3, - width: 53 * 12 + 52 * 3, - height: 7 * 12 + 6 * 3, + columnGap: 3, + width: 53 * 14 + 52 * 3, + height: 7 * 14 + 6 * 3, overflows: false, }); }); - it('caps the cell at the full size in a wide pane', () => { - const wide = heatmapGeometry(1000, columns); - expect(wide).toMatchObject({ cell: 12, gap: 3, overflows: false }); - expect(wide.width).toBe(792); - expect(wide.height).toBe(102); + it('caps the cell in a wide pane and opens the seams so the year spans it', () => { + const wide = heatmapGeometry(1002, columns); + expect(wide).toMatchObject({ cell: 14, gap: 3, overflows: false }); + // (1002 - 53 * 14) / 52 = 5px between columns; rows keep the 3px gap. + expect(wide.columnGap).toBe(5); + expect(wide.width).toBe(1002); + expect(wide.height).toBe(7 * 14 + 6 * 3); }); - it('shrinks the cell at the wide gap while ten pixels still fit', () => { - // 53 * 11 + 52 * 3 = 739. - expect(heatmapGeometry(740, columns)).toMatchObject({ - cell: 11, - gap: 3, - width: 739, - overflows: false, - }); + it('spreads the rounding remainder so a fitted pane is spanned exactly', () => { + // 53 * 11 + 52 * 3 = 739 fits in 745; the 6px remainder opens the seams. + const fitted = heatmapGeometry(745, columns); + expect(fitted).toMatchObject({ cell: 11, gap: 3, overflows: false }); + expect(fitted.columnGap).toBeCloseTo(3 + 6 / 52); + expect(fitted.width).toBeCloseTo(745); }); it('tightens the gap once the cell would fall under ten pixels', () => { - // At gap 3, 640 fits a 9px cell; at gap 2 it fits 10, capped to 9: 53 * 9 + 52 * 2 = 581. + // At gap 3, 640 fits a 9px cell; at gap 2 it fits 10, capped to 9. expect(heatmapGeometry(640, columns)).toMatchObject({ cell: 9, gap: 2, - width: 581, height: 75, overflows: false, }); @@ -188,12 +188,15 @@ describe('heatmapGeometry', () => { expect(heatmapGeometry(528, columns)).toMatchObject({ cell: HEATMAP_MIN_CELL, gap: 2, + columnGap: 2, width: 528, overflows: false, }); + // Overflowing keeps the natural seams: the area scrolls instead. expect(heatmapGeometry(336, columns)).toMatchObject({ cell: HEATMAP_MIN_CELL, gap: 2, + columnGap: 2, width: 528, height: 68, overflows: true, @@ -222,7 +225,7 @@ describe('week-anchored scroll position', () => { }); it('counts panned distance in week columns, not pixels', () => { - const pitch = phone.cell + phone.gap; + const pitch = phone.cell + phone.columnGap; const atEnd = area.scrollWidth - area.clientWidth; expect( weeksFromEnd({ ...area, scrollLeft: atEnd - 12 * pitch }, phone) @@ -237,7 +240,7 @@ describe('week-anchored scroll position', () => { }); it('lands on the same weeks after the pane changes size', () => { - const pitch = phone.cell + phone.gap; + const pitch = phone.cell + phone.columnGap; const panned = { ...area, scrollLeft: area.scrollWidth - area.clientWidth - 8 * pitch, diff --git a/apps/web/src/features/activity/core/contribution-grid.ts b/apps/web/src/features/activity/core/contribution-grid.ts index d7192c4b223..4d1711565ec 100644 --- a/apps/web/src/features/activity/core/contribution-grid.ts +++ b/apps/web/src/features/activity/core/contribution-grid.ts @@ -36,9 +36,15 @@ export type ContributionGrid = { export type HeatmapGeometry = { /** Edge of one day cell. */ cell: number; - /** Between day cells and between week columns. */ + /** Between the day cells of a week (the row gap). */ gap: number; - /** `columns * cell + (columns - 1) * gap`. */ + /** + * Between week columns. Equal to `gap` unless the pane is wider than the + * year at the largest cell, when the leftover is spread here so the board + * still spans the card. + */ + columnGap: number; + /** `columns * cell + (columns - 1) * columnGap`. */ width: number; /** `7 * cell + 6 * gap`. */ height: number; @@ -47,7 +53,7 @@ export type HeatmapGeometry = { }; /** Cell edge when the pane has room. */ -export const HEATMAP_MAX_CELL = 12; +export const HEATMAP_MAX_CELL = 14; /** Cell edge below which the area scrolls instead of shrinking further. */ export const HEATMAP_MIN_CELL = 8; @@ -70,21 +76,31 @@ function geometry( columns: number, measuredWidth: number | null ): HeatmapGeometry { - const width = columns * cell + Math.max(0, columns - 1) * gap; + const seams = Math.max(0, columns - 1); + const natural = columns * cell + seams * gap; + const overflows = measuredWidth !== null && natural > measuredWidth; + // Leftover width (a wide pane, or rounding the cell down) opens the seams + // between columns so the last week sits at the card's right edge. + const columnGap = + measuredWidth === null || overflows || seams === 0 + ? gap + : (measuredWidth - columns * cell) / seams; return { cell, gap, - width, + columnGap, + width: columns * cell + seams * columnGap, height: 7 * cell + 6 * gap, - overflows: measuredWidth !== null && width > measuredWidth, + overflows, }; } /** - * Size the year to the pane. Cells are 12px with 3px gaps when they fit, - * shrink to 10px at that gap, then to 8px at 2px gaps, and below that the - * area scrolls sideways at 8px. Unmeasured (`null`) or empty grids take the - * full size so the first paint has the final shape at a wide pane. + * Size the year to the pane. Cells are up to 14px at 3px row gaps, with any + * width beyond that spread between the columns so the board spans the card; + * they shrink to 10px at that gap, then to 8px at 2px gaps, and below that + * the area scrolls sideways at 8px. Unmeasured (`null`) or empty grids take + * the full cell so the first paint has the final shape at a wide pane. */ export function heatmapGeometry( measuredWidth: number | null, @@ -127,7 +143,7 @@ export function weeksFromEnd( area: ScrollExtent, geometry: HeatmapGeometry ): number { - const pitch = geometry.cell + geometry.gap; + const pitch = geometry.cell + geometry.columnGap; return Math.max( 0, (area.scrollWidth - area.clientWidth - area.scrollLeft) / pitch @@ -140,7 +156,7 @@ export function scrollLeftAtWeeksFromEnd( area: Pick, geometry: HeatmapGeometry ): number { - const pitch = geometry.cell + geometry.gap; + const pitch = geometry.cell + geometry.columnGap; return Math.max(0, area.scrollWidth - area.clientWidth - weeks * pitch); } diff --git a/docs/AGENT_GUIDE/surfaces.md b/docs/AGENT_GUIDE/surfaces.md index aaaa07603ba..40f37af2a7e 100644 --- a/docs/AGENT_GUIDE/surfaces.md +++ b/docs/AGENT_GUIDE/surfaces.md @@ -135,7 +135,8 @@ button. Once the heatmap card scrolls away, the day header for the topmost visible row stays pinned at the top of the list (`[data-activity-pinned-day]`, a non-interactive copy), so a snapshot taken mid-scroll shows that label twice at most. The heatmap always shows the whole year, including -the partial first and current weeks: its cells shrink from 12px to 8px as the pane narrows, and +the partial first and current weeks, and spans the card at every width: in a wide pane the +space between week columns opens up, its cells shrink from 14px to 8px as the pane narrows, and below that (a phone) the week area scrolls sideways with the month letters, opened on the newest week and with no visible scrollbar. Under ~672px the four stats read as a two-column grid; under ~448px (a phone) the legend drops its `Fewer`/`More` words, each stat stacks its label over its From 73b92731f112d52f4a25e093d0a264c079e4d375 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 14:31:06 +0000 Subject: [PATCH 6/8] test(activity): drop the tests that only restated the code Delete the jsdom wiring test for ActionGraph (it mocked layout to read heatmapGeometry's numbers back out of CSS variables), the geometry cases that recomputed the same arithmetic at another width, the tautological week-pitch case, the clamp duplicate, the placeholder-columns case that placeholder-overview.test already pins, the sub-week window case no overview produces, and the collapseRuns timing budget that measured the CI runner rather than the code. Trim the pinnedDayLabel table to the transitions. The view test no longer fakes getBoundingClientRect: every week renders at any width, so the measured size decides nothing it asserts. Co-authored-by: teo --- .../activity/components/action-graph.test.tsx | 92 ------------------- .../activity/core/collapse-runs.test.ts | 19 ---- .../activity/core/contribution-grid.test.ts | 71 -------------- .../features/activity/core/feed-rows.test.ts | 3 - .../activity/views/my-activity-view.test.tsx | 43 +++------ 5 files changed, 12 insertions(+), 216 deletions(-) delete mode 100644 apps/web/src/features/activity/components/action-graph.test.tsx diff --git a/apps/web/src/features/activity/components/action-graph.test.tsx b/apps/web/src/features/activity/components/action-graph.test.tsx deleted file mode 100644 index a1c53860dd4..00000000000 --- a/apps/web/src/features/activity/components/action-graph.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { cleanup, render } from '@solidjs/testing-library'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { placeholderOverview } from '../core/placeholder-overview'; -import { ActionGraph } from './action-graph'; - -// jsdom has no ResizeObserver and no layout; `weekAreaPx` is what the graph's -// week area measures. -const layout = { weekAreaPx: 0 }; - -beforeEach(() => { - layout.weekAreaPx = 0; - vi.stubGlobal( - 'ResizeObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - } - ); - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( - () => ({ - width: layout.weekAreaPx, - height: 0, - top: 0, - left: 0, - right: layout.weekAreaPx, - bottom: 0, - x: 0, - y: 0, - toJSON: () => ({}), - }) - ); -}); - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -// Monday 2026-09-07: the window opens on a Tuesday and ends mid-week, so the -// year spans 53 columns including both partial weeks. -const overview = placeholderOverview(new Date('2026-09-07T12:00:00Z')); -const days = (root: ParentNode) => - root.querySelectorAll('[data-activity-day]').length; -const heatmapStyle = (root: ParentNode) => - (root.querySelector('[data-activity-heatmap]') as HTMLElement).style; - -describe('ActionGraph', () => { - it('renders every week of the year including the partial ones', () => { - layout.weekAreaPx = 900; - const { container } = render(() => ); - expect(days(container)).toBe(365); - expect( - container.querySelectorAll('[data-activity-heatmap-weeks] > div > div') - ).toHaveLength(53); - }); - - it('takes the full cell size in a wide pane and spans it with the seams', () => { - layout.weekAreaPx = 1002; - const { container } = render(() => ); - const style = heatmapStyle(container); - expect(style.getPropertyValue('--heatmap-cell')).toBe('14px'); - expect(style.getPropertyValue('--heatmap-gap')).toBe('3px'); - // (1002 - 53 * 14) / 52 = 5px between the week columns. - expect(style.getPropertyValue('--heatmap-column-gap')).toBe('5px'); - }); - - it('shrinks the cells in a narrow pane and keeps every week', () => { - layout.weekAreaPx = 336; - const { container } = render(() => ); - expect(days(container)).toBe(365); - expect(heatmapStyle(container).getPropertyValue('--heatmap-cell')).toBe( - '8px' - ); - expect(heatmapStyle(container).getPropertyValue('--heatmap-gap')).toBe( - '2px' - ); - expect( - heatmapStyle(container).getPropertyValue('--heatmap-column-gap') - ).toBe('2px'); - }); - - it('renders the skeleton with the same columns and no numbers', () => { - layout.weekAreaPx = 900; - const { container } = render(() => ( - - )); - expect(days(container)).toBe(365); - expect(container.textContent).not.toContain('('); - }); -}); diff --git a/apps/web/src/features/activity/core/collapse-runs.test.ts b/apps/web/src/features/activity/core/collapse-runs.test.ts index 08b779f4cb8..818359d729e 100644 --- a/apps/web/src/features/activity/core/collapse-runs.test.ts +++ b/apps/web/src/features/activity/core/collapse-runs.test.ts @@ -122,23 +122,4 @@ describe('collapseRuns', () => { event: expect.objectContaining({ id: 'p' }), }); }); - - it('collapses 1,000 events in under 2ms', () => { - const events = Array.from({ length: 1000 }, (_, index) => - event(`evt-${index}`, { entityId: `doc-${Math.floor(index / 5)}` }) - ); - expect(collapseRuns(events)).toHaveLength(200); - - // Shared CI runners inject GC pauses and scheduler hiccups into any single - // sample, so the budget applies to the best of several runs: that still - // fails on an algorithmic regression without failing on a noisy neighbour. - let fastest = Number.POSITIVE_INFINITY; - for (let sample = 0; sample < 20; sample++) { - const started = performance.now(); - collapseRuns(events); - fastest = Math.min(fastest, performance.now() - started); - } - - expect(fastest).toBeLessThan(2); - }); }); diff --git a/apps/web/src/features/activity/core/contribution-grid.test.ts b/apps/web/src/features/activity/core/contribution-grid.test.ts index 4b00cef4260..0a8c49ae0a2 100644 --- a/apps/web/src/features/activity/core/contribution-grid.test.ts +++ b/apps/web/src/features/activity/core/contribution-grid.test.ts @@ -7,16 +7,8 @@ import { scrollLeftAtWeeksFromEnd, weeksFromEnd, } from './contribution-grid'; -import { placeholderOverview } from './placeholder-overview'; describe('buildContributionGrid', () => { - // 2025-09-07 (Sunday) through 2026-09-06: 52 full weeks. - const year = { from: '2025-09-07', to: '2026-09-06', days: [] }; - - it('keeps every column of a whole-week window', () => { - expect(buildContributionGrid(year).weeks).toHaveLength(52); - }); - it('keeps partial first and last weeks with the outside days null', () => { // Wednesday 2026-08-19 through Sunday 2026-08-30 (to is exclusive). const grid = buildContributionGrid({ @@ -51,38 +43,6 @@ describe('buildContributionGrid', () => { expect(grid.weeks[2][0]?.count).toBe(5); }); - it('shows a window shorter than a week as one partial column', () => { - const grid = buildContributionGrid({ - from: '2026-08-19', - to: '2026-08-24', - days: [{ date: '2026-08-23', count: 8 }], - }); - - expect(grid.weeks).toHaveLength(2); - expect(grid.weeks[1][0]?.count).toBe(8); - expect(grid.weeks[1].slice(1)).toEqual([ - null, - null, - null, - null, - null, - null, - ]); - }); - - it('gives the placeholder the same columns as the overview it stands in for', () => { - const placeholder = placeholderOverview(new Date('2026-09-07T12:00:00Z')); - const real = { ...placeholder, days: [{ date: '2026-09-06', count: 90 }] }; - const placeholderGrid = buildContributionGrid(placeholder); - const realGrid = buildContributionGrid(real); - - expect(placeholderGrid.weeks).toHaveLength(realGrid.weeks.length); - expect(realGrid.weeks.at(-1)?.[0]).toMatchObject({ - date: '2026-09-06', - count: 90, - }); - }); - it('fills missing API dates with zero and derives relative intensity', () => { const grid = buildContributionGrid({ from: '2026-08-16', @@ -165,14 +125,6 @@ describe('heatmapGeometry', () => { expect(wide.height).toBe(7 * 14 + 6 * 3); }); - it('spreads the rounding remainder so a fitted pane is spanned exactly', () => { - // 53 * 11 + 52 * 3 = 739 fits in 745; the 6px remainder opens the seams. - const fitted = heatmapGeometry(745, columns); - expect(fitted).toMatchObject({ cell: 11, gap: 3, overflows: false }); - expect(fitted.columnGap).toBeCloseTo(3 + 6 / 52); - expect(fitted.width).toBeCloseTo(745); - }); - it('tightens the gap once the cell would fall under ten pixels', () => { // At gap 3, 640 fits a 9px cell; at gap 2 it fits 10, capped to 9. expect(heatmapGeometry(640, columns)).toMatchObject({ @@ -202,14 +154,6 @@ describe('heatmapGeometry', () => { overflows: true, }); }); - - it('has no width for an empty grid', () => { - expect(heatmapGeometry(300, 0)).toMatchObject({ - cell: HEATMAP_MAX_CELL, - width: 0, - overflows: false, - }); - }); }); describe('week-anchored scroll position', () => { @@ -224,14 +168,6 @@ describe('week-anchored scroll position', () => { expect(weeksFromEnd({ ...area, scrollLeft: atEnd + 40 }, phone)).toBe(0); }); - it('counts panned distance in week columns, not pixels', () => { - const pitch = phone.cell + phone.columnGap; - const atEnd = area.scrollWidth - area.clientWidth; - expect( - weeksFromEnd({ ...area, scrollLeft: atEnd - 12 * pitch }, phone) - ).toBe(12); - }); - it('opens on the newest week at zero and clamps at the oldest', () => { expect(scrollLeftAtWeeksFromEnd(0, area, phone)).toBe( area.scrollWidth - area.clientWidth @@ -256,11 +192,4 @@ describe('week-anchored scroll position', () => { 8 ); }); - - it('clamps at the oldest week when a wider pane shows more than was hidden', () => { - const oldest = weeksFromEnd({ ...area, scrollLeft: 0 }, phone); - const wider = heatmapGeometry(380, 53); - const after = { scrollWidth: wider.width, clientWidth: 380 }; - expect(scrollLeftAtWeeksFromEnd(oldest, after, wider)).toBe(0); - }); }); diff --git a/apps/web/src/features/activity/core/feed-rows.test.ts b/apps/web/src/features/activity/core/feed-rows.test.ts index f240b69041e..730d6d8bb4e 100644 --- a/apps/web/src/features/activity/core/feed-rows.test.ts +++ b/apps/web/src/features/activity/core/feed-rows.test.ts @@ -168,11 +168,8 @@ describe('pinnedDayLabel', () => { // [startIndex, expected] [0, undefined], [1, 'Today'], - [2, 'Today'], [3, 'Today'], [4, 'Yesterday'], - [5, 'Yesterday'], - [6, 'Yesterday'], [99, 'Yesterday'], ])('start index %i -> %s', (startIndex, expected) => { expect(pinnedDayLabel(rows, startIndex)).toBe(expected); diff --git a/apps/web/src/features/activity/views/my-activity-view.test.tsx b/apps/web/src/features/activity/views/my-activity-view.test.tsx index 28f2bade5f3..98b9ae79b01 100644 --- a/apps/web/src/features/activity/views/my-activity-view.test.tsx +++ b/apps/web/src/features/activity/views/my-activity-view.test.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import type { JSX } from 'solid-js'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ActivityContextProvider } from '../context/activity-context'; import { placeholderOverview } from '../core/placeholder-overview'; import { @@ -34,30 +34,16 @@ const virtual = vi.hoisted(() => { }; }); -// jsdom has neither ResizeObserver nor layout. The graph measures its week -// area to decide how many columns fit, so give it a desktop-width answer. -const WEEK_AREA_PX = 900; -beforeEach(() => { - vi.stubGlobal( - 'ResizeObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - } - ); - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ - width: WEEK_AREA_PX, - height: 0, - top: 0, - left: 0, - right: WEEK_AREA_PX, - bottom: 0, - x: 0, - y: 0, - toJSON: () => ({}), - }); -}); +// jsdom has no ResizeObserver; the graph and the mobile insets measure +// themselves with one. +vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } +); vi.mock('virtua/solid', async () => { const { For } = await import('solid-js'); @@ -98,11 +84,7 @@ vi.mock('@service-storage/websocket', () => ({ createWebSocketJob: () => Promise.reject(new Error('no websocket in tests')), })); -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); +afterEach(cleanup); const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -127,7 +109,6 @@ describe('MyActivityView', () => { const skeletonDays = skeleton.querySelectorAll( '[data-activity-day]' ).length; - // 900px of week area fits 60 columns, more than the placeholder year has. expect(skeletonDays).toBeGreaterThan(300); expect(container.textContent).not.toContain('Loading activity overview'); expect(container.textContent).toContain('Loading…'); From 9976356087acde79e6750b122bbb1100f03fb40b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 14:32:29 +0000 Subject: [PATCH 7/8] refactor(activity): inline the heatmap class strings at their elements (FE-30) ast-grep tsx-no-class-string-consts flagged the five hoisted class constants in action-graph.tsx. Each Tailwind literal now sits on the element it styles; the px-not-rem note moves to the style block that sets the variables. Co-authored-by: teo --- .../activity/components/action-graph.tsx | 43 +++++-------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/apps/web/src/features/activity/components/action-graph.tsx b/apps/web/src/features/activity/components/action-graph.tsx index 23b6d8cb260..f43ae90a0b5 100644 --- a/apps/web/src/features/activity/components/action-graph.tsx +++ b/apps/web/src/features/activity/components/action-graph.tsx @@ -33,14 +33,6 @@ import type { ActivityIntensity } from '../core/intensity'; const WEEKDAY_LABELS = ['', 'M', '', 'W', '', 'F', '']; -// Cell geometry arrives as CSS variables from `heatmapGeometry`, in px rather -// than rem so the fit holds when Dynamic Type scales the root font size. -const CELL_CLASS = 'size-(--heatmap-cell)'; -const COLUMN_CLASS = 'w-(--heatmap-cell)'; -const GAP_CLASS = 'gap-(--heatmap-gap)'; -const COLUMN_GAP_CLASS = 'gap-(--heatmap-column-gap)'; -const MONTH_ROW_CLASS = 'mb-1 h-3'; - function dateLabel(date: string): string { return format(parseOverviewDate(date), 'EEE, MMM d, yyyy', { in: OVERVIEW_TZ, @@ -226,6 +218,8 @@ function ContributionHeatmap(props: { return (
-
+
{(week, index) => ( - +
+ {(label) => ( - + {label} )} @@ -287,13 +274,8 @@ function HeatmapWeek(props: { skeleton: boolean; }) { return ( -
- +
+ {props.monthLabel} @@ -306,7 +288,7 @@ function HeatmapWeek(props: { function DaySquare(props: { day: ContributionDay | null; skeleton: boolean }) { const day = props.day; if (!day) { - return ; + return ; } const label = actionLabel(day); @@ -317,17 +299,14 @@ function DaySquare(props: { day: ContributionDay | null; skeleton: boolean }) { } > Date: Wed, 9 Sep 2026 14:34:27 +0000 Subject: [PATCH 8/8] refactor(activity): set the heatmap scroll in the effect instead of a frame later Solid runs the style bindings that apply the new cell variables before the user effect, so the effect already reads the new scroll extents; the requestAnimationFrame hop and its cancel-on-cleanup bought nothing. The live anchoring probe reads the same weeksFromEnd at every step as before. Co-authored-by: teo --- .../activity/components/action-graph.tsx | 49 ++++++------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/apps/web/src/features/activity/components/action-graph.tsx b/apps/web/src/features/activity/components/action-graph.tsx index f43ae90a0b5..a36c17135ef 100644 --- a/apps/web/src/features/activity/components/action-graph.tsx +++ b/apps/web/src/features/activity/components/action-graph.tsx @@ -1,15 +1,7 @@ import { createElementSize } from '@solid-primitives/resize-observer'; import { cn, Layer, Tooltip } from '@ui'; import { format } from 'date-fns'; -import { - createEffect, - createMemo, - createSignal, - For, - on, - onCleanup, - Show, -} from 'solid-js'; +import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'; import { match } from 'ts-pattern'; import { OVERVIEW_TZ, parseOverviewDate } from '../core/activity-dates'; import { @@ -189,31 +181,20 @@ function ContributionHeatmap(props: { } }; - createEffect( - on( - () => props.geometry, - (geometry) => { - if (!weekArea) return; - if (!geometry.overflows) { - panned = undefined; - return; - } - const element = weekArea; - // Entering overflow opens on the newest week; later geometry changes - // (a pane drag, a rotation) keep the weeks the user was looking at. - const target = panned ?? 0; - const frame = requestAnimationFrame(() => { - element.scrollLeft = scrollLeftAtWeeksFromEnd( - target, - element, - geometry - ); - panned = target; - }); - onCleanup(() => cancelAnimationFrame(frame)); - } - ) - ); + // Runs after the style bindings below have applied the new variables, so + // the scroll extents it reads are already the new geometry's. + createEffect(() => { + const geometry = props.geometry; + if (!weekArea) return; + if (!geometry.overflows) { + panned = undefined; + return; + } + // Entering overflow opens on the newest week; later geometry changes + // (a pane drag, a rotation) keep the weeks the user was looking at. + panned ??= 0; + weekArea.scrollLeft = scrollLeftAtWeeksFromEnd(panned, weekArea, geometry); + }); return (