feat(work-view): show later-today calendar events as outlines

Surface timed calendar events occurring later today in the Today view's
'Later Today' section, rendered with the planner's calendar-event outline
component (read-only card + context menu). The section now appears when
there are later-today tasks or events, and its count reflects both.

Events are filtered by a pure util (timed events starting between now and
end-of-today, honoring the start-of-next-day offset; all-day excluded),
mirroring selectLaterTodayTasksWithSubTasks' window for tasks.
This commit is contained in:
Johannes Millan 2026-06-23 12:24:44 +02:00
parent 0a6a692187
commit 858b214d8b
6 changed files with 229 additions and 2 deletions

View file

@ -0,0 +1,116 @@
import { getLaterTodayCalendarEvents } from './get-later-today-calendar-events';
import {
ScheduleCalendarMapEntry,
ScheduleFromCalendarEvent,
} from '../schedule/schedule.model';
import { dateStrToUtcDate } from '../../util/date-str-to-utc-date';
import { oneDayInMilliseconds } from '../../util/month-time-conversion';
const TODAY_STR = '2026-06-23';
const hours = (n: number): number => n * 60 * 60 * 1000;
// Mirror the util's own end-of-today computation so assertions stay
// timezone-independent (setHours is local time).
const endOfToday = (): number => {
const d = dateStrToUtcDate(TODAY_STR);
d.setHours(23, 59, 59, 999);
return d.getTime();
};
const ev = (
id: string,
start: number,
overrides: Partial<ScheduleFromCalendarEvent> = {},
): ScheduleFromCalendarEvent => ({
id,
calProviderId: 'cal-1',
title: id,
start,
duration: hours(1),
issueProviderKey: 'ICAL',
...overrides,
});
const entries = (items: ScheduleFromCalendarEvent[]): ScheduleCalendarMapEntry[] => [
{ items },
];
describe('getLaterTodayCalendarEvents', () => {
it('returns [] when todayStr is empty', () => {
const now = endOfToday() - hours(6);
expect(
getLaterTodayCalendarEvents(entries([ev('a', now + hours(1))]), '', 0, now),
).toEqual([]);
});
it('includes only timed events starting between now and end of today', () => {
const now = endOfToday() - hours(6);
const result = getLaterTodayCalendarEvents(
entries([
ev('soon', now + hours(1)),
ev('past', now - hours(1)),
ev('tomorrow', endOfToday() + hours(1)),
]),
TODAY_STR,
0,
now,
);
expect(result.map((e) => e.id)).toEqual(['soon']);
});
it('includes an event starting exactly at now', () => {
const now = endOfToday() - hours(6);
const result = getLaterTodayCalendarEvents(
entries([ev('atNow', now)]),
TODAY_STR,
0,
now,
);
expect(result.map((e) => e.id)).toEqual(['atNow']);
});
it('excludes all-day events', () => {
const now = endOfToday() - hours(6);
const result = getLaterTodayCalendarEvents(
entries([
ev('allDayFlag', now + hours(1), { isAllDay: true }),
ev('fullDayDuration', now + hours(1), { duration: oneDayInMilliseconds }),
ev('timed', now + hours(2)),
]),
TODAY_STR,
0,
now,
);
expect(result.map((e) => e.id)).toEqual(['timed']);
});
it('sorts events by start time and flattens multiple entries', () => {
const now = endOfToday() - hours(6);
const result = getLaterTodayCalendarEvents(
[
{ items: [ev('later', now + hours(3))] },
{ items: [ev('earlier', now + hours(1))] },
],
TODAY_STR,
0,
now,
);
expect(result.map((e) => e.id)).toEqual(['earlier', 'later']);
});
it('extends the window by the start-of-next-day offset', () => {
const now = endOfToday() - hours(6);
const justAfterMidnight = ev('afterMidnight', endOfToday() + hours(1));
expect(
getLaterTodayCalendarEvents(entries([justAfterMidnight]), TODAY_STR, 0, now).length,
).toBe(0);
expect(
getLaterTodayCalendarEvents(
entries([justAfterMidnight]),
TODAY_STR,
hours(3),
now,
).map((e) => e.id),
).toEqual(['afterMidnight']);
});
});

View file

@ -0,0 +1,41 @@
import {
ScheduleCalendarMapEntry,
ScheduleFromCalendarEvent,
} from '../schedule/schedule.model';
import { dateStrToUtcDate } from '../../util/date-str-to-utc-date';
import { oneDayInMilliseconds } from '../../util/month-time-conversion';
/**
* Timed calendar events starting between `now` and the end of `todayStr`
* (honoring the start-of-next-day offset), sorted by start time.
*
* All-day events are excluded "Later Today" is about upcoming timed
* commitments, mirroring selectLaterTodayTasksWithSubTasks' `dueWithTime >= now`
* rule for tasks so events and tasks share the same visibility window.
*/
export const getLaterTodayCalendarEvents = (
calendarEventEntries: ScheduleCalendarMapEntry[],
todayStr: string,
startOfNextDayDiffMs: number,
now: number,
): ScheduleFromCalendarEvent[] => {
if (!todayStr) {
return [];
}
const todayDate = dateStrToUtcDate(todayStr);
todayDate.setHours(23, 59, 59, 999);
const todayEndTime = todayDate.getTime() + startOfNextDayDiffMs;
const events: ScheduleFromCalendarEvent[] = [];
for (const entry of calendarEventEntries) {
for (const calEv of entry.items) {
const isAllDay = calEv.isAllDay === true || calEv.duration >= oneDayInMilliseconds;
if (!isAllDay && calEv.start >= now && calEv.start <= todayEndTime) {
events.push(calEv);
}
}
}
return events.sort((a, b) => a.start - b.start);
};

View file

@ -278,7 +278,10 @@
</section>
}
@if (laterTodayTasks().length > 0 && isOnTodayList()) {
@if (
(laterTodayTasks().length > 0 || laterTodayCalendarEvents().length > 0) &&
isOnTodayList()
) {
<div
@expand
class="collapsible-section"
@ -287,7 +290,7 @@
[title]="
(T.WW.LATER_TODAY | translate) +
' (' +
(laterTodayTasks()?.length || 0) +
(laterTodayTasks().length + laterTodayCalendarEvents().length) +
')'
"
[isExpanded]="!isLaterTodayHidden()"
@ -301,6 +304,15 @@
listId="PARENT"
listModelId="LATER_TODAY"
></task-list>
@if (laterTodayCalendarEvents().length) {
<div class="later-today-cal-events">
@for (calEv of laterTodayCalendarEvents(); track calEv.id) {
<planner-calendar-event
[calendarEvent]="calEv"
></planner-calendar-event>
}
</div>
}
</collapsible>
</div>
}

View file

@ -287,6 +287,15 @@ finish-day-btn,
padding: var(--s-half);
}
// Read-only calendar event outlines below the Later Today task list.
.later-today-cal-events {
display: flex;
flex-direction: column;
gap: var(--s-half);
margin-top: var(--s);
padding: 0 var(--s-half);
}
.no-section,
.section-container {
margin-bottom: var(--s3);

View file

@ -27,6 +27,11 @@ import {
selectTaskRepeatCfgsByProjectId,
selectTaskRepeatCfgsByTagId,
} from '../task-repeat-cfg/store/task-repeat-cfg.selectors';
import {
selectStartOfNextDayDiffMs,
selectTodayStr,
} from '../../root-store/app-state/app-state.selectors';
import { CalendarIntegrationService } from '../calendar-integration/calendar-integration.service';
import { TODAY_TAG } from '../tag/tag.const';
/**
@ -140,6 +145,10 @@ describe('WorkViewComponent', () => {
},
},
{ provide: SnackService, useValue: { open: () => {} } },
{
provide: CalendarIntegrationService,
useValue: { calendarEvents$: of([]) },
},
{
provide: GlobalConfigService,
useValue: {
@ -163,6 +172,8 @@ describe('WorkViewComponent', () => {
store.overrideSelector(selectLaterTodayTasksWithSubTasks, []);
store.overrideSelector(selectTaskRepeatCfgsByProjectId, []);
store.overrideSelector(selectTaskRepeatCfgsByTagId, []);
store.overrideSelector(selectTodayStr, '2026-06-23');
store.overrideSelector(selectStartOfNextDayDiffMs, 0);
});
it('deselects when the task is absent from every list', async () => {
@ -350,6 +361,10 @@ describe('WorkViewComponent', () => {
},
},
{ provide: SnackService, useValue: { open: () => {} } },
{
provide: CalendarIntegrationService,
useValue: { calendarEvents$: of([]) },
},
{
provide: GlobalConfigService,
useValue: {
@ -368,6 +383,8 @@ describe('WorkViewComponent', () => {
store.overrideSelector(selectLaterTodayTasksWithSubTasks, []);
store.overrideSelector(selectTaskRepeatCfgsByProjectId, []);
store.overrideSelector(selectTaskRepeatCfgsByTagId, []);
store.overrideSelector(selectTodayStr, '2026-06-23');
store.overrideSelector(selectStartOfNextDayDiffMs, 0);
await TestBed.compileComponents();
const fixture = TestBed.createComponent(WorkViewComponent);

View file

@ -70,6 +70,14 @@ import {
selectLaterTodayTasksWithSubTasks,
selectOverdueTasksWithSubTasks,
} from '../tasks/store/task.selectors';
import {
selectStartOfNextDayDiffMs,
selectTodayStr,
} from '../../root-store/app-state/app-state.selectors';
import { CalendarIntegrationService } from '../calendar-integration/calendar-integration.service';
import { PlannerCalendarEventComponent } from '../planner/planner-calendar-event/planner-calendar-event.component';
import { ScheduleCalendarMapEntry } from '../schedule/schedule.model';
import { getLaterTodayCalendarEvents } from './get-later-today-calendar-events';
import { CollapsibleComponent } from '../../ui/collapsible/collapsible.component';
import { SnackService } from '../../core/snack/snack.service';
import { GlobalConfigService } from '../config/global-config.service';
@ -132,6 +140,7 @@ const INITIAL_CUSTOMIZED_UNDONE_TASKS: CustomizedUndoneTasks = { list: [] };
RepeatCfgPreviewComponent,
PluginIndexComponent,
PlainspaceClaimPoolComponent,
PlannerCalendarEventComponent,
],
})
export class WorkViewComponent implements OnInit, OnDestroy {
@ -154,6 +163,7 @@ export class WorkViewComponent implements OnInit, OnDestroy {
private _destroyRef = inject(DestroyRef);
private _dateService = inject(DateService);
private _pluginBridge = inject(PluginBridgeService);
private _calendarIntegrationService = inject(CalendarIntegrationService);
protected readonly dragDelayForTouch = dragDelayForTouch;
isProjectContext = toSignal(this.workContextService.isActiveWorkContextProject$, {
@ -199,6 +209,28 @@ export class WorkViewComponent implements OnInit, OnDestroy {
laterTodayTasks = toSignal(this._store.select(selectLaterTodayTasksWithSubTasks), {
initialValue: [],
});
// Calendar events are not in the store — sourced live (cached + polled,
// shareReplay/refCount) from the calendar integration. Shown as read-only
// outlines in the "Later Today" section, mirroring the planner.
private _calendarEventEntries = toSignal(
this._calendarIntegrationService.calendarEvents$,
{ initialValue: [] as ScheduleCalendarMapEntry[] },
);
private _todayStr = toSignal(this._store.select(selectTodayStr), {
initialValue: '',
});
private _startOfNextDayDiffMs = toSignal(
this._store.select(selectStartOfNextDayDiffMs),
{ initialValue: 0 },
);
laterTodayCalendarEvents = computed(() =>
getLaterTodayCalendarEvents(
this._calendarEventEntries(),
this._todayStr(),
this._startOfNextDayDiffMs(),
Date.now(),
),
);
undoneTasks = input.required<TaskWithSubTasks[]>();
customizedUndoneTasks = toSignal(
this.customizerService.customizeUndoneTasks(this.workContextService.undoneTasks$),