Skip to content

Commit 3a888fc

Browse files
Uarmaganclaude
andauthored
fix(web): align week header and grid columns (#1692)
* fix(web): align week headers with grid * test(web): cover week header grid alignment Adds a 1728x1426 regression case that asserts day headers, all-day columns, and timed columns share the same x/right/width within 1px. Documents the column contract and would catch a future regression (e.g. reintroducing the CSS Grid + Flex distribution mismatch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web): unify week grid columns * fix(web): join week grid divider lines * fix(web): remove week grid scrollbar gutter * fix(web): keep week drag drafts on selected day * fix(web): initialize current week widths * fix(web): return current week widths directly * fix(web): validate grid element lookup * test(web): simplify week grid alignment coverage * refactor(web): tighten week grid alignment code * fix(web): keep week grid scrollbar hidden * feat(web): add subtle week grid scroll cue * Remove week calendar padding and horizontal scroll indicator * Adjust week e2e tests for grid alignment * Restore right padding for day view header --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 01a7b55 commit 3a888fc

28 files changed

Lines changed: 514 additions & 322 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { expect, type Page, test } from "@playwright/test";
2+
3+
const layoutWidths = [900, 1728];
4+
const maxLayoutDelta = 1;
5+
const daysInWeek = 7;
6+
7+
test.describe("Week view layout", () => {
8+
for (const width of layoutWidths) {
9+
test(`aligns day headers with calendar columns at ${width}px`, async ({
10+
page,
11+
}) => {
12+
await page.setViewportSize({ width, height: 1000 });
13+
await page.goto("/week");
14+
await page.locator("#allDayColumns").waitFor();
15+
await page.locator("#timedColumns").waitFor();
16+
17+
const layout = await getWeekColumnLayout(page);
18+
const mainGridScrollbarWidth = await page
19+
.locator("#mainGrid")
20+
.evaluate(
21+
(node) => getComputedStyle(node, "::-webkit-scrollbar").width,
22+
);
23+
const horizontalScrollState = await getHorizontalScrollState(page);
24+
25+
expect(layout.allDayColumns).toHaveLength(daysInWeek);
26+
expect(layout.dayLabels).toHaveLength(daysInWeek);
27+
expect(layout.timedColumns).toHaveLength(daysInWeek);
28+
expect(mainGridScrollbarWidth).toBe("0px");
29+
expect(horizontalScrollState.scrollbarHeight).toBe("0px");
30+
if (width === 900) {
31+
expect(horizontalScrollState.isScrollable).toBe(true);
32+
await expectWeekGridCanScrollHorizontally(page);
33+
} else {
34+
expect(horizontalScrollState.isScrollable).toBe(false);
35+
}
36+
37+
for (const [index, dayLabel] of layout.dayLabels.entries()) {
38+
expectColumnsToAlign(dayLabel, layout.allDayColumns[index]);
39+
expectColumnsToAlign(dayLabel, layout.timedColumns[index]);
40+
}
41+
});
42+
}
43+
});
44+
45+
const getWeekColumnLayout = async (page: Page) =>
46+
page.evaluate((daysInView) => {
47+
const roundRect = (rect: DOMRect) => ({
48+
right: Math.round(rect.right * 100) / 100,
49+
width: Math.round(rect.width * 100) / 100,
50+
x: Math.round(rect.x * 100) / 100,
51+
});
52+
53+
const dayLabels = [
54+
...document.querySelectorAll("#weekGridScroller [title]"),
55+
]
56+
.filter((node): node is HTMLElement => node instanceof HTMLElement)
57+
.slice(0, daysInView)
58+
.map((node) => roundRect(node.getBoundingClientRect()));
59+
60+
const getColumns = (selector: string) =>
61+
[...document.querySelectorAll(selector)]
62+
.filter((node): node is HTMLElement => node instanceof HTMLElement)
63+
.map((node) => {
64+
const rect = node.getBoundingClientRect();
65+
66+
return {
67+
...roundRect(rect),
68+
height: rect.height,
69+
};
70+
})
71+
.filter((rect) => rect.height > 20)
72+
.slice(0, daysInView);
73+
74+
return {
75+
allDayColumns: getColumns("#allDayColumns > div"),
76+
dayLabels,
77+
timedColumns: getColumns("#timedColumns > div"),
78+
};
79+
}, daysInWeek);
80+
81+
const getHorizontalScrollState = async (page: Page) =>
82+
page.locator("#weekGridScroller").evaluate((node) => {
83+
return {
84+
isScrollable: node.scrollWidth > node.clientWidth,
85+
scrollbarHeight: getComputedStyle(node, "::-webkit-scrollbar").height,
86+
};
87+
});
88+
89+
const expectWeekGridCanScrollHorizontally = async (page: Page) => {
90+
const scrollLeft = await page
91+
.locator("#weekGridScroller")
92+
.evaluate((node) => {
93+
node.scrollLeft = node.scrollWidth;
94+
return node.scrollLeft;
95+
});
96+
97+
expect(scrollLeft).toBeGreaterThan(0);
98+
};
99+
100+
const expectColumnsToAlign = (
101+
dayLabel: { right: number; width: number; x: number },
102+
gridColumn: { right: number; width: number; x: number },
103+
) => {
104+
expect(Math.abs(dayLabel.x - gridColumn.x)).toBeLessThanOrEqual(
105+
maxLayoutDelta,
106+
);
107+
expect(Math.abs(dayLabel.right - gridColumn.right)).toBeLessThanOrEqual(
108+
maxLayoutDelta,
109+
);
110+
expect(Math.abs(dayLabel.width - gridColumn.width)).toBeLessThanOrEqual(
111+
maxLayoutDelta,
112+
);
113+
};

e2e/timed/create-event-mouse.spec.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { test } from "@playwright/test";
1+
import { expect, test } from "@playwright/test";
22
import {
33
createEventTitle,
4+
ensureSidebarOpen,
45
expectTimedEventVisible,
56
fillTitleAndSaveEventForm,
67
openTimedEventFormWithMouse,
@@ -23,3 +24,46 @@ test("should create a timed event using mouse interaction", async ({
2324

2425
await expectTimedEventVisible(page, title);
2526
});
27+
28+
test("starts the timed draft in the day column under the pointer after horizontal scroll", async ({
29+
page,
30+
}) => {
31+
await page.setViewportSize({ width: 900, height: 1000 });
32+
await prepareCalendarPage(page);
33+
await ensureSidebarOpen(page);
34+
await page.locator("#weekGridScroller").evaluate((node) => {
35+
node.scrollLeft = node.scrollWidth;
36+
});
37+
38+
const targetDayLabel = page.locator("#weekGridScroller [title]").nth(6);
39+
const mainGrid = page.locator("#mainGrid");
40+
const targetBox = await targetDayLabel.boundingBox();
41+
const gridBox = await mainGrid.boundingBox();
42+
43+
if (!targetBox || !gridBox) {
44+
throw new Error("Expected the week grid and Friday label to be visible.");
45+
}
46+
47+
const x = targetBox.x + targetBox.width / 2;
48+
const y = gridBox.y + gridBox.height * 0.4;
49+
50+
await page.mouse.move(x, y);
51+
await page.mouse.down();
52+
await page.mouse.move(x, y + 80);
53+
54+
const draftEvent = page.locator(
55+
'#timedEvents > [role="button"]:not([data-event-id])',
56+
);
57+
await expect(draftEvent).toBeVisible();
58+
59+
const draftBox = await draftEvent.boundingBox();
60+
if (!draftBox) {
61+
throw new Error("Expected the timed draft to be visible.");
62+
}
63+
64+
const draftCenterX = draftBox.x + draftBox.width / 2;
65+
expect(draftCenterX).toBeGreaterThanOrEqual(targetBox.x);
66+
expect(draftCenterX).toBeLessThanOrEqual(targetBox.x + targetBox.width);
67+
68+
await page.mouse.up();
69+
});

packages/web/src/__tests__/utils/state/draft.test.util.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { useToday } from "@web/views/Calendar/hooks/useToday";
1313
import { useWeek } from "@web/views/Calendar/hooks/useWeek";
1414

1515
export function setupDraftState(event: Schema_WebEvent) {
16-
const isSidebarOpen = true;
1716
const draft = assembleGridEvent(event);
1817

1918
const state = {
@@ -32,10 +31,9 @@ export function setupDraftState(event: Schema_WebEvent) {
3231

3332
const weekHook = renderHook(() => useWeek(useToday().today), { state });
3433
const weekProps = weekHook.result.current;
35-
const { week } = weekProps.component;
3634
const dispatch = renderHook(useDispatch, { state }).result.current;
3735

38-
const gridHook = renderHook(() => useGridLayout(isSidebarOpen, week), {
36+
const gridHook = renderHook(() => useGridLayout(), {
3937
state,
4038
});
4139

@@ -67,7 +65,6 @@ export function setupDraftState(event: Schema_WebEvent) {
6765
setters,
6866
dateCalcs,
6967
weekProps,
70-
isSidebarOpen,
7168
),
7269
{ state },
7370
);
@@ -82,7 +79,6 @@ export function setupDraftState(event: Schema_WebEvent) {
8279
dateCalcs,
8380
deleteEvent,
8481
dispatch,
85-
isSidebarOpen,
8682
submit,
8783
draft,
8884
rerenderActions: actions.rerender,

packages/web/src/common/constants/web.constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const ID_EVENT_FORM = "Event Form";
99
export const ID_GRID_ALLDAY_ROW = "allDayRow";
1010
export const ID_GRID_EVENTS_ALLDAY = "allDayEvents";
1111
export const ID_GRID_EVENTS_TIMED = "timedEvents";
12+
export const ID_GRID_COLUMNS_TIMED = "timedColumns";
13+
export const ID_WEEK_GRID_SCROLLER = "weekGridScroller";
1214
export const ID_SOMEDAY_WEEK_COLUMN = "somedayWeekColumn";
1315
export const ID_GRID_MAIN = "mainGrid";
1416
export const ID_REMINDER_INPUT = "reminderInput";

packages/web/src/common/utils/draft/draft.util.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from "@web/common/constants/web.constants";
99
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
1010
import { assembleDefaultEvent } from "@web/common/utils/event/event.util";
11-
import { getElemById, getX } from "@web/common/utils/grid/grid.util";
11+
import { getElemById } from "@web/common/utils/grid/grid.util";
1212
import { roundToNext } from "@web/common/utils/round/round.util";
1313
import { draftSlice } from "@web/ducks/events/slices/draft.slice";
1414
import { type Activity_DraftEvent } from "@web/ducks/events/slices/draft.slice.types";
@@ -21,11 +21,9 @@ import {
2121
export const assembleAlldayDraft = async (
2222
e: MouseEvent,
2323
dateCalcs: DateCalcs,
24-
isSidebarOpen: boolean,
2524
startOfView: Dayjs,
2625
): Promise<Schema_GridEvent> => {
27-
const x = getX(e, isSidebarOpen);
28-
const _start = dateCalcs.getDateByXY(x, e.clientY, startOfView);
26+
const _start = dateCalcs.getDateByXY(e.clientX, e.clientY, startOfView);
2927
const startDate = _start.format();
3028
const endDate = _start.add(1, "day").format();
3129

@@ -40,11 +38,9 @@ export const assembleAlldayDraft = async (
4038
export const assembleTimedDraft = async (
4139
e: MouseEvent,
4240
dateCalcs: DateCalcs,
43-
isSidebarOpen: boolean,
4441
startOfView: Dayjs,
4542
): Promise<Schema_GridEvent> => {
46-
const x = getX(e, isSidebarOpen);
47-
const _start = dateCalcs.getDateByXY(x, e.clientY, startOfView);
43+
const _start = dateCalcs.getDateByXY(e.clientX, e.clientY, startOfView);
4844
const startDate = _start.format();
4945
const endDate = _start.add(DRAFT_DURATION_MIN, "minutes").format();
5046

0 commit comments

Comments
 (0)