diff --git a/src/app/features/work-view/get-later-today-calendar-events.spec.ts b/src/app/features/work-view/get-later-today-calendar-events.spec.ts new file mode 100644 index 0000000000..b4f95559c8 --- /dev/null +++ b/src/app/features/work-view/get-later-today-calendar-events.spec.ts @@ -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 => ({ + 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']); + }); +}); diff --git a/src/app/features/work-view/get-later-today-calendar-events.ts b/src/app/features/work-view/get-later-today-calendar-events.ts new file mode 100644 index 0000000000..8e1fb86493 --- /dev/null +++ b/src/app/features/work-view/get-later-today-calendar-events.ts @@ -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); +}; diff --git a/src/app/features/work-view/work-view.component.html b/src/app/features/work-view/work-view.component.html index 82b9ab13a1..4480b2a4c4 100644 --- a/src/app/features/work-view/work-view.component.html +++ b/src/app/features/work-view/work-view.component.html @@ -278,7 +278,10 @@ } - @if (laterTodayTasks().length > 0 && isOnTodayList()) { + @if ( + (laterTodayTasks().length > 0 || laterTodayCalendarEvents().length > 0) && + isOnTodayList() + ) {
+ @if (laterTodayCalendarEvents().length) { +
+ @for (calEv of laterTodayCalendarEvents(); track calEv.id) { + + } +
+ }
} diff --git a/src/app/features/work-view/work-view.component.scss b/src/app/features/work-view/work-view.component.scss index bf2eed7966..bdd6637bab 100644 --- a/src/app/features/work-view/work-view.component.scss +++ b/src/app/features/work-view/work-view.component.scss @@ -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); diff --git a/src/app/features/work-view/work-view.component.spec.ts b/src/app/features/work-view/work-view.component.spec.ts index f9ca2ab2d8..8a8e58d05e 100644 --- a/src/app/features/work-view/work-view.component.spec.ts +++ b/src/app/features/work-view/work-view.component.spec.ts @@ -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); diff --git a/src/app/features/work-view/work-view.component.ts b/src/app/features/work-view/work-view.component.ts index c6ce5247c6..1dc1283e54 100644 --- a/src/app/features/work-view/work-view.component.ts +++ b/src/app/features/work-view/work-view.component.ts @@ -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(); customizedUndoneTasks = toSignal( this.customizerService.customizeUndoneTasks(this.workContextService.undoneTasks$),