From d5afe620169633c1a9a45d48a9f3cac97cc4bfbd Mon Sep 17 00:00:00 2001 From: Jon Kilroy Date: Fri, 17 Jul 2026 09:00:08 -0500 Subject: [PATCH] feat(schedule): add a single-day view to the Schedule tab (#9058) * feat(schedule): allow 'day' as a persisted time-view mode * feat(schedule): compute single-day range and header for day view * feat(schedule): add day-view toggle and single-day labels Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JzGvAaeT2TrZcEUA6fYPSZ * docs(schedule): note the day view mode in the Schedule wiki * fix(schedule): make the day-view header compact and locale-aware Address review feedback on the single-day view: - Build the day title with a locale-aware Intl skeleton instead of a hardcoded 'EEE, MMM d, yyyy' pattern, which pinned en-US field ordering. - Shrink the title responsively so it never gets ellipsis-clipped: show the full weekday + date + year on roomy widths, and fall back to a compact month + day below TABLET, where the toggle group and nav controls leave too little room. The weekday still shows in the day-column header. - Rename the .day-view-btn test hook to .e2e-day-view-btn per convention. Co-Authored-By: Claude Opus 4.8 * refactor(schedule): rename week-month-* classes to time-view-* The toggle group now has three buttons (Day, Week, Month), so the week-month-selector / week-month-btn names no longer describe it. Rename to time-view-selector / time-view-btn. Pure CSS class rename, no behavior change. Co-Authored-By: Claude Opus 4.8 * refactor(schedule): complete time-view-* rename in zen theme The class rename left zen.css targeting .week-month-*, so its overrides matched nothing. The :not(.active) rule is the load-bearing one: its !important suppresses the toggle's hover highlight, which zen exists to strip. Co-authored-by: Jon Kilroy --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Johannes Millan Co-authored-by: Jon Kilroy --- docs/wiki/4.04-Schedule-View.md | 2 +- e2e/tests/schedule/schedule-day-view.spec.ts | 97 +++++++++++++ src/app/core-ui/layout/layout.service.ts | 2 +- .../schedule/schedule/schedule.component.html | 51 +++++-- .../schedule/schedule/schedule.component.scss | 6 +- .../schedule/schedule.component.spec.ts | 137 +++++++++++++++++- .../schedule/schedule/schedule.component.ts | 24 ++- src/app/t.const.ts | 3 + src/assets/i18n/en.json | 3 + src/assets/themes/zen.css | 8 +- 10 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 e2e/tests/schedule/schedule-day-view.spec.ts diff --git a/docs/wiki/4.04-Schedule-View.md b/docs/wiki/4.04-Schedule-View.md index a96e2785a0..6751d5ebbb 100644 --- a/docs/wiki/4.04-Schedule-View.md +++ b/docs/wiki/4.04-Schedule-View.md @@ -35,7 +35,7 @@ Tasks that run past midnight are continued on the next day, so you see the full ## What You See -The Schedule shows a range of days (the upcoming 30 days). How many days are visible at once depends on your screen size and the view mode (week or month): fewer on narrow screens, more on wide ones. +The Schedule shows a range of days (the upcoming 30 days). How many days are visible at once depends on the view mode and your screen size: the **day** view shows a single day, while the **week** and **month** views show fewer days on narrow screens and more on wide ones. ## Planning Vs Scheduling diff --git a/e2e/tests/schedule/schedule-day-view.spec.ts b/e2e/tests/schedule/schedule-day-view.spec.ts new file mode 100644 index 0000000000..d7a5fdafda --- /dev/null +++ b/e2e/tests/schedule/schedule-day-view.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '../../fixtures/test.fixture'; + +/** Format a Date as the app's day key (YYYY-MM-DD in local time). */ +const dbDate = (d: Date): string => { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; + +// schedule-week draws two [data-day] cells per day (main + end-of-day) and +// schedule-month also uses [data-day], so scope day counting to the main +// week columns only. +const DAY_COL = 'schedule-week .col:not(.end-of-day)[data-day]'; + +const DESKTOP_VIEWPORT = { width: 1280, height: 720 }; + +test.describe('Schedule day view', () => { + test.use({ viewport: DESKTOP_VIEWPORT }); + + test('renders one day, navigates a day at a time, and switches views', async ({ + page, + workViewPage, + }) => { + await workViewPage.waitForTaskList(); + await page.getByRole('menuitem', { name: 'Schedule' }).click(); + + const dayCols = page.locator(DAY_COL); + const dayBtn = page.getByRole('button', { name: 'View Day' }); + const weekBtn = page.getByRole('button', { name: 'View Week' }); + const monthBtn = page.getByRole('button', { name: 'View Month' }); + const nextBtn = page.getByRole('button', { name: 'Next Day' }); + const prevBtn = page.getByRole('button', { name: 'Previous Day' }); + const todayBtn = page.locator('schedule .today-btn'); + // Scope to the header container: task events also render a `.title`, so a + // bare `schedule .title` would be ambiguous once the schedule has events. + const title = page.locator('schedule .schedule-nav-controls .title'); + const scheduleWeek = page.locator('schedule-week'); + const scheduleMonth = page.locator('schedule-month'); + + const today = dbDate(new Date()); + // Calendar-based next day (DST-safe) so it matches the app's setDate(+1) logic. + const tomorrowDate = new Date(); + tomorrowDate.setDate(tomorrowDate.getDate() + 1); + const tomorrow = dbDate(tomorrowDate); + + // --- Day view renders exactly one day (today), single-date header --- + await dayBtn.click(); + await expect(dayCols).toHaveCount(1); + await expect(dayCols.first()).toHaveAttribute('data-day', today); + await expect(dayBtn).toHaveAttribute('aria-pressed', 'true'); + // Header is a single en-GB date ("Fri, 17 Jul 2026"; locale pinned in + // config), not a week range. + await expect(title).toHaveText(/^\w+, \d{1,2} \w+ \d{4}$/); + // Viewing today: cannot go earlier, "today" reset is disabled. + await expect(prevBtn).toBeDisabled(); + await expect(todayBtn).toBeDisabled(); + + // --- Navigation moves exactly one day at a time --- + await nextBtn.click(); + await expect(dayCols).toHaveCount(1); + await expect(dayCols.first()).toHaveAttribute('data-day', tomorrow); + // Off today, both "back to today" affordances become enabled. + await expect(prevBtn).toBeEnabled(); + await expect(todayBtn).toBeEnabled(); + + // Prev steps back to today (snap-to-today) and re-locks. + await prevBtn.click(); + await expect(dayCols.first()).toHaveAttribute('data-day', today); + await expect(prevBtn).toBeDisabled(); + + // The Today button also resets to today after navigating away (distinct + // code path from Prev: goToToday() vs goToPreviousPeriod()). + await nextBtn.click(); + await expect(dayCols.first()).toHaveAttribute('data-day', tomorrow); + await todayBtn.click(); + await expect(dayCols.first()).toHaveAttribute('data-day', today); + await expect(todayBtn).toBeDisabled(); + + // --- Switching views renders the expected grid each time --- + await weekBtn.click(); // day -> week: week grid, more than one day + await expect(weekBtn).toHaveAttribute('aria-pressed', 'true'); + await expect(scheduleWeek).toBeVisible(); + // week shows multiple day columns (retry-safe; not coupled to the exact count) + await expect.poll(() => dayCols.count()).toBeGreaterThan(1); + + await monthBtn.click(); // week -> month: month grid replaces the week grid + await expect(monthBtn).toHaveAttribute('aria-pressed', 'true'); + await expect(scheduleMonth).toBeVisible(); + await expect(scheduleWeek).toHaveCount(0); + + await dayBtn.click(); // month -> day: back to a single day column + await expect(dayBtn).toHaveAttribute('aria-pressed', 'true'); + await expect(scheduleMonth).toHaveCount(0); + await expect(dayCols).toHaveCount(1); + }); +}); diff --git a/src/app/core-ui/layout/layout.service.ts b/src/app/core-ui/layout/layout.service.ts index 3855668be6..4201cef5bb 100644 --- a/src/app/core-ui/layout/layout.service.ts +++ b/src/app/core-ui/layout/layout.service.ts @@ -61,7 +61,7 @@ export class LayoutService { select(selectIsShowIssuePanel), ); - readonly selectedTimeView = signal<'week' | 'month'>('week'); + readonly selectedTimeView = signal<'week' | 'month' | 'day'>('week'); readonly isWorkViewScrolled = signal(false); readonly isShowAddTaskBar = toSignal(this.isShowAddTaskBar$, { initialValue: false }); diff --git a/src/app/features/schedule/schedule/schedule.component.html b/src/app/features/schedule/schedule/schedule.component.html index 795afaafb8..7328b8040f 100644 --- a/src/app/features/schedule/schedule/schedule.component.html +++ b/src/app/features/schedule/schedule/schedule.component.html @@ -1,10 +1,21 @@
-
+
+