mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
0a6a692187
commit
858b214d8b
6 changed files with 229 additions and 2 deletions
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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$),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue