diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift index 22a6e4186..248619ceb 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift @@ -390,18 +390,19 @@ public final class TimeGridView: NSView { palette: (background: NSColor, surface: NSColor, surfaceRaised: NSColor, border: NSColor, text: NSColor, textMuted: NSColor), currentHour: Int ) { - // Web `CalendarTimeColumn`: `top: calc(100% / TIMED_VISIBLE_HOURS - 5px)`. - let labelColumnTop = max(0, hourHeight - 5) - for hour in 0 ..< Int(ceil(state.civilDayHours)) { - let text = hour == 0 ? "" : (labels.indices.contains(hour - 1) ? labels[hour - 1] : "") + // Web `HourLabelColumn` (#4686): one hour slot per label from 1 AM, + // starting at the top of the track, the same 1 AM origin cards, hour + // lines and the now line use (`timedGridTopPx`). The old + // `calc(100% / TIMED_VISIBLE_HOURS - 5px)` offset plus an empty first + // slot drew every label two hours below its events. + for (index, text) in labels.enumerated() { let label = NSTextField(labelWithString: text) label.font = CompassFont.nsFont(size: 10) - let isCurrentHour = hour > 0 && hour == currentHour - label.textColor = isCurrentHour ? themeAccentColor : palette.textMuted + label.textColor = index + 1 == currentHour ? themeAccentColor : palette.textMuted label.alignment = .center label.frame = NSRect( x: xOffset + 4, - y: labelColumnTop + Double(hour) * hourHeight + 4, + y: Double(index) * hourHeight + 1, width: width - 8, height: 14) hourGutterView.addSubview(label) diff --git a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/TimeGridCivilDayTests.swift b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/TimeGridCivilDayTests.swift index 5e69fa098..74fff0d5e 100644 --- a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/TimeGridCivilDayTests.swift +++ b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/TimeGridCivilDayTests.swift @@ -28,6 +28,26 @@ final class TimeGridCivilDayTests: XCTestCase { } } + /// Web #4686: the 5 PM label sits at the top of the 5 PM slot, where a + /// 5 PM event starts (1 AM track origin), not two hours lower. + func testHourLabelsShareTheEventTrackOrigin() throws { + let savedZone = EffectiveTimeZone.identifier + defer { EffectiveTimeZone.identifier = savedZone } + EffectiveTimeZone.identifier = "UTC" + let state = TimeGridState(layoutMode: .day, + scenario: GridLayoutScenario(layoutMode: .day, + referenceNow: GridLayoutSnapshotFixtures.referenceNow, visibleDateKeys: ["2026-05-20"]), + effectiveTimeZone: "UTC") + let view = TimeGridView(state: state, theme: .lightBeach) + view.frame = NSRect(x: 0, y: 0, width: 800, height: 600) + view.layoutSubtreeIfNeeded() + let document = try XCTUnwrap(descendants(view).compactMap { $0 as? NSScrollView }.first?.documentView) + let hourHeight = (document.frame.height - GridTimeConstants.gridPaddingBottom) / state.civilDayHours + let label = try XCTUnwrap(descendants(view).compactMap { $0 as? NSTextField }.first { $0.stringValue == "5 PM" }) + let eventTop = GridTimeConstants.timedGridTopPx(minutesFromCivilMidnight: 17 * 60, hourHeight: hourHeight) + XCTAssertEqual(label.frame.minY, eventTop, accuracy: 3, "5 PM label at \(label.frame.minY), 5 PM event top at \(eventTop)") + } + private func descendants(_ view: NSView) -> [NSView] { view.subviews.flatMap { [$0] + descendants($0) } } diff --git a/docs/features/desktop-web-parity-checklist.md b/docs/features/desktop-web-parity-checklist.md index 56d9068f3..51d670beb 100644 --- a/docs/features/desktop-web-parity-checklist.md +++ b/docs/features/desktop-web-parity-checklist.md @@ -341,6 +341,7 @@ Each PR should link a child issue, include before/after screenshots (web Chromiu | 2026-10-21 | Hold-Mod sidebar chips: month picker and Up next chips render, calendar list chip moved onto the list (compare run 39634795849) | | 2026-10-21 | Per-account page-jump targets (web `calendar-account:` ids) | | 2026-10-21 | Event jump chips use web day-prefix labels (W1, SU2) and digits after a day pick | +| 2026-10-22 | Week/day hour gutter labels use web's 1 AM track origin (#4686); they sat two hours below their events (a 5 PM event drew on the 3 PM row) | | 2026-10-22 | Parity capture draws popover windows (form date picker, time list) over the window render, so those surfaces stop showing a closed field | | 2026-10-22 | macOS capture: register `onboarding-welcome-account-choice` so the welcome account step exports instead of timing out |