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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <jkusa@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Co-authored-by: Jon Kilroy <jkusa@users.noreply.github.com>
This commit is contained in:
Jon Kilroy 2026-07-17 09:00:08 -05:00 committed by GitHub
parent 5097155e25
commit d5afe62016
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 309 additions and 24 deletions

View file

@ -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

View file

@ -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);
});
});

View file

@ -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<boolean>(false);
readonly isShowAddTaskBar = toSignal(this.isShowAddTaskBar$, { initialValue: false });

View file

@ -1,10 +1,21 @@
<div class="schedule-nav-controls">
<div class="week-month-selector">
<div class="time-view-selector">
<button
mat-icon-button
class="week-month-btn"
[class.active]="!isMonthView()"
[attr.aria-pressed]="!isMonthView()"
class="time-view-btn e2e-day-view-btn"
[class.active]="isDayView()"
[attr.aria-pressed]="isDayView()"
(click)="selectTimeView('day')"
[matTooltip]="T.F.SCHEDULE.VIEW_DAY | translate"
[attr.aria-label]="T.F.SCHEDULE.VIEW_DAY | translate"
>
<mat-icon>calendar_view_day</mat-icon>
</button>
<button
mat-icon-button
class="time-view-btn"
[class.active]="isWeekView()"
[attr.aria-pressed]="isWeekView()"
(click)="selectTimeView('week')"
[matTooltip]="T.F.SCHEDULE.VIEW_WEEK | translate"
[attr.aria-label]="T.F.SCHEDULE.VIEW_WEEK | translate"
@ -13,7 +24,7 @@
</button>
<button
mat-icon-button
class="week-month-btn"
class="time-view-btn"
[class.active]="isMonthView()"
[attr.aria-pressed]="isMonthView()"
(click)="selectTimeView('month')"
@ -68,12 +79,20 @@
(click)="goToPreviousPeriod()"
[disabled]="isViewingToday()"
[attr.aria-label]="
(isMonthView() ? T.F.SCHEDULE.PREVIOUS_MONTH : T.F.SCHEDULE.PREVIOUS_WEEK)
| translate
(isMonthView()
? T.F.SCHEDULE.PREVIOUS_MONTH
: isDayView()
? T.F.SCHEDULE.PREVIOUS_DAY
: T.F.SCHEDULE.PREVIOUS_WEEK
) | translate
"
[matTooltip]="
(isMonthView() ? T.F.SCHEDULE.PREVIOUS_MONTH : T.F.SCHEDULE.PREVIOUS_WEEK)
| translate
(isMonthView()
? T.F.SCHEDULE.PREVIOUS_MONTH
: isDayView()
? T.F.SCHEDULE.PREVIOUS_DAY
: T.F.SCHEDULE.PREVIOUS_WEEK
) | translate
"
>
<mat-icon>chevron_left</mat-icon>
@ -94,10 +113,20 @@
mat-icon-button
(click)="goToNextPeriod()"
[attr.aria-label]="
(isMonthView() ? T.F.SCHEDULE.NEXT_MONTH : T.F.SCHEDULE.NEXT_WEEK) | translate
(isMonthView()
? T.F.SCHEDULE.NEXT_MONTH
: isDayView()
? T.F.SCHEDULE.NEXT_DAY
: T.F.SCHEDULE.NEXT_WEEK
) | translate
"
[matTooltip]="
(isMonthView() ? T.F.SCHEDULE.NEXT_MONTH : T.F.SCHEDULE.NEXT_WEEK) | translate
(isMonthView()
? T.F.SCHEDULE.NEXT_MONTH
: isDayView()
? T.F.SCHEDULE.NEXT_DAY
: T.F.SCHEDULE.NEXT_WEEK
) | translate
"
>
<mat-icon>chevron_right</mat-icon>

View file

@ -85,7 +85,7 @@
align-items: center;
}
.week-month-selector {
.time-view-selector {
display: flex;
margin-left: var(--s);
border-radius: var(--card-border-radius);
@ -94,7 +94,7 @@
background: var(--bg-lighter);
}
.week-month-btn {
.time-view-btn {
border: none;
border-radius: 0;
transition: var(--transition-standard);
@ -111,7 +111,7 @@
color: white;
}
&:first-child {
&:not(:last-child) {
border-right: 1px solid var(--extra-border-color);
}
}

View file

@ -92,7 +92,9 @@ describe('ScheduleComponent', () => {
);
mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], {
localization: signal({ firstDayOfWeek: 1 }),
// Pin the date locale so Intl-formatted headers are deterministic across
// runners (an en-GB runner would render "20 Jan", not "Jan 20").
localization: signal({ firstDayOfWeek: 1, dateTimeLocale: 'en-US' }),
cfg: signal(undefined),
});
@ -274,6 +276,22 @@ describe('ScheduleComponent', () => {
});
describe('goToPreviousPeriod', () => {
it('should go back exactly one day in day view', () => {
mockLayoutService.selectedTimeView.set('day');
mockScheduleService.getDaysToShow.and.returnValue(['2027-06-15']);
component['_selectedDate'].set(new Date(2027, 5, 15)); // Jun 15, 2027 (future → no snap-to-today)
fixture.detectChanges();
expect(component.daysToShow().length).toBe(1);
component.goToPreviousPeriod();
const d = component['_selectedDate']();
expect(d?.getFullYear()).toBe(2027);
expect(d?.getMonth()).toBe(5);
expect(d?.getDate()).toBe(14); // back by exactly one day
expect(d?.getHours()).toBe(0); // normalized to midnight
});
it('should navigate backward by the number of days currently shown', () => {
// Arrange - view a future range that doesn't contain today
mockScheduleService.getDaysToShow.and.returnValue([
@ -343,6 +361,22 @@ describe('ScheduleComponent', () => {
});
describe('goToNextPeriod', () => {
it('should advance exactly one day in day view', () => {
mockLayoutService.selectedTimeView.set('day');
mockScheduleService.getDaysToShow.and.returnValue(['2027-06-15']);
component['_selectedDate'].set(new Date(2027, 5, 15)); // Jun 15, 2027
fixture.detectChanges();
expect(component.daysToShow().length).toBe(1);
component.goToNextPeriod();
const d = component['_selectedDate']();
expect(d?.getFullYear()).toBe(2027);
expect(d?.getMonth()).toBe(5);
expect(d?.getDate()).toBe(16); // advanced by exactly one day
expect(d?.getHours()).toBe(0); // normalized to midnight
});
it('should navigate forward by the number of days currently shown', () => {
// Arrange
const startDate = new Date(2026, 0, 20); // Jan 20, 2026
@ -786,6 +820,12 @@ describe('ScheduleComponent', () => {
});
describe('shouldEnableHorizontalScroll computed', () => {
it('should return false in day view', () => {
mockLayoutService.selectedTimeView.set('day');
fixture.detectChanges();
expect(component.shouldEnableHorizontalScroll()).toBe(false);
});
it('should return false in month view regardless of window size', () => {
// Arrange
mockLayoutService.selectedTimeView.set('month');
@ -915,4 +955,99 @@ describe('ScheduleComponent', () => {
expect(weeks).toBe(2);
});
});
describe('day view persistence', () => {
afterEach(() => localStorage.removeItem('SELECTED_TIME_VIEW'));
it('persists the day view and reads it back', () => {
component.selectTimeView('day');
expect(mockLayoutService.selectedTimeView()).toBe('day');
expect(localStorage.getItem('SELECTED_TIME_VIEW')).toBe('day');
// getTimeView is private; cast to reach it
expect((component as any).getTimeView()).toBe('day');
});
it('reads back month, and defaults to week for absent or unknown values', () => {
localStorage.setItem('SELECTED_TIME_VIEW', 'month');
expect((component as any).getTimeView()).toBe('month');
localStorage.removeItem('SELECTED_TIME_VIEW');
expect((component as any).getTimeView()).toBe('week');
localStorage.setItem('SELECTED_TIME_VIEW', 'not-a-view');
expect((component as any).getTimeView()).toBe('week');
});
});
describe('day view mode logic', () => {
it('shows exactly one day in day mode', () => {
mockScheduleService.getDaysToShow.and.returnValue(['2026-01-20']);
mockLayoutService.selectedTimeView.set('day');
fixture.detectChanges();
expect((component as any)._daysToShowCount()).toBe(1);
// Verify the count is actually wired into the day range (the mock returns
// a fixed 1-element array regardless of args, so length alone is not proof).
expect(mockScheduleService.getDaysToShow).toHaveBeenCalledWith(1, null);
expect(component.daysToShow().length).toBe(1);
expect(component.isDayView()).toBe(true);
expect(component.isMonthView()).toBe(false);
});
it('renders the full single-date header when roomy', () => {
// Force the roomy (non-tablet) state so the full form is deterministic
// regardless of the test runner's window width.
component['_isTablet'] = signal(false);
mockScheduleService.getDaysToShow.and.returnValue(['2026-01-20']);
mockLayoutService.selectedTimeView.set('day');
fixture.detectChanges();
// 2026-01-20 is a Tuesday (en-US locale pinned in beforeEach).
expect(component.headerTitle()).toBe('Tue, Jan 20, 2026');
});
it('compacts the day header to month and day when tight', () => {
component['_isTablet'] = signal(true);
mockScheduleService.getDaysToShow.and.returnValue(['2026-01-20']);
mockLayoutService.selectedTimeView.set('day');
fixture.detectChanges();
// Compact form is month + day only (no weekday, no year).
expect(component.headerTitle()).toBe('Jan 20');
});
it('exposes mutually exclusive view-mode flags', () => {
mockLayoutService.selectedTimeView.set('week');
fixture.detectChanges();
expect(component.isWeekView()).toBe(true);
expect(component.isDayView()).toBe(false);
expect(component.isMonthView()).toBe(false);
mockLayoutService.selectedTimeView.set('day');
fixture.detectChanges();
expect(component.isDayView()).toBe(true);
expect(component.isWeekView()).toBe(false);
expect(component.isMonthView()).toBe(false);
});
});
describe('day view toggle rendering', () => {
afterEach(() => localStorage.removeItem('SELECTED_TIME_VIEW'));
it('renders a schedule-week (not schedule-month) with one day when in day view', () => {
mockScheduleService.getDaysToShow.and.returnValue(['2026-01-20']);
mockLayoutService.selectedTimeView.set('day');
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector('schedule-week')).toBeTruthy();
expect(el.querySelector('schedule-month')).toBeFalsy();
});
it('has a day-view toggle button that selects day mode', () => {
const el: HTMLElement = fixture.nativeElement;
const dayBtn = el.querySelector<HTMLButtonElement>(
'.time-view-btn.e2e-day-view-btn',
);
expect(dayBtn).toBeTruthy();
dayBtn!.click();
expect(mockLayoutService.selectedTimeView()).toBe('day');
});
});
});

View file

@ -103,6 +103,8 @@ export class ScheduleComponent {
private _currentTimeViewMode = computed(() => this.layoutService.selectedTimeView());
isMonthView = computed(() => this._currentTimeViewMode() === 'month');
isDayView = computed(() => this._currentTimeViewMode() === 'day');
isWeekView = computed(() => this._currentTimeViewMode() === 'week');
// Navigation state - null = viewing today, Date = viewing selected date
private _selectedDate = signal<Date | null>(null);
@ -141,6 +143,8 @@ export class ScheduleComponent {
const width = size.width;
const height = size.height;
if (selectedView === 'day') return 1;
if (selectedView === 'month') {
const availableHeight = height - SCHEDULE_CONSTANTS.MONTH_VIEW.HEADER_OFFSET;
const minHeightPerWeek =
@ -189,12 +193,24 @@ export class ScheduleComponent {
private _isVeryCompact = computed(
() => this._windowSize().width < SCHEDULE_CONSTANTS.BREAKPOINTS.XXS,
);
private _isTablet = computed(
() => this._windowSize().width < SCHEDULE_CONSTANTS.BREAKPOINTS.TABLET,
);
headerTitle = computed(() => {
const days = this.daysToShow();
if (!days.length) return '';
const locale = this._dateTimeFormatService.currentLocale();
if (this.isDayView()) {
// On tablet width and below the full date clips, so drop to month + day
// (the weekday still shows in the day-column header).
const dayOpts: Intl.DateTimeFormatOptions = this._isTablet()
? { month: 'short', day: 'numeric' }
: { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' };
return new Intl.DateTimeFormat(locale, dayOpts).format(parseDbDateStr(days[0]));
}
if (this.isMonthView()) {
const mid = parseDbDateStr(days[Math.floor(days.length / 2)]);
return safeFormatDate(mid, 'LLLL yyyy', locale);
@ -404,14 +420,16 @@ export class ScheduleComponent {
});
}
selectTimeView(view: 'week' | 'month'): void {
selectTimeView(view: 'week' | 'month' | 'day'): void {
this.layoutService.selectedTimeView.set(view);
localStorage.setItem(LS.SELECTED_TIME_VIEW, view);
}
private getTimeView(): 'week' | 'month' {
private getTimeView(): 'week' | 'month' | 'day' {
const preservedView = localStorage.getItem(LS.SELECTED_TIME_VIEW);
return preservedView === 'month' ? 'month' : 'week';
if (preservedView === 'month') return 'month';
if (preservedView === 'day') return 'day';
return 'week';
}
constructor() {

View file

@ -1161,11 +1161,14 @@ const T = {
INSERT_BEFORE: 'F.SCHEDULE.INSERT_BEFORE',
LUNCH_BREAK: 'F.SCHEDULE.LUNCH_BREAK',
MONTH: 'F.SCHEDULE.MONTH',
NEXT_DAY: 'F.SCHEDULE.NEXT_DAY',
NEXT_MONTH: 'F.SCHEDULE.NEXT_MONTH',
NEXT_WEEK: 'F.SCHEDULE.NEXT_WEEK',
PREVIOUS_DAY: 'F.SCHEDULE.PREVIOUS_DAY',
PREVIOUS_MONTH: 'F.SCHEDULE.PREVIOUS_MONTH',
PREVIOUS_WEEK: 'F.SCHEDULE.PREVIOUS_WEEK',
PLAN_TASK_FOR_DAY_PLACEHOLDER: 'F.SCHEDULE.PLAN_TASK_FOR_DAY_PLACEHOLDER',
VIEW_DAY: 'F.SCHEDULE.VIEW_DAY',
VIEW_MONTH: 'F.SCHEDULE.VIEW_MONTH',
VIEW_WEEK: 'F.SCHEDULE.VIEW_WEEK',
NO_TASKS: 'F.SCHEDULE.NO_TASKS',

View file

@ -1139,11 +1139,14 @@
"INSERT_BEFORE": "Before",
"LUNCH_BREAK": "Lunch Break",
"MONTH": "Month",
"NEXT_DAY": "Next Day",
"NEXT_MONTH": "Next Month",
"NEXT_WEEK": "Next Week",
"PREVIOUS_DAY": "Previous Day",
"PREVIOUS_MONTH": "Previous Month",
"PREVIOUS_WEEK": "Previous Week",
"PLAN_TASK_FOR_DAY_PLACEHOLDER": "Plan task for day...",
"VIEW_DAY": "View Day",
"VIEW_MONTH": "View Month",
"VIEW_WEEK": "View Week",
"NO_TASKS": "Currently there are no tasks. Please add some tasks via the plus (+) button in the top bar.",

View file

@ -257,17 +257,17 @@ body.isDarkTheme.hasBgImage schedule {
--bg-lighter: rgba(19, 19, 20, 0.85);
}
/* Flatten week/month selector in main header */
body .week-month-selector {
/* Flatten day/week/month selector in main header */
body .time-view-selector {
box-shadow: none !important;
border: 1px solid rgba(128, 128, 128, 0.2);
}
body.isDarkTheme .week-month-selector {
body.isDarkTheme .time-view-selector {
border-color: rgba(255, 255, 255, 0.1);
}
body .week-month-btn:not(.active) {
body .time-view-btn:not(.active) {
background: transparent !important;
}