mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 08:56:41 +00:00
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:
parent
bea2c18fd1
commit
fff1ee1596
7 changed files with 91 additions and 67 deletions
|
|
@ -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,
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue