feat(schedule): move calendar visibility into menu, responsive header

- Replace the calendar provider chip listbox below the nav row with a
  mat-icon-button + mat-menu placed next to the week/month switcher.
  Each provider is a checkable menu item (role="menuitemcheckbox" +
  aria-checked) that keeps the menu open across toggles.
- Make the right-side header label responsive in three tiers so it
  stops getting ellipsised on narrow viewports:
    >= 600px (xs)  : `Week 17 · Apr 20 – 26`
    >= 420px (xxs) : `W17 · Apr 20 – 26`
    <  420px       : `W17`
  Same-month ranges are collapsed (`Apr 20 – 26` instead of
  `Apr 20 – Apr 26`).
- Memoize the breakpoint booleans as their own computeds so headerTitle
  re-formats only when a tier boundary is crossed, not on every
  debounced resize tick. The thresholds live in
  SCHEDULE_CONSTANTS.BREAKPOINTS.XS / XXS; XS mirrors `$layout-xs`.
This commit is contained in:
Johannes Millan 2026-05-07 23:01:59 +02:00
parent bea2c18fd1
commit fff1ee1596
7 changed files with 91 additions and 67 deletions

View file

@ -18,8 +18,12 @@ export const SCHEDULE_CONSTANTS = {
BREAKPOINTS: {
/** Width threshold for tablet devices (768px) */
TABLET: 768,
/** Width threshold below which the schedule header switches to its compact form. Mirrors `$layout-xs` in `_media-queries.scss`. */
XS: 600,
/** Width threshold for mobile devices (480px) */
MOBILE: 480,
/** Width threshold below which the schedule header drops the date range and shows only the week number. */
XXS: 420,
},
/**

View file

@ -22,6 +22,44 @@
</button>
</div>
@if (showCalFilterBtn()) {
<button
mat-icon-button
[matMenuTriggerFor]="calFilterMenu"
[matTooltip]="T.F.SCHEDULE.CALENDAR_VISIBILITY | translate"
[attr.aria-label]="T.F.SCHEDULE.CALENDAR_VISIBILITY | translate"
>
<mat-icon>event_note</mat-icon>
</button>
<mat-menu #calFilterMenu="matMenu">
<ng-template matMenuContent>
<div (click)="$event.stopPropagation()">
@for (provider of enabledCalendarProviders(); track provider.id) {
<button
mat-menu-item
role="menuitemcheckbox"
[attr.aria-checked]="!hiddenCalendarProviderIds().includes(provider.id)"
(click)="toggleCalProvider(provider.id)"
>
@if (hiddenCalendarProviderIds().includes(provider.id)) {
<mat-icon>check_box_outline_blank</mat-icon>
} @else {
<mat-icon>check_box</mat-icon>
}
@if (provider.color) {
<span
class="cal-color-dot"
[style.background]="provider.color"
></span>
}
{{ calProviderLabel(provider) }}
</button>
}
</div>
</ng-template>
</mat-menu>
}
<div class="date-nav-group">
<button
mat-icon-button
@ -67,31 +105,6 @@
<div class="title">{{ headerTitle() }}</div>
</div>
@if (showCalFilterBar()) {
<mat-chip-listbox
class="cal-filter-bar"
[multiple]="true"
[attr.aria-label]="T.F.SCHEDULE.CALENDAR_VISIBILITY | translate"
(change)="onCalProvidersChange($event)"
>
@for (provider of enabledCalendarProviders(); track provider.id) {
<mat-chip-option
[value]="provider.id"
[selected]="!hiddenCalendarProviderIds().includes(provider.id)"
[matTooltip]="calProviderLabel(provider)"
>
@if (provider.color) {
<span
class="cal-color-dot"
[style.background]="provider.color"
></span>
}
{{ calProviderInitials(provider) }}
</mat-chip-option>
}
</mat-chip-listbox>
}
<div
class="scroll-wrapper"
[attr.data-horizontal-scroll]="shouldEnableHorizontalScroll() || null"

View file

@ -117,19 +117,14 @@
}
}
.cal-filter-bar {
padding: var(--s-half) var(--s);
@include extraBorder('-bottom');
.cal-color-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.cal-color-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.main-controls {

View file

@ -700,7 +700,7 @@ describe('ScheduleComponent', () => {
});
});
describe('showCalFilterBar computed', () => {
describe('showCalFilterBtn computed', () => {
const makeProvider = (id: string): any => ({
id,
isEnabled: true,
@ -725,7 +725,7 @@ describe('ScheduleComponent', () => {
store.overrideSelector(selectCalendarProviders, []);
store.refreshState();
fixture.detectChanges();
expect(component.showCalFilterBar()).toBe(false);
expect(component.showCalFilterBtn()).toBe(false);
});
it('should be false with a single visible provider', () => {
@ -733,7 +733,7 @@ describe('ScheduleComponent', () => {
store.overrideSelector(selectCalendarProviders, [makeProvider('only')]);
store.refreshState();
fixture.detectChanges();
expect(component.showCalFilterBar()).toBe(false);
expect(component.showCalFilterBtn()).toBe(false);
});
it('should be true when the only enabled provider is hidden (C2 regression)', () => {
@ -743,7 +743,7 @@ describe('ScheduleComponent', () => {
const hidden = TestBed.inject(HiddenCalendarProvidersService);
hidden.setHidden(['only']);
fixture.detectChanges();
expect(component.showCalFilterBar()).toBe(true);
expect(component.showCalFilterBtn()).toBe(true);
});
it('should be true with multiple enabled providers', () => {
@ -754,7 +754,7 @@ describe('ScheduleComponent', () => {
]);
store.refreshState();
fixture.detectChanges();
expect(component.showCalFilterBar()).toBe(true);
expect(component.showCalFilterBtn()).toBe(true);
});
});

View file

@ -11,16 +11,14 @@ import { fromEvent } from 'rxjs';
import { select, Store } from '@ngrx/store';
import { selectCalendarProviders } from '../../issue/store/issue-provider.selectors';
import { HiddenCalendarProvidersService } from '../../calendar-integration/hidden-calendar-providers.service';
import {
getIssueProviderInitials,
getIssueProviderTooltip,
} from '../../issue/mapping-helper/get-issue-provider-tooltip';
import { getIssueProviderTooltip } from '../../issue/mapping-helper/get-issue-provider-tooltip';
import { IssueProvider } from '../../issue/issue.model';
import {
MatChipListbox,
MatChipListboxChange,
MatChipOption,
} from '@angular/material/chips';
MatMenu,
MatMenuContent,
MatMenuItem,
MatMenuTrigger,
} from '@angular/material/menu';
import { debounceTime, map, startWith } from 'rxjs/operators';
import { safeFormatDate } from '../../../util/safe-format-date';
import { TaskService } from '../../tasks/task.service';
@ -55,8 +53,10 @@ import { parseDbDateStr } from '../../../util/parse-db-date-str';
MatIcon,
MatTooltip,
TranslatePipe,
MatChipListbox,
MatChipOption,
MatMenu,
MatMenuContent,
MatMenuItem,
MatMenuTrigger,
],
templateUrl: './schedule.component.html',
styleUrls: ['./schedule.component.scss'],
@ -86,26 +86,19 @@ export class ScheduleComponent {
.pipe(map((ps) => ps.filter((p) => p.isEnabled))),
{ initialValue: [] },
);
// Show the bar with multiple providers, OR with a single provider that is
// currently hidden — otherwise the user has no UI to re-enable the only
// Show the button with multiple providers, OR with a single provider that
// is currently hidden — otherwise the user has no UI to re-enable the only
// calendar after, e.g., deleting all but one provider in settings.
readonly showCalFilterBar = computed(() => {
readonly showCalFilterBtn = computed(() => {
const providers = this.enabledCalendarProviders();
if (providers.length > 1) return true;
const hidden = this.hiddenCalendarProviderIds();
return providers.some((p) => hidden.includes(p.id));
});
readonly calProviderLabel = (p: IssueProvider): string => getIssueProviderTooltip(p);
readonly calProviderInitials = (p: IssueProvider): string =>
getIssueProviderInitials(p) ??
getIssueProviderTooltip(p).substring(0, 2).toUpperCase();
onCalProvidersChange(ev: MatChipListboxChange): void {
const visible = new Set<string>((ev.value as string[]) ?? []);
const hidden = this.enabledCalendarProviders()
.map((p) => p.id)
.filter((id) => !visible.has(id));
this._hiddenCalendarProviders.setHidden(hidden);
toggleCalProvider(providerId: string): void {
this._hiddenCalendarProviders.toggle(providerId);
}
private _currentTimeViewMode = computed(() => this.layoutService.selectedTimeView());
@ -188,6 +181,15 @@ export class ScheduleComponent {
weeksToShow = computed(() => Math.ceil(this.daysToShow().length / 7));
// Memoized so headerTitle only re-runs at the breakpoint boundary,
// not on every debounced resize tick.
private _isCompact = computed(
() => this._windowSize().width < SCHEDULE_CONSTANTS.BREAKPOINTS.XS,
);
private _isVeryCompact = computed(
() => this._windowSize().width < SCHEDULE_CONSTANTS.BREAKPOINTS.XXS,
);
headerTitle = computed(() => {
const days = this.daysToShow();
if (!days.length) return '';
@ -201,9 +203,17 @@ export class ScheduleComponent {
const start = parseDbDateStr(days[0]);
const end = parseDbDateStr(days[days.length - 1]);
const weekNr = getWeekNumber(start); // ISO — default firstDayOfWeek=1
const sameMonth =
start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
const startStr = safeFormatDate(start, 'MMM d', locale);
const endStr = safeFormatDate(end, 'MMM d', locale);
const label = this._translate.instant(T.F.WORKLOG.CMP.WEEK_NR, { nr: weekNr });
const endStr = sameMonth
? safeFormatDate(end, 'd', locale)
: safeFormatDate(end, 'MMM d', locale);
const labelKey = this._isCompact()
? T.F.WORKLOG.CMP.WEEK_NR_SHORT
: T.F.WORKLOG.CMP.WEEK_NR;
const label = this._translate.instant(labelKey, { nr: weekNr });
if (this._isVeryCompact()) return label;
return `${label} · ${startStr} ${endStr}`;
});

View file

@ -1984,6 +1984,7 @@ const T = {
TOTAL_TIME: 'F.WORKLOG.CMP.TOTAL_TIME',
VIEW_TASK_DETAILS: 'F.WORKLOG.CMP.VIEW_TASK_DETAILS',
WEEK_NR: 'F.WORKLOG.CMP.WEEK_NR',
WEEK_NR_SHORT: 'F.WORKLOG.CMP.WEEK_NR_SHORT',
WORKED: 'F.WORKLOG.CMP.WORKED',
},
D_CONFIRM_RESTORE: 'F.WORKLOG.D_CONFIRM_RESTORE',

View file

@ -1940,6 +1940,7 @@
"TOTAL_TIME": "Time spent total:",
"VIEW_TASK_DETAILS": "View task details",
"WEEK_NR": "Week {{nr}}",
"WEEK_NR_SHORT": "W{{nr}}",
"WORKED": "Worked"
},
"D_CONFIRM_RESTORE": "Are you sure you want to move the task <strong>\"{{title}}\"</strong> into your Today task list?",