mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Feat/start of next day (#6565)
* feat: add label to heatmap for repeat task * style(e2e): fix prettier formatting in planner spec * fix(focus-mode): clear stale _isResumingBreak flag when isPauseTrackingDuringBreak is enabled (#6534) When both isSyncSessionWithTracking and isPauseTrackingDuringBreak are enabled, pausing and resuming a break left _isResumingBreak stale, causing the next manual tracking start to dispatch clearResumingBreakFlag instead of skipBreak. Refactor syncSessionResumeToTracking$ to explicitly dispatch clearResumingBreakFlag in this case. * refactor(sync): unify JWT expiry to 365 days for all auth methods Replace separate JWT_EXPIRY_MAGIC_LINK (365d) and JWT_EXPIRY_PASSKEY (7d) constants with a single JWT_EXPIRY (365d). The auth method only matters during login — once a JWT is issued, it represents a verified session regardless of how the user authenticated. * feat(start-of-next-day): respect startOfNextDayDiff offset in today view Thread startOfNextDayDiffMs through AppState, selectors, meta-reducers, and task component so that "today" membership correctly accounts for the user's configured day-start offset. When startOfNextDay=4 (4 AM), at 2:30 AM the app now correctly treats the previous calendar day as "today". - Add isTodayWithOffset utility for offset-aware date comparison - Store startOfNextDayDiffMs in AppState alongside todayStr - Update all selectors (work-context, planner, task, overdue) to use offset - Update meta-reducers with defensive fallbacks for state access - Fix task component computed signals (isOverdue, isScheduledToday, etc.) - Add 48 new tests covering offset boundary conditions * style(planner): remove unused eslint-disable directive * fix(start-of-next-day): use offset-aware date in ensureTasksDueTodayInTodayTag effect Replace raw getDbDateStr() and getDateRangeForDay(Date.now()) with store-derived todayStr and offset-adjusted range in task-due.effects.ts. Without this, the effect would use the wrong day between midnight and the configured startOfNextDay hour. Also add selectOverdueTasks offset boundary tests. * refactor(start-of-next-day): use DateService for offset-aware today checks Add DateService.isToday() method that encapsulates the startOfNextDayDiff offset logic, replacing scattered isToday()/getDbDateStr() calls across effects, services, and components. - Add isToday(date) to DateService for DRY offset-aware checks - Fix task-repeat-cfg.effects.ts: 6 isToday/getDbDateStr calls - Fix task-repeat-cfg.service.ts: isToday call - Fix task-context-menu-inner.component.ts: 6 isToday/getDbDateStr calls - Fix task-related-model.effects.ts: getDbDateStr call - Fix work-context.service.ts: getDbDateStr call - Remove duplicate planTaskForDay handler from tag.reducer.ts (already handled by planner-shared meta-reducer with offset) * refactor(start-of-next-day): migrate remaining isToday() calls to offset-aware DateService Replace 6 call sites still using the non-offset isToday()/isYesterday() with DateService methods that respect startOfNextDayDiff. Also adds isYesterday() to DateService and uses isTodayWithOffset in legacy backup migration. Behavior is identical at offset=0 (default). * fix(start-of-next-day): fix offset bugs, sync regression, and code quality issues - Fix wrong config path in legacy backup migration (misc.startOfNextDay) - Restore sync readiness check (filter+first instead of take(1)) - Restore SYNC_AFTER_ENABLE in setInitialSyncDone conditions - Use offset-aware dates in addAllDueToday/addAllDueTomorrow - Make isSameDay offset-aware and pass offset through planner selectors - Fix TagSettingsPageComponent selector from 'project-settings' to 'tag-settings' - Hide settings link for virtual TODAY tag - Revert direct ru.json edits (only en.json should be edited) - Add standalone:true and use takeUntilDestroyed in settings components - Restore Math.max(duration,1) for zero-duration overlap detection - Remove dead code, stale CSS, and commented-out HTML - Add input validation clamping in DateService.setStartOfNextDayDiff * fix(start-of-next-day): fix offset bugs in overdue detection, planner display, and LWW sync - Fix isOverdue ignoring offset for dueWithTime tasks in task.component - Remove duplicate moveBeforeTask handler from tag.reducer (handled by meta-reducer) - Add skip(1) and hydration guard to setTodayStr$ effect to prevent race condition - Move side effects from map() to tap() in global-config.effects - Fix isSameDay double-offset bug in planner.selectors for scheduled tasks/events - Replace unsafe `as any` casts with proper PlannerState types - Use safe optional chaining for todayStr access in meta-reducers - Refactor handlePlanTaskForDay to use helper functions with hasChanges optimization - Extend syncTodayTagTaskIds in LWW meta-reducer to handle dueWithTime changes - Fix absolute import path in global-config.effects - Add @deprecated to isToday() in favor of offset-aware alternatives * fix(config): migrate task dueDays when startOfNextDay offset changes When the "start of next day" offset changes and causes todayStr to shift, existing tasks with dueDay matching the old todayStr are now migrated to the new todayStr so they remain classified as "today" tasks. * refactor(config): use switchMap and document archive task exclusion Replace mergeMap with switchMap in setStartOfNextDayDiffOnChange effect to better communicate intent (only latest emission matters). Add comment clarifying archived tasks are intentionally excluded from dueDay migration. * test(sync): add LWW tests for dueWithTime → TODAY_TAG sync Cover the dueWithTime path in syncTodayTagTaskIds that was added but had no test coverage. Tests verify TODAY_TAG membership updates when dueWithTime changes via LWW sync.
This commit is contained in:
parent
60e9b27c1b
commit
03572c3f2c
51 changed files with 2076 additions and 728 deletions
|
|
@ -39,11 +39,13 @@ The app supports **11 issue-style integrations** (the exact list may vary by ver
|
|||
**Calendar (iCal)** and **CalDAV** are both calendar-related but **use different protocols and data**:
|
||||
|
||||
**Calendar (iCal)** — Uses a **subscription URL** to fetch an **iCal feed** (e.g. `.ics`). The app parses **VEVENT** components only (events with start/end time). Events appear in the Schedule/Planner; the event description becomes the task note.
|
||||
|
||||
- **No login** is required: you only paste the calendar URL (e.g. a “subscription” or “export” link).
|
||||
- The app does **not** sync completion or changes back to the server; if you change the event in the app, the external calendar is not updated.
|
||||
- In **Nextcloud**, use the **“Copy subscription link”** (or equivalent) so the URL is in the form expected for iCal (e.g. `https://<NC-URL>/remote.php/dav/public-calendars/<CALENDAR-ID>?export`). Do **not** use the “Copy private link” here—that format is for CalDAV.
|
||||
|
||||
**CalDAV** — Connects to a **CalDAV server** (e.g. Nextcloud calendars) with **username and password**.
|
||||
|
||||
- The app requests **VTODO** components only (tasks/todos). It does **not** use VEVENTs.
|
||||
- You get task import and **completion status can sync back** to the server.
|
||||
- In **Nextcloud**, use the **“Copy private link”** (or the CalDAV URL for your user/calendar), e.g. `https://<NC-URL>/remote.php/dav/calendars/<USER>/<CALENDAR_NAME>/`. Do **not** use the subscription/export link here—that is for the iCal integration.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { ProjectService } from '../../features/project/project.service';
|
|||
import { Router } from '@angular/router';
|
||||
import { Task } from '../../features/tasks/task.model';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { isToday } from '../../util/is-today.util';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { TODAY_TAG } from '../../features/tag/tag.const';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { T } from '../../t.const';
|
||||
|
|
@ -21,6 +21,7 @@ export class NavigateToTaskService {
|
|||
private _projectService = inject(ProjectService);
|
||||
private _router = inject(Router);
|
||||
private _snackService = inject(SnackService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
async navigate(taskId: string, isArchiveTask: boolean = false): Promise<void> {
|
||||
try {
|
||||
|
|
@ -81,9 +82,9 @@ export class NavigateToTaskService {
|
|||
|
||||
private _isDueToday(task: Task): boolean {
|
||||
if (task.dueWithTime) {
|
||||
return isToday(task.dueWithTime);
|
||||
return this._dateService.isToday(task.dueWithTime);
|
||||
}
|
||||
return task.dueDay === getDbDateStr();
|
||||
return task.dueDay === this._dateService.todayStr();
|
||||
}
|
||||
|
||||
private _focusTaskElement(taskId: string): void {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@ export class DateService {
|
|||
startOfNextDayDiff: number = 0;
|
||||
|
||||
setStartOfNextDayDiff(startOfNextDay: number): void {
|
||||
this.startOfNextDayDiff = (startOfNextDay || 0) * 60 * 60 * 1000;
|
||||
const clamped = Math.max(0, Math.min(23, startOfNextDay || 0));
|
||||
this.startOfNextDayDiff = clamped * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns today's date string with offset applied.
|
||||
* NOTE: When a date argument is provided, the offset is NOT applied to it —
|
||||
* the caller is responsible for adjusting the date if needed.
|
||||
*/
|
||||
todayStr(date?: Date | number): string {
|
||||
if (!date) {
|
||||
date = new Date(Date.now() - this.startOfNextDayDiff);
|
||||
|
|
@ -17,13 +23,16 @@ export class DateService {
|
|||
}
|
||||
|
||||
isToday(date: number | Date): boolean {
|
||||
const timestamp = typeof date === 'number' ? date : date.getTime();
|
||||
const d = new Date(timestamp - this.startOfNextDayDiff);
|
||||
const today = new Date(Date.now() - this.startOfNextDayDiff);
|
||||
const ts = typeof date === 'number' ? date : date.getTime();
|
||||
return getDbDateStr(new Date(ts - this.startOfNextDayDiff)) === this.todayStr();
|
||||
}
|
||||
|
||||
isYesterday(date: number | Date): boolean {
|
||||
const ts = typeof date === 'number' ? date : date.getTime();
|
||||
const yesterday = new Date(Date.now() - this.startOfNextDayDiff);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return (
|
||||
d.getDate() === today.getDate() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getFullYear() === today.getFullYear()
|
||||
getDbDateStr(new Date(ts - this.startOfNextDayDiff)) === getDbDateStr(yesterday)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { getDateRangeForDay } from '../../util/get-date-range-for-day';
|
|||
import { first, map, switchMap, withLatestFrom } from 'rxjs/operators';
|
||||
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
|
||||
import { selectTodayTaskIds } from '../work-context/store/work-context.selectors';
|
||||
import { selectTasksForPlannerDay } from '../planner/store/planner.selectors';
|
||||
|
|
@ -26,6 +27,7 @@ export class AddTasksForTomorrowService {
|
|||
private _store = inject(Store);
|
||||
private _taskRepeatCfgService = inject(TaskRepeatCfgService);
|
||||
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
private _tomorrowDate$ = this._globalTrackingIntervalService.todayDateStr$.pipe(
|
||||
map((todayStr) => {
|
||||
|
|
@ -69,8 +71,8 @@ export class AddTasksForTomorrowService {
|
|||
async addAllDueTomorrow(): Promise<'ADDED' | void> {
|
||||
const dueRepeatCfgs = await this._repeatableForTomorrow$.pipe(first()).toPromise();
|
||||
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
const tomorrow = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const tomorrow = Date.now() - this._dateService.startOfNextDayDiff + ONE_DAY_MS;
|
||||
|
||||
const promises = dueRepeatCfgs.sort(sortRepeatableTaskCfgs).map((repeatCfg) => {
|
||||
return this._taskRepeatCfgService.createRepeatableTask(repeatCfg, tomorrow);
|
||||
|
|
@ -134,10 +136,9 @@ export class AddTasksForTomorrowService {
|
|||
|
||||
// NOTE: this gets a lot of interference from tagEffect.preventParentAndSubTaskInTodayList$:
|
||||
async addAllDueToday(): Promise<'ADDED' | void> {
|
||||
const todayDate = new Date();
|
||||
// Use current timestamp for today
|
||||
const todayTS = Date.now();
|
||||
const todayStr = getDbDateStr();
|
||||
const todayDate = new Date(Date.now() - this._dateService.startOfNextDayDiff);
|
||||
const todayTS = todayDate.getTime();
|
||||
const todayStr = this._dateService.todayStr();
|
||||
|
||||
TaskLog.log('[AddTasksForTomorrow] Starting addAllDueToday', { todayStr });
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { distinctUntilChangedObject } from '../../../util/distinct-until-changed
|
|||
import { selectCalendarProviders } from '../../issue/store/issue-provider.selectors';
|
||||
import { IssueProviderCalendar } from '../../issue/issue.model';
|
||||
import { IssueService } from '../../issue/issue.service';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { Log } from '../../../core/log';
|
||||
import {
|
||||
|
|
@ -40,6 +40,7 @@ export class CalendarIntegrationEffects {
|
|||
private _calendarIntegrationService = inject(CalendarIntegrationService);
|
||||
private _navigateToTaskService = inject(NavigateToTaskService);
|
||||
private _issueService = inject(IssueService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
/**
|
||||
* Poll external calendar providers for events and auto-import them as tasks.
|
||||
|
|
@ -88,7 +89,7 @@ export class CalendarIntegrationEffects {
|
|||
);
|
||||
allEventsToday.forEach((calEv) => {
|
||||
if (
|
||||
isToday(calEv.start) &&
|
||||
this._dateService.isToday(calEv.start) &&
|
||||
!matchesAnyCalendarEventId(calEv, allIssueIds)
|
||||
) {
|
||||
this._issueService.addTaskFromIssue({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { Subject } from 'rxjs';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { GlobalConfigEffects } from './global-config.effects';
|
||||
|
|
@ -10,15 +10,26 @@ import { SnackService } from '../../../core/snack/snack.service';
|
|||
import { UserProfileService } from '../../user-profile/user-profile.service';
|
||||
import { updateGlobalConfigSection } from './global-config.actions';
|
||||
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
|
||||
import { AppStateActions } from '../../../root-store/app-state/app-state.actions';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { selectAllTasks } from '../../tasks/store/task.selectors';
|
||||
|
||||
describe('GlobalConfigEffects', () => {
|
||||
let effects: GlobalConfigEffects;
|
||||
let actions$: Subject<Action>;
|
||||
let dateServiceSpy: jasmine.SpyObj<DateService>;
|
||||
let store: MockStore;
|
||||
|
||||
beforeEach(() => {
|
||||
actions$ = new Subject<Action>();
|
||||
dateServiceSpy = jasmine.createSpyObj('DateService', ['setStartOfNextDayDiff']);
|
||||
dateServiceSpy = jasmine.createSpyObj('DateService', [
|
||||
'setStartOfNextDayDiff',
|
||||
'todayStr',
|
||||
]);
|
||||
dateServiceSpy.todayStr.and.returnValue('2026-02-20');
|
||||
dateServiceSpy.startOfNextDayDiff = 0;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
|
@ -42,13 +53,21 @@ describe('GlobalConfigEffects', () => {
|
|||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(MockStore);
|
||||
store.overrideSelector(selectAllTasks, []);
|
||||
effects = TestBed.inject(GlobalConfigEffects);
|
||||
// Subscribe to the effect to activate it
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe();
|
||||
effects.setStartOfNextDayDiffOnLoad.subscribe();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.resetSelectors();
|
||||
});
|
||||
|
||||
describe('setStartOfNextDayDiffOnChange', () => {
|
||||
it('should call setStartOfNextDayDiff when startOfNextDay is set to a non-zero value', () => {
|
||||
const dispatched: Action[] = [];
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
|
||||
|
||||
actions$.next(
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'misc',
|
||||
|
|
@ -60,6 +79,9 @@ describe('GlobalConfigEffects', () => {
|
|||
});
|
||||
|
||||
it('should call setStartOfNextDayDiff when startOfNextDay is set to 0', () => {
|
||||
const dispatched: Action[] = [];
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
|
||||
|
||||
actions$.next(
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'misc',
|
||||
|
|
@ -71,6 +93,9 @@ describe('GlobalConfigEffects', () => {
|
|||
});
|
||||
|
||||
it('should not call setStartOfNextDayDiff for other config sections', () => {
|
||||
const dispatched: Action[] = [];
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
|
||||
|
||||
actions$.next(
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'keyboard',
|
||||
|
|
@ -79,6 +104,147 @@ describe('GlobalConfigEffects', () => {
|
|||
);
|
||||
|
||||
expect(dateServiceSpy.setStartOfNextDayDiff).not.toHaveBeenCalled();
|
||||
expect(dispatched.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should dispatch setTodayString when startOfNextDay changes', () => {
|
||||
const dispatched: Action[] = [];
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
|
||||
|
||||
actions$.next(
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'misc',
|
||||
sectionCfg: { startOfNextDay: 4 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatched).toContain(
|
||||
AppStateActions.setTodayString({
|
||||
todayStr: '2026-02-20',
|
||||
startOfNextDayDiffMs: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch updateTasks when todayStr shifts and tasks have old dueDay', () => {
|
||||
// First call returns old date, second call (after setStartOfNextDayDiff) returns new date
|
||||
dateServiceSpy.todayStr.and.returnValues('2026-02-20', '2026-02-19');
|
||||
|
||||
store.overrideSelector(selectAllTasks, [
|
||||
{ id: 'task1', dueDay: '2026-02-20' } as any,
|
||||
{ id: 'task2', dueDay: '2026-02-20' } as any,
|
||||
{ id: 'task3', dueDay: '2026-02-18' } as any,
|
||||
]);
|
||||
store.refreshState();
|
||||
|
||||
const dispatched: Action[] = [];
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
|
||||
|
||||
actions$.next(
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'misc',
|
||||
sectionCfg: { startOfNextDay: 4 },
|
||||
}),
|
||||
);
|
||||
|
||||
const updateTasksAction = dispatched.find(
|
||||
(a) => a.type === TaskSharedActions.updateTasks.type,
|
||||
);
|
||||
expect(updateTasksAction).toBeTruthy();
|
||||
expect((updateTasksAction as any).tasks).toEqual([
|
||||
{ id: 'task1', changes: { dueDay: '2026-02-19' } },
|
||||
{ id: 'task2', changes: { dueDay: '2026-02-19' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not dispatch updateTasks when todayStr does not change', () => {
|
||||
// Both calls return the same date
|
||||
dateServiceSpy.todayStr.and.returnValue('2026-02-20');
|
||||
|
||||
store.overrideSelector(selectAllTasks, [
|
||||
{ id: 'task1', dueDay: '2026-02-20' } as any,
|
||||
]);
|
||||
store.refreshState();
|
||||
|
||||
const dispatched: Action[] = [];
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe((a) => dispatched.push(a));
|
||||
|
||||
actions$.next(
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'misc',
|
||||
sectionCfg: { startOfNextDay: 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
const updateTasksAction = dispatched.find(
|
||||
(a) => a.type === TaskSharedActions.updateTasks.type,
|
||||
);
|
||||
expect(updateTasksAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should dispatch setTodayString with todayStr and startOfNextDayDiffMs', () => {
|
||||
dateServiceSpy.startOfNextDayDiff = 14400000;
|
||||
const dispatched: Action[] = [];
|
||||
effects.setStartOfNextDayDiffOnChange.subscribe((action) => {
|
||||
dispatched.push(action);
|
||||
});
|
||||
|
||||
actions$.next(
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'misc',
|
||||
sectionCfg: { startOfNextDay: 4 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatched).toContain(
|
||||
AppStateActions.setTodayString({
|
||||
todayStr: '2026-02-20',
|
||||
startOfNextDayDiffMs: 14400000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setStartOfNextDayDiffOnLoad', () => {
|
||||
it('should call setStartOfNextDayDiff when loadAllData is dispatched', () => {
|
||||
actions$.next(
|
||||
loadAllData({
|
||||
appDataComplete: {
|
||||
globalConfig: {
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
misc: { ...DEFAULT_GLOBAL_CONFIG.misc, startOfNextDay: 4 },
|
||||
},
|
||||
} as any,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dateServiceSpy.setStartOfNextDayDiff).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('should dispatch setTodayString when loadAllData is dispatched', () => {
|
||||
dateServiceSpy.startOfNextDayDiff = 14400000;
|
||||
let emittedAction: Action | undefined;
|
||||
effects.setStartOfNextDayDiffOnLoad.subscribe((action) => {
|
||||
emittedAction = action;
|
||||
});
|
||||
|
||||
actions$.next(
|
||||
loadAllData({
|
||||
appDataComplete: {
|
||||
globalConfig: {
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
misc: { ...DEFAULT_GLOBAL_CONFIG.misc, startOfNextDay: 4 },
|
||||
},
|
||||
} as any,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(emittedAction).toEqual(
|
||||
AppStateActions.setTodayString({
|
||||
todayStr: '2026-02-20',
|
||||
startOfNextDayDiffMs: 14400000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect, ofType } from '@ngrx/effects';
|
||||
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
|
||||
import { distinctUntilChanged, filter, map, tap, withLatestFrom } from 'rxjs/operators';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
switchMap,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from 'rxjs/operators';
|
||||
import { Action, Store } from '@ngrx/store';
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
import { T } from '../../../t.const';
|
||||
import { LanguageService } from '../../../core/language/language.service';
|
||||
import { DateService } from 'src/app/core/date/date.service';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const';
|
||||
|
|
@ -18,6 +25,9 @@ import {
|
|||
} from './global-config.reducer';
|
||||
import { AppFeaturesConfig, MiscConfig } from '../global-config.model';
|
||||
import { UserProfileService } from '../../user-profile/user-profile.service';
|
||||
import { AppStateActions } from '../../../root-store/app-state/app-state.actions';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { selectAllTasks } from '../../tasks/store/task.selectors';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalConfigEffects {
|
||||
|
|
@ -99,35 +109,61 @@ export class GlobalConfigEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
setStartOfNextDayDiffOnChange = createEffect(
|
||||
() =>
|
||||
this._actions$.pipe(
|
||||
ofType(updateGlobalConfigSection),
|
||||
filter(({ sectionKey, sectionCfg }) => sectionKey === 'misc'),
|
||||
filter(
|
||||
({ sectionCfg }) =>
|
||||
sectionCfg && typeof (sectionCfg as MiscConfig).startOfNextDay === 'number',
|
||||
),
|
||||
tap(({ sectionKey, sectionCfg }) => {
|
||||
this._dateService.setStartOfNextDayDiff(
|
||||
(sectionCfg as MiscConfig).startOfNextDay,
|
||||
);
|
||||
}),
|
||||
setStartOfNextDayDiffOnChange = createEffect(() =>
|
||||
this._actions$.pipe(
|
||||
ofType(updateGlobalConfigSection),
|
||||
filter(({ sectionKey }) => sectionKey === 'misc'),
|
||||
filter(
|
||||
({ sectionCfg }) =>
|
||||
sectionCfg && typeof (sectionCfg as MiscConfig).startOfNextDay === 'number',
|
||||
),
|
||||
{ dispatch: false },
|
||||
withLatestFrom(this._store.select(selectAllTasks)),
|
||||
switchMap(([{ sectionCfg }, allTasks]) => {
|
||||
const oldTodayStr = this._dateService.todayStr();
|
||||
this._dateService.setStartOfNextDayDiff(
|
||||
(sectionCfg as MiscConfig).startOfNextDay,
|
||||
);
|
||||
const newTodayStr = this._dateService.todayStr();
|
||||
|
||||
const actions: Action[] = [
|
||||
AppStateActions.setTodayString({
|
||||
todayStr: newTodayStr,
|
||||
startOfNextDayDiffMs: this._dateService.startOfNextDayDiff,
|
||||
}),
|
||||
];
|
||||
|
||||
// Migrate active task dueDays so "today" tasks stay "today" after offset change.
|
||||
// Archived tasks are intentionally excluded — their dueDay is historical.
|
||||
if (oldTodayStr !== newTodayStr) {
|
||||
const taskUpdates = allTasks
|
||||
.filter((t) => t.dueDay === oldTodayStr)
|
||||
.map((t) => ({ id: t.id, changes: { dueDay: newTodayStr } }));
|
||||
|
||||
if (taskUpdates.length > 0) {
|
||||
actions.push(TaskSharedActions.updateTasks({ tasks: taskUpdates }));
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
setStartOfNextDayDiffOnLoad = createEffect(
|
||||
() =>
|
||||
this._actions$.pipe(
|
||||
ofType(loadAllData),
|
||||
tap(({ appDataComplete }) => {
|
||||
const cfg = appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG;
|
||||
const startOfNextDay = cfg && cfg.misc && cfg.misc.startOfNextDay;
|
||||
this._dateService.setStartOfNextDayDiff(startOfNextDay);
|
||||
setStartOfNextDayDiffOnLoad = createEffect(() =>
|
||||
this._actions$.pipe(
|
||||
ofType(loadAllData),
|
||||
tap(({ appDataComplete }) => {
|
||||
const cfg = appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG;
|
||||
const startOfNextDay = cfg && cfg.misc && cfg.misc.startOfNextDay;
|
||||
this._dateService.setStartOfNextDayDiff(startOfNextDay);
|
||||
}),
|
||||
map(() =>
|
||||
AppStateActions.setTodayString({
|
||||
todayStr: this._dateService.todayStr(),
|
||||
startOfNextDayDiffMs: this._dateService.startOfNextDayDiff,
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
),
|
||||
);
|
||||
|
||||
notifyElectronAboutCfgChange =
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { FormsModule } from '@angular/forms';
|
|||
import { millisecondsDiffToRemindOption } from '../../tasks/util/remind-option-to-milliseconds';
|
||||
import { expandFadeAnimation } from '../../../ui/animations/expand.ani';
|
||||
import { getClockStringFromHours } from '../../../util/get-clock-string-from-hours';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { ReminderService } from '../../reminder/reminder.service';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
|
|
@ -99,6 +99,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
private _reminderService = inject(ReminderService);
|
||||
private _translateService = inject(TranslateService);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _dateService = inject(DateService);
|
||||
private readonly _dateAdapter = inject(DateAdapter);
|
||||
|
||||
// Wait for localization config to be loaded before rendering calendar
|
||||
|
|
@ -122,7 +123,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
isInitValOnTimeFocus: boolean = true;
|
||||
|
||||
isShowEnterMsg = false;
|
||||
todayStr = getDbDateStr();
|
||||
todayStr = this._dateService.todayStr();
|
||||
// private _prevSelectedQuickAccessDate: Date | null = null;
|
||||
// private _prevQuickAccessAction: number | null = null;
|
||||
private _timeCheckVal: string | null = null;
|
||||
|
|
@ -282,7 +283,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
id: this.data.task.id,
|
||||
}),
|
||||
);
|
||||
} else if (this.plannedDayForTask === getDbDateStr()) {
|
||||
} else if (this.plannedDayForTask === this._dateService.todayStr()) {
|
||||
// to cover edge cases
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.unscheduleTask({
|
||||
|
|
@ -325,7 +326,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
this.isInitValOnTimeFocus = false;
|
||||
|
||||
if (this.selectedDate) {
|
||||
if (isToday(this.selectedDate as Date)) {
|
||||
if (this._dateService.isToday(this.selectedDate as Date)) {
|
||||
this.selectedTime = getClockStringFromHours(new Date().getHours() + 1);
|
||||
} else {
|
||||
this.selectedTime = DEFAULT_TIME;
|
||||
|
|
@ -368,7 +369,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
!this.data.task.dueWithTime
|
||||
) {
|
||||
const formattedDate =
|
||||
newDay == getDbDateStr()
|
||||
newDay == this._dateService.todayStr()
|
||||
? this._translateService.instant(T.G.TODAY_TAG_TITLE)
|
||||
: (this._datePipe.transform(newDay, 'shortDate') as string);
|
||||
this._snackService.open({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { LayoutService } from '../../core-ui/layout/layout.service';
|
||||
import { PlannerActions } from './store/planner.actions';
|
||||
import { selectTaskFeatureState } from '../tasks/store/task.selectors';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
|
|
@ -11,6 +10,7 @@ import { PlannerPlanViewComponent } from './planner-plan-view/planner-plan-view.
|
|||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { PlannerCalendarNavComponent } from './planner-calendar-nav/planner-calendar-nav.component';
|
||||
import { PlannerService } from './planner.service';
|
||||
import { LayoutService } from '../../core-ui/layout/layout.service';
|
||||
|
||||
@Component({
|
||||
selector: 'planner',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { plannerInitialState, plannerReducer } from './planner.reducer';
|
||||
import { PlannerActions } from './planner.actions';
|
||||
import { DEFAULT_TASK } from '../../tasks/task.model';
|
||||
import * as getDbDateStrUtil from '../../../util/get-db-date-str';
|
||||
|
||||
describe('Planner Reducer', () => {
|
||||
describe('an unknown action', () => {
|
||||
|
|
@ -59,124 +57,5 @@ describe('Planner Reducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('planTaskForDay', () => {
|
||||
it('should not add task to planner days when planning for today', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days[todayStr]).toBeUndefined();
|
||||
expect(result.days['2024-01-16']).toEqual(['task2']);
|
||||
});
|
||||
|
||||
it('should remove task from all days when planning for today', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task1', 'task2'],
|
||||
'2024-01-17': ['task3'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days[todayStr]).toBeUndefined();
|
||||
expect(result.days['2024-01-16']).toEqual(['task2']);
|
||||
expect(result.days['2024-01-17']).toEqual(['task3']);
|
||||
});
|
||||
|
||||
it('should add task to planner days when planning for future day', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16', // future day
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task2', 'task1']);
|
||||
});
|
||||
|
||||
it('should add task to top when isAddToTop is true', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16',
|
||||
isAddToTop: true,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task1', 'task2']);
|
||||
});
|
||||
|
||||
it('should handle reordering task within same day', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16',
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task1', 'task2', 'task3'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task2', 'task3', 'task1']);
|
||||
});
|
||||
|
||||
it('should remove subtasks when moving parent task', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: {
|
||||
...DEFAULT_TASK,
|
||||
id: 'parent',
|
||||
projectId: 'test',
|
||||
subTaskIds: ['sub1', 'sub2'],
|
||||
},
|
||||
day: '2024-01-16',
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task1', 'sub1', 'task2', 'sub2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task1', 'task2', 'parent']);
|
||||
});
|
||||
});
|
||||
// NOTE: planTaskForDay is now tested in planner-shared.reducer.spec.ts
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { createFeature, createReducer, on } from '@ngrx/store';
|
|||
import { PlannerActions } from './planner.actions';
|
||||
import { moveItemInArray } from '../../../util/move-item-in-array';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
export const plannerFeatureKey = 'planner';
|
||||
|
|
@ -159,37 +157,7 @@ export const plannerReducer = createReducer(
|
|||
};
|
||||
}),
|
||||
|
||||
on(PlannerActions.planTaskForDay, (state, { task, day, isAddToTop }) => {
|
||||
const daysCopy = { ...state.days };
|
||||
// filter out from other days (including the target day to handle reordering)
|
||||
Object.keys(daysCopy).forEach((dayI) => {
|
||||
daysCopy[dayI] = daysCopy[dayI].filter((id) => id !== task.id);
|
||||
});
|
||||
|
||||
const todayStr = getDbDateStr();
|
||||
const isPlannedForToday = day === todayStr;
|
||||
|
||||
return {
|
||||
...state,
|
||||
days: {
|
||||
...daysCopy,
|
||||
// Today's ordering is managed by TODAY_TAG.taskIds, not planner.days.
|
||||
// This dual-system design keeps move operations (drag/drop, Ctrl+↑/↓)
|
||||
// uniform for all tags. See: docs/ai/today-tag-architecture.md
|
||||
...(isPlannedForToday
|
||||
? {}
|
||||
: {
|
||||
[day]: unique(
|
||||
isAddToTop
|
||||
? [task.id, ...(daysCopy[day] || [])]
|
||||
: [...(daysCopy[day] || []), task.id],
|
||||
)
|
||||
// when moving a parent to the day, remove all sub-tasks
|
||||
.filter((id) => !task.subTaskIds.includes(id)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
// NOTE: planTaskForDay is now handled in planner-shared.reducer.ts
|
||||
|
||||
on(PlannerActions.updatePlannerDialogLastShown, (state, { today }) => ({
|
||||
...state,
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ describe('Planner Selectors - selectAllTasksDueToday', () => {
|
|||
todayStr: string = today,
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
) => ({
|
||||
[appStateFeatureKey]: { todayStr },
|
||||
[appStateFeatureKey]: { todayStr, startOfNextDayDiffMs: 0 },
|
||||
[TASK_FEATURE_NAME]: { ...mockTaskState, ...taskState },
|
||||
[plannerFeatureKey]: { ...mockPlannerState, ...plannerState },
|
||||
});
|
||||
|
|
@ -564,5 +564,115 @@ describe('Planner Selectors - selectAllTasksDueToday', () => {
|
|||
expect(ids).toContain('taskDueTomorrow');
|
||||
expect(ids).not.toContain('taskDueToday');
|
||||
});
|
||||
|
||||
describe('with startOfNextDayDiff offset', () => {
|
||||
const FOUR_HOURS_MS = 4 * 3600000;
|
||||
const offsetTodayStr = '2026-02-15';
|
||||
|
||||
// Helper to create a local timestamp for a specific date and time
|
||||
const getLocalTimestamp = (
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number = 0,
|
||||
): number => {
|
||||
return new Date(year, month - 1, day, hour, minute, 0, 0).getTime();
|
||||
};
|
||||
|
||||
const createOffsetState = (
|
||||
tasks: { [id: string]: Task },
|
||||
startOfNextDayDiffMs: number = FOUR_HOURS_MS,
|
||||
todayStr: string = offsetTodayStr,
|
||||
plannerDays: { [day: string]: string[] } = {},
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
) => ({
|
||||
[appStateFeatureKey]: { todayStr, startOfNextDayDiffMs },
|
||||
[TASK_FEATURE_NAME]: {
|
||||
...mockTaskState,
|
||||
ids: Object.keys(tasks),
|
||||
entities: tasks,
|
||||
},
|
||||
[plannerFeatureKey]: {
|
||||
...mockPlannerState,
|
||||
days: plannerDays,
|
||||
},
|
||||
});
|
||||
|
||||
it('should include dueWithTime task at 2 AM next day when offset extends today', () => {
|
||||
// 2 AM Feb 16 minus 4h offset => Feb 15 22:00 => dateStr "2026-02-15" === todayStr
|
||||
const task = createMockTask({
|
||||
id: 'task2am',
|
||||
title: 'Due at 2 AM Feb 16',
|
||||
dueWithTime: getLocalTimestamp(2026, 2, 16, 2),
|
||||
});
|
||||
|
||||
const mockState = createOffsetState({ task2am: task });
|
||||
const result = fromSelectors.selectAllTasksDueToday(mockState);
|
||||
|
||||
const ids = result.map((t) => t.id);
|
||||
expect(ids).toContain('task2am');
|
||||
});
|
||||
|
||||
it('should NOT include dueWithTime task at 5 AM next day when offset is 4 hours', () => {
|
||||
// 5 AM Feb 16 minus 4h offset => Feb 16 01:00 => dateStr "2026-02-16" !== todayStr
|
||||
const task = createMockTask({
|
||||
id: 'task5am',
|
||||
title: 'Due at 5 AM Feb 16',
|
||||
dueWithTime: getLocalTimestamp(2026, 2, 16, 5),
|
||||
});
|
||||
|
||||
const mockState = createOffsetState({ task5am: task });
|
||||
const result = fromSelectors.selectAllTasksDueToday(mockState);
|
||||
|
||||
const ids = result.map((t) => t.id);
|
||||
expect(ids).not.toContain('task5am');
|
||||
});
|
||||
|
||||
it('should include task with dueDay matching todayStr regardless of offset', () => {
|
||||
// dueDay comparison is a simple string match, unaffected by offset
|
||||
const task = createMockTask({
|
||||
id: 'taskDueDay',
|
||||
title: 'Due Day Feb 15',
|
||||
dueDay: '2026-02-15',
|
||||
});
|
||||
|
||||
const mockState = createOffsetState({ taskDueDay: task });
|
||||
const result = fromSelectors.selectAllTasksDueToday(mockState);
|
||||
|
||||
const ids = result.map((t) => t.id);
|
||||
expect(ids).toContain('taskDueDay');
|
||||
});
|
||||
|
||||
it('should include dueWithTime task at 3:59 AM next day (boundary, just before offset)', () => {
|
||||
// 3:59 AM Feb 16 minus 4h => Feb 15 23:59 => dateStr "2026-02-15" === todayStr
|
||||
const task = createMockTask({
|
||||
id: 'task359am',
|
||||
title: 'Due at 3:59 AM Feb 16',
|
||||
dueWithTime: getLocalTimestamp(2026, 2, 16, 3, 59),
|
||||
});
|
||||
|
||||
const mockState = createOffsetState({ task359am: task });
|
||||
const result = fromSelectors.selectAllTasksDueToday(mockState);
|
||||
|
||||
const ids = result.map((t) => t.id);
|
||||
expect(ids).toContain('task359am');
|
||||
});
|
||||
|
||||
it('should NOT include dueWithTime task at 4:00 AM next day (boundary, exactly at offset)', () => {
|
||||
// 4:00 AM Feb 16 minus 4h => Feb 16 00:00 => dateStr "2026-02-16" !== todayStr
|
||||
const task = createMockTask({
|
||||
id: 'task400am',
|
||||
title: 'Due at 4:00 AM Feb 16',
|
||||
dueWithTime: getLocalTimestamp(2026, 2, 16, 4, 0),
|
||||
});
|
||||
|
||||
const mockState = createOffsetState({ task400am: task });
|
||||
const result = fromSelectors.selectAllTasksDueToday(mockState);
|
||||
|
||||
const ids = result.map((t) => t.id);
|
||||
expect(ids).not.toContain('task400am');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,15 +14,18 @@ import { ScheduleFromCalendarEvent } from '../../schedule/schedule.model';
|
|||
import { TaskCopy, TaskWithDueDay, TaskWithDueTime } from '../../tasks/task.model';
|
||||
import { TaskRepeatCfg } from '../../task-repeat-cfg/task-repeat-cfg.model';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import { isSameDay } from '../../../util/is-same-day';
|
||||
import { getTimeLeftForTask } from '../../../util/get-time-left-for-task';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { ScheduleCalendarMapEntry } from '../../schedule/schedule.model';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { calculateAvailableHours } from '../util/calculate-available-hours';
|
||||
import { selectConfigFeatureState } from '../../config/store/global-config.reducer';
|
||||
import { ScheduleConfig } from '../../config/global-config.model';
|
||||
import { selectTodayStr } from '../../../root-store/app-state/app-state.selectors';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
import {
|
||||
selectStartOfNextDayDiffMs,
|
||||
selectTodayStr,
|
||||
} from '../../../root-store/app-state/app-state.selectors';
|
||||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
import { selectTaskRepeatCfgsForExactDay } from '../../task-repeat-cfg/store/task-repeat-cfg.selectors';
|
||||
|
||||
export const selectPlannerState = createFeatureSelector<fromPlanner.PlannerState>(
|
||||
|
|
@ -31,9 +34,15 @@ export const selectPlannerState = createFeatureSelector<fromPlanner.PlannerState
|
|||
|
||||
export const selectAllTasksDueToday = createSelector(
|
||||
selectTodayStr,
|
||||
selectStartOfNextDayDiffMs,
|
||||
selectTaskFeatureState,
|
||||
selectPlannerState,
|
||||
(todayStr, taskState, plannerState): (TaskWithDueTime | TaskWithDueDay)[] => {
|
||||
(
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
taskState,
|
||||
plannerState,
|
||||
): (TaskWithDueTime | TaskWithDueDay)[] => {
|
||||
// Start with tasks from planner state for today
|
||||
const allDue: (TaskWithDueTime | TaskWithDueDay)[] = (
|
||||
plannerState.days[todayStr] || []
|
||||
|
|
@ -55,7 +64,7 @@ export const selectAllTasksDueToday = createSelector(
|
|||
// Priority: dueWithTime takes precedence over dueDay (mutual exclusivity pattern)
|
||||
let isDueToday = false;
|
||||
if (task.dueWithTime) {
|
||||
isDueToday = isToday(task.dueWithTime);
|
||||
isDueToday = isTodayWithOffset(task.dueWithTime, todayStr, startOfNextDayDiffMs);
|
||||
} else if (task.dueDay === todayStr) {
|
||||
isDueToday = true;
|
||||
}
|
||||
|
|
@ -101,7 +110,8 @@ export const selectPlannerDays = (
|
|||
selectPlannerState,
|
||||
// TODO this could be more efficient by limiting this to changes of the relevant stuff
|
||||
selectConfigFeatureState,
|
||||
(taskState, plannerState, globalConfig): PlannerDay[] => {
|
||||
selectStartOfNextDayDiffMs,
|
||||
(taskState, plannerState, globalConfig, startOfNextDayDiffMs): PlannerDay[] => {
|
||||
const allDatesWithData = Object.keys(plannerState.days);
|
||||
const dayDatesToUse = [
|
||||
...dayDates,
|
||||
|
|
@ -121,6 +131,7 @@ export const selectPlannerDays = (
|
|||
icalEvents,
|
||||
unplannedTaskIdsToday,
|
||||
globalConfig.schedule,
|
||||
startOfNextDayDiffMs,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -157,6 +168,7 @@ const getPlannerDay = (
|
|||
icalEvents: ScheduleCalendarMapEntry[],
|
||||
unplannedTaskIdsToday: string[] | false,
|
||||
scheduleConfig?: ScheduleConfig,
|
||||
startOfNextDayDiffMs: number = 0,
|
||||
): PlannerDay => {
|
||||
const isTodayI = dayDate === todayStr;
|
||||
const currentDayDate = dateStrToUtcDate(dayDate);
|
||||
|
|
@ -174,8 +186,16 @@ const getPlannerDay = (
|
|||
const { repeatProjectionsForDay, noStartTimeRepeatProjections } =
|
||||
getAllRepeatableTasksForDay(taskRepeatCfgs, currentDayTimestamp);
|
||||
|
||||
const scheduledTaskItems = getScheduledTaskItems(allPlannedTasks, currentDayDate);
|
||||
const { timedEvents, allDayEvents } = getIcalEventsForDay(icalEvents, currentDayDate);
|
||||
const scheduledTaskItems = getScheduledTaskItems(
|
||||
allPlannedTasks,
|
||||
dayDate,
|
||||
startOfNextDayDiffMs,
|
||||
);
|
||||
const { timedEvents, allDayEvents } = getIcalEventsForDay(
|
||||
icalEvents,
|
||||
dayDate,
|
||||
startOfNextDayDiffMs,
|
||||
);
|
||||
|
||||
const timeEstimate = getAllTimeSpent(
|
||||
normalTasks,
|
||||
|
|
@ -277,10 +297,14 @@ const getAllRepeatableTasksForDay = (
|
|||
|
||||
const getScheduledTaskItems = (
|
||||
allPlannedTasks: TaskWithDueTime[],
|
||||
currentDayDate: Date,
|
||||
dayDate: string,
|
||||
startOfNextDayDiffMs: number = 0,
|
||||
): ScheduleItemTask[] =>
|
||||
allPlannedTasks
|
||||
.filter((task) => isSameDay(task.dueWithTime, currentDayDate))
|
||||
.filter(
|
||||
(task) =>
|
||||
getDbDateStr(new Date(task.dueWithTime - startOfNextDayDiffMs)) === dayDate,
|
||||
)
|
||||
.map((task) => {
|
||||
const start = task.dueWithTime;
|
||||
const end = start + Math.max(task.timeEstimate - task.timeSpent, 0);
|
||||
|
|
@ -300,7 +324,8 @@ interface IcalEventsForDayResult {
|
|||
|
||||
const getIcalEventsForDay = (
|
||||
icalEvents: ScheduleCalendarMapEntry[],
|
||||
currentDayDate: Date,
|
||||
dayDate: string,
|
||||
startOfNextDayDiffMs: number = 0,
|
||||
): IcalEventsForDayResult => {
|
||||
const timedEvents: ScheduleItemEvent[] = [];
|
||||
const allDayEvents: ScheduleFromCalendarEvent[] = [];
|
||||
|
|
@ -308,7 +333,7 @@ const getIcalEventsForDay = (
|
|||
icalEvents.forEach((icalMapEntry) => {
|
||||
icalMapEntry.items.forEach((calEv) => {
|
||||
const start = calEv.start;
|
||||
if (isSameDay(start, currentDayDate)) {
|
||||
if (getDbDateStr(new Date(start - startOfNextDayDiffMs)) === dayDate) {
|
||||
if (calEv.isAllDay) {
|
||||
// All-day events go to a separate list with full event data
|
||||
allDayEvents.push({ ...calEv });
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ import { computeOrderedTaskIdsForTag, initialTagState, tagReducer } from './tag.
|
|||
import { Tag } from '../tag.model';
|
||||
import { addTag } from './tag.actions';
|
||||
import { TODAY_TAG } from '../tag.const';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { DEFAULT_TASK } from '../../tasks/task.model';
|
||||
import * as getDbDateStrUtil from '../../../util/get-db-date-str';
|
||||
import { moveTaskInTodayList } from '../../work-context/store/work-context-meta.actions';
|
||||
import { WorkContextType } from '../../work-context/work-context.model';
|
||||
|
||||
|
|
@ -41,157 +38,8 @@ describe('TagReducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('planTaskForDay', () => {
|
||||
it('should add new task to today tag when planning for today', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task3', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual([
|
||||
'task1',
|
||||
'task2',
|
||||
'task3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add task to top of today tag when isAddToTop is true', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task3', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: true,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual([
|
||||
'task3',
|
||||
'task1',
|
||||
'task2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle reordering existing task within today tag', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2', 'task3'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false, // move to end
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual([
|
||||
'task2',
|
||||
'task3',
|
||||
'task1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove task from today tag when planning for future day', () => {
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2', 'task3'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task2', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16', // future day
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual(['task1', 'task3']);
|
||||
});
|
||||
|
||||
it('should not modify state when planning for future day with task not in today', () => {
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task3', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16', // future day
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect(result).toBe(initialState);
|
||||
});
|
||||
|
||||
it('should not duplicate task when already in today list', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
const taskIds = (result.entities[TODAY_TAG.id] as Tag).taskIds;
|
||||
expect(taskIds).toEqual(['task2', 'task1']);
|
||||
expect(taskIds.filter((id) => id === 'task1').length).toBe(1);
|
||||
});
|
||||
});
|
||||
// NOTE: planTaskForDay tests removed - this action is now handled by the meta-reducer
|
||||
// in planner-shared.reducer.ts with offset-aware todayStr. See planner-shared.reducer.spec.ts.
|
||||
|
||||
describe('moveTaskInTodayList (anchor-based)', () => {
|
||||
it('should move task to start of list when afterTaskId is null and target is UNDONE', () => {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
arrayMoveToEnd,
|
||||
arrayMoveToStart,
|
||||
} from '../../../util/array-move';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import {
|
||||
addTag,
|
||||
|
|
@ -40,8 +39,6 @@ import {
|
|||
updateTag,
|
||||
updateTagOrder,
|
||||
} from './tag.actions';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
export const TAG_FEATURE_NAME = 'tag';
|
||||
|
|
@ -220,73 +217,8 @@ export const tagReducer = createReducer<TagState>(
|
|||
),
|
||||
|
||||
// NOTE: transferTask is now handled in planner-shared.reducer.ts
|
||||
|
||||
on(PlannerActions.planTaskForDay, (state, { task, day, isAddToTop }) => {
|
||||
const todayStr = getDbDateStr();
|
||||
const todayTag = state.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
if (day === todayStr) {
|
||||
// Always remove first, then add in correct position (handles reordering)
|
||||
const taskIdsWithoutCurrent = todayTag.taskIds.filter((id) => id !== task.id);
|
||||
return tagAdapter.updateOne(
|
||||
{
|
||||
id: todayTag.id,
|
||||
changes: {
|
||||
taskIds: unique(
|
||||
isAddToTop
|
||||
? [task.id, ...taskIdsWithoutCurrent]
|
||||
: [...taskIdsWithoutCurrent, task.id],
|
||||
),
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
} else if (todayTag.taskIds.includes(task.id)) {
|
||||
// Moving away from today, remove from today's list
|
||||
return tagAdapter.updateOne(
|
||||
{
|
||||
id: todayTag.id,
|
||||
changes: {
|
||||
taskIds: todayTag.taskIds.filter((id) => id !== task.id),
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}),
|
||||
|
||||
on(PlannerActions.moveBeforeTask, (state, { fromTask, toTaskId }) => {
|
||||
const todayTag = state.entities[TODAY_TAG.id] as Tag;
|
||||
if (todayTag.taskIds.includes(toTaskId)) {
|
||||
const taskIds = todayTag.taskIds.filter((id) => id !== fromTask.id);
|
||||
const targetIndex = taskIds.indexOf(toTaskId);
|
||||
taskIds.splice(targetIndex, 0, fromTask.id);
|
||||
|
||||
return tagAdapter.updateOne(
|
||||
{
|
||||
id: todayTag.id,
|
||||
changes: {
|
||||
taskIds: unique(taskIds),
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
} else if (todayTag.taskIds.includes(fromTask.id)) {
|
||||
return tagAdapter.updateOne(
|
||||
{
|
||||
id: todayTag.id,
|
||||
changes: {
|
||||
taskIds: todayTag.taskIds.filter((id) => id !== fromTask.id),
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}),
|
||||
// NOTE: planTaskForDay is handled in planner-shared.reducer.ts (meta-reducer with offset-aware todayStr)
|
||||
// NOTE: moveBeforeTask is handled in planner-shared.reducer.ts (meta-reducer)
|
||||
|
||||
// REGULAR ACTIONS
|
||||
// --------------------
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ import { Update } from '@ngrx/entity';
|
|||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
import { TaskArchiveService } from '../../archive/task-archive.service';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { Log } from '../../../core/log';
|
||||
import {
|
||||
addSubTask,
|
||||
|
|
@ -65,6 +65,7 @@ export class TaskRepeatCfgEffects {
|
|||
private _taskRepeatCfgService = inject(TaskRepeatCfgService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
private _taskArchiveService = inject(TaskArchiveService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
addRepeatCfgToTaskUpdateTask$ = createEffect(() =>
|
||||
this._localActions$.pipe(
|
||||
|
|
@ -96,7 +97,7 @@ export class TaskRepeatCfgEffects {
|
|||
);
|
||||
|
||||
// Only skip auto-removal from today if the task is scheduled for today
|
||||
const scheduledForToday = isToday(dateTime);
|
||||
const scheduledForToday = this._dateService.isToday(dateTime);
|
||||
|
||||
return TaskSharedActions.scheduleTaskWithTime({
|
||||
task,
|
||||
|
|
@ -164,9 +165,11 @@ export class TaskRepeatCfgEffects {
|
|||
new Date(),
|
||||
);
|
||||
const firstOccurrenceStr = firstOccurrence
|
||||
? getDbDateStr(firstOccurrence)
|
||||
: getDbDateStr(new Date());
|
||||
const isFirstOccurrenceToday_ = firstOccurrence ? isToday(firstOccurrence) : true;
|
||||
? this._dateService.todayStr(firstOccurrence)
|
||||
: this._dateService.todayStr();
|
||||
const isFirstOccurrenceToday_ = firstOccurrence
|
||||
? this._dateService.isToday(firstOccurrence)
|
||||
: true;
|
||||
|
||||
// Update repeat config with subtask templates AND the correct lastTaskCreationDay
|
||||
this._taskRepeatCfgService.updateTaskRepeatCfg(taskRepeatCfg.id, {
|
||||
|
|
@ -258,8 +261,8 @@ export class TaskRepeatCfgEffects {
|
|||
|
||||
const firstOccurrence = getFirstRepeatOccurrence(fullCfg, new Date());
|
||||
const firstOccurrenceStr = firstOccurrence
|
||||
? getDbDateStr(firstOccurrence)
|
||||
: getDbDateStr(new Date());
|
||||
? this._dateService.todayStr(firstOccurrence)
|
||||
: this._dateService.todayStr();
|
||||
|
||||
// Update lastTaskCreationDay on the config
|
||||
this._taskRepeatCfgService.updateTaskRepeatCfg(cfgId, {
|
||||
|
|
@ -269,7 +272,7 @@ export class TaskRepeatCfgEffects {
|
|||
|
||||
const isTimedTask = !!(fullCfg.startTime && fullCfg.remindAt);
|
||||
const isFirstOccurrenceToday_ = firstOccurrence
|
||||
? isToday(firstOccurrence)
|
||||
? this._dateService.isToday(firstOccurrence)
|
||||
: true;
|
||||
|
||||
if (isTimedTask) {
|
||||
|
|
@ -280,7 +283,7 @@ export class TaskRepeatCfgEffects {
|
|||
fullCfg.startTime as string,
|
||||
targetDayTimestamp,
|
||||
);
|
||||
const scheduledForToday = isToday(dateTime);
|
||||
const scheduledForToday = this._dateService.isToday(dateTime);
|
||||
|
||||
return rxOf(
|
||||
TaskSharedActions.scheduleTaskWithTime({
|
||||
|
|
@ -518,7 +521,7 @@ export class TaskRepeatCfgEffects {
|
|||
filter(({ cfg }) => !!cfg && cfg.repeatFromCompletionDate === true),
|
||||
filter(({ task, cfg }) => this._isLatestInstance(task, cfg)),
|
||||
map(({ cfg }) => {
|
||||
const today = getDbDateStr();
|
||||
const today = this._dateService.todayStr();
|
||||
return updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: cfg.id as string,
|
||||
|
|
@ -640,7 +643,7 @@ export class TaskRepeatCfgEffects {
|
|||
(changes.startTime || changes.remindAt) &&
|
||||
completeCfg.remindAt &&
|
||||
completeCfg.startTime &&
|
||||
isToday(task.created)
|
||||
this._dateService.isToday(task.created)
|
||||
) {
|
||||
const dateTime = getDateTimeFromClockString(
|
||||
completeCfg.startTime as string,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { getDateTimeFromClockString } from '../../util/get-date-time-from-clock-
|
|||
import { remindOptionToMilliseconds } from '../tasks/util/remind-option-to-milliseconds';
|
||||
import { getNewestPossibleDueDate } from './store/get-newest-possible-due-date.util';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { isToday } from '../../util/is-today.util';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { TODAY_TAG } from '../tag/tag.const';
|
||||
import {
|
||||
selectAllTaskRepeatCfgs,
|
||||
|
|
@ -50,6 +50,7 @@ export class TaskRepeatCfgService {
|
|||
private _matDialog = inject(MatDialog);
|
||||
private _taskService = inject(TaskService);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
taskRepeatCfgs$: Observable<TaskRepeatCfg[]> = this._store$.pipe(
|
||||
select(selectAllTaskRepeatCfgs),
|
||||
|
|
@ -285,7 +286,7 @@ export class TaskRepeatCfgService {
|
|||
remindAt: remindOptionToMilliseconds(dateTime, taskRepeatCfg.remindAt),
|
||||
isMoveToBacklog: false,
|
||||
// Only keep in today list if scheduled for today (#5594)
|
||||
isSkipAutoRemoveFromToday: isToday(dateTime),
|
||||
isSkipAutoRemoveFromToday: this._dateService.isToday(dateTime),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,11 @@ import {
|
|||
import { SyncWrapperService } from '../../../imex/sync/sync-wrapper.service';
|
||||
import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors';
|
||||
import { AddTasksForTomorrowService } from '../../add-tasks-for-tomorrow/add-tasks-for-tomorrow.service';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { getDateRangeForDay } from '../../../util/get-date-range-for-day';
|
||||
import {
|
||||
selectStartOfNextDayDiffMs,
|
||||
selectTodayStr,
|
||||
} from '../../../root-store/app-state/app-state.selectors';
|
||||
import { TaskLog } from '../../../core/log';
|
||||
import { SyncTriggerService } from '../../../imex/sync/sync-trigger.service';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
|
@ -145,9 +148,12 @@ export class TaskDueEffects {
|
|||
}),
|
||||
debounceTime(2000), // Wait a bit longer to ensure all other effects have run
|
||||
switchMap(() => this._syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$),
|
||||
switchMap(() => {
|
||||
const todayStr = getDbDateStr();
|
||||
const todayRange = getDateRangeForDay(Date.now());
|
||||
withLatestFrom(
|
||||
this._store$.select(selectTodayStr),
|
||||
this._store$.select(selectStartOfNextDayDiffMs),
|
||||
),
|
||||
switchMap(([, todayStr, startOfNextDayDiffMs]) => {
|
||||
const todayRange = getDateRangeForDay(Date.now() - startOfNextDayDiffMs);
|
||||
return combineLatest([
|
||||
this._store$.select(selectTasksDueForDay, { day: todayStr }),
|
||||
this._store$.select(selectTasksWithDueTimeForRange, todayRange),
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockAllTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -193,6 +194,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockAllTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -203,6 +205,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockAllTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -213,6 +216,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockAllTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -223,6 +227,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockAllTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -255,6 +260,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([...mockAllTasks, taskForTomorrow]),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -265,6 +271,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockAllTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const parentIndex = result.findIndex((t) => t.id === 'PARENT_LATER');
|
||||
|
|
@ -293,6 +300,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(tasksWithCurrent),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Task scheduled at exactly current time should be included
|
||||
|
|
@ -317,6 +325,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(tasksWithMidnight),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -340,6 +349,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(noMatchingTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
|
|
@ -349,6 +359,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockAllTasks),
|
||||
null as any,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
|
|
@ -376,6 +387,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Should include parent (because it has scheduled subtask) - not the subtask as separate item
|
||||
|
|
@ -407,6 +419,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -461,6 +474,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Should be sorted by earliest time: Parent 1 (has subtask at 2 PM), then Parent 2 (has subtask at 3 PM)
|
||||
|
|
@ -505,6 +519,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Should not include any tasks
|
||||
|
|
@ -533,6 +548,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
const taskIds = result.map((t) => t.id);
|
||||
|
|
@ -580,6 +596,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Only orphaned subtask should appear (parent is for tomorrow)
|
||||
|
|
@ -618,6 +635,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Parent should be included because it has a scheduled subtask
|
||||
|
|
@ -665,6 +683,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Only grandparent should appear as top-level
|
||||
|
|
@ -688,6 +707,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([taskWithTimeOnly]),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Task should be included because dueWithTime is for today
|
||||
|
|
@ -727,6 +747,7 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState(mockTasks),
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Parent should be included
|
||||
|
|
@ -736,4 +757,148 @@ describe('selectLaterTodayTasksWithSubTasks', () => {
|
|||
// Both subtasks should be in subTasks array (done status doesn't affect inclusion in subTasks)
|
||||
expect(result[0].subTasks?.length).toBe(2);
|
||||
});
|
||||
|
||||
describe('with startOfNextDayDiff offset', () => {
|
||||
// Use a fixed date: Feb 15, 2026 at 10:00 AM
|
||||
const feb15 = new Date(2026, 1, 15, 10, 0, 0, 0);
|
||||
const feb15Str = '2026-02-15';
|
||||
const FOUR_HOURS_MS = 4 * 3600000;
|
||||
|
||||
// Helper to create timestamps for a specific date at specific times
|
||||
const dateAt = (
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hours: number,
|
||||
minutes: number = 0,
|
||||
): number => {
|
||||
return new Date(year, month - 1, day, hours, minutes, 0, 0).getTime();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jasmine.clock().mockDate(feb15);
|
||||
});
|
||||
|
||||
it('should include task at 2 AM next day when offset extends today past midnight', () => {
|
||||
// With 4h offset and todayStr "2026-02-15", a task at 2 AM Feb 16 is still "today"
|
||||
// because isTodayWithOffset subtracts offset: 2 AM - 4h = 10 PM Feb 15 => "2026-02-15"
|
||||
// And todayEndTime = Feb 15 23:59:59.999 + 4h = Feb 16 ~3:59:59.999 AM
|
||||
// So 2 AM Feb 16 < 3:59:59.999 AM Feb 16 => within range
|
||||
const taskAt2amFeb16 = createMockTask({
|
||||
id: 'TASK_2AM_FEB16',
|
||||
title: 'Task at 2 AM Feb 16',
|
||||
dueWithTime: dateAt(2026, 2, 16, 2, 0),
|
||||
dueDay: null,
|
||||
});
|
||||
|
||||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([taskAt2amFeb16]),
|
||||
feb15Str,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('TASK_2AM_FEB16');
|
||||
});
|
||||
|
||||
it('should not include task at 5 AM next day when offset does not extend that far', () => {
|
||||
// With 4h offset and todayStr "2026-02-15", a task at 5 AM Feb 16
|
||||
// isTodayWithOffset: 5 AM - 4h = 1 AM Feb 16 => "2026-02-16" != "2026-02-15"
|
||||
// So it is NOT today
|
||||
const taskAt5amFeb16 = createMockTask({
|
||||
id: 'TASK_5AM_FEB16',
|
||||
title: 'Task at 5 AM Feb 16',
|
||||
dueWithTime: dateAt(2026, 2, 16, 5, 0),
|
||||
dueDay: null,
|
||||
});
|
||||
|
||||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([taskAt5amFeb16]),
|
||||
feb15Str,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should include task at 3:59 AM next day (just within end-of-day boundary)', () => {
|
||||
// With 4h offset, todayEndTime = Feb 15 23:59:59.999 + 4h = Feb 16 03:59:59.999
|
||||
// Task at 3:59 AM Feb 16:
|
||||
// isTodayWithOffset: 3:59 AM - 4h = 11:59 PM Feb 15 => "2026-02-15" == todayStr
|
||||
// dueWithTime (3:59 AM) <= todayEndTime (3:59:59.999 AM) => within range
|
||||
const taskAt359amFeb16 = createMockTask({
|
||||
id: 'TASK_359AM_FEB16',
|
||||
title: 'Task at 3:59 AM Feb 16',
|
||||
dueWithTime: dateAt(2026, 2, 16, 3, 59),
|
||||
dueDay: null,
|
||||
});
|
||||
|
||||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([taskAt359amFeb16]),
|
||||
feb15Str,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('TASK_359AM_FEB16');
|
||||
});
|
||||
|
||||
it('should not include task at 4:01 AM next day (just past end-of-day boundary)', () => {
|
||||
// With 4h offset, todayEndTime = Feb 15 23:59:59.999 + 4h = Feb 16 03:59:59.999
|
||||
// Task at 4:01 AM Feb 16:
|
||||
// isTodayWithOffset: 4:01 AM - 4h = 12:01 AM Feb 16 => "2026-02-16" != "2026-02-15"
|
||||
// Not today => excluded
|
||||
const taskAt401amFeb16 = createMockTask({
|
||||
id: 'TASK_401AM_FEB16',
|
||||
title: 'Task at 4:01 AM Feb 16',
|
||||
dueWithTime: dateAt(2026, 2, 16, 4, 1),
|
||||
dueDay: null,
|
||||
});
|
||||
|
||||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([taskAt401amFeb16]),
|
||||
feb15Str,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should still include normal later-today tasks when offset is set', () => {
|
||||
// A task at 3 PM Feb 15 should still be included with the offset
|
||||
const taskAt3pmFeb15 = createMockTask({
|
||||
id: 'TASK_3PM_FEB15',
|
||||
title: 'Task at 3 PM Feb 15',
|
||||
dueWithTime: dateAt(2026, 2, 15, 15, 0),
|
||||
dueDay: feb15Str,
|
||||
});
|
||||
|
||||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([taskAt3pmFeb15]),
|
||||
feb15Str,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('TASK_3PM_FEB15');
|
||||
});
|
||||
|
||||
it('should not include past tasks even with offset', () => {
|
||||
// A task at 8 AM Feb 15 is in the past (clock is mocked to 10 AM Feb 15)
|
||||
const taskAt8amFeb15 = createMockTask({
|
||||
id: 'TASK_8AM_FEB15',
|
||||
title: 'Task at 8 AM Feb 15',
|
||||
dueWithTime: dateAt(2026, 2, 15, 8, 0),
|
||||
dueDay: feb15Str,
|
||||
});
|
||||
|
||||
const result = selectLaterTodayTasksWithSubTasks.projector(
|
||||
createTaskState([taskAt8amFeb15]),
|
||||
feb15Str,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Store } from '@ngrx/store';
|
|||
import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors';
|
||||
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
|
||||
import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
|
||||
@Injectable()
|
||||
export class TaskRelatedModelEffects {
|
||||
|
|
@ -22,6 +22,7 @@ export class TaskRelatedModelEffects {
|
|||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _store = inject(Store);
|
||||
private _hydrationState = inject(HydrationStateService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
// EFFECTS ===> EXTERNAL
|
||||
// ---------------------
|
||||
|
|
@ -65,7 +66,8 @@ export class TaskRelatedModelEffects {
|
|||
mergeMap(({ task }) => this._taskService.getByIdOnce$(task.id as string)),
|
||||
// Skip if task is already scheduled for today (avoids no-op dispatch)
|
||||
filter(
|
||||
(task: Task) => !!task && !task.parentId && task.dueDay !== getDbDateStr(),
|
||||
(task: Task) =>
|
||||
!!task && !task.parentId && task.dueDay !== this._dateService.todayStr(),
|
||||
),
|
||||
map((task) =>
|
||||
TaskSharedActions.planTasksForToday({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as fromSelectors from './task.selectors';
|
||||
import { Task, TaskState } from '../task.model';
|
||||
import { DEFAULT_TASK, Task, TaskState } from '../task.model';
|
||||
import { TASK_FEATURE_NAME } from './task.reducer';
|
||||
import { taskAdapter } from './task.adapter';
|
||||
import { TODAY_TAG } from '../../tag/tag.const';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { PROJECT_FEATURE_NAME } from '../../project/store/project.reducer';
|
||||
|
|
@ -172,6 +173,7 @@ describe('Task Selectors', () => {
|
|||
const mockState = {
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: today,
|
||||
startOfNextDayDiffMs: 0,
|
||||
},
|
||||
[TASK_FEATURE_NAME]: mockTaskState,
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
|
|
@ -450,6 +452,88 @@ describe('Task Selectors', () => {
|
|||
expect(result[0].id).toBe('olderOverdue');
|
||||
expect(result[1].id).toBe('task6');
|
||||
});
|
||||
describe('selectOverdueTasks with startOfNextDayDiffMs offset', () => {
|
||||
const FOUR_HOURS = 4 * 3600 * 1000;
|
||||
|
||||
// Helper to create a date at a specific hour/minute on a given day string
|
||||
const makeTime = (dayStr: string, hours: number, minutes = 0): number => {
|
||||
const d = new Date(dayStr + 'T00:00:00');
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
it('should NOT consider a task overdue if dueWithTime is within offset-adjusted today', () => {
|
||||
// With 4h offset and todayStr='2026-02-15', "today" runs from Feb 15 4AM to Feb 16 4AM
|
||||
// A task at Feb 15 5AM is within today => not overdue
|
||||
const stateWithOffset = {
|
||||
...mockState,
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: '2026-02-15',
|
||||
startOfNextDayDiffMs: FOUR_HOURS,
|
||||
},
|
||||
[TASK_FEATURE_NAME]: taskAdapter.setAll(
|
||||
[
|
||||
{
|
||||
...DEFAULT_TASK,
|
||||
id: 'timeTask1',
|
||||
dueWithTime: makeTime('2026-02-15', 5),
|
||||
} as Task,
|
||||
],
|
||||
taskAdapter.getInitialState(),
|
||||
),
|
||||
};
|
||||
const result = fromSelectors.selectOverdueTasks(stateWithOffset as any);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should consider a task overdue if dueWithTime is before offset-adjusted today start', () => {
|
||||
// With 4h offset and todayStr='2026-02-15', today starts at Feb 15 4AM
|
||||
// A task at Feb 15 3:59AM is before today start => overdue
|
||||
const stateWithOffset = {
|
||||
...mockState,
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: '2026-02-15',
|
||||
startOfNextDayDiffMs: FOUR_HOURS,
|
||||
},
|
||||
[TASK_FEATURE_NAME]: taskAdapter.setAll(
|
||||
[
|
||||
{
|
||||
...DEFAULT_TASK,
|
||||
id: 'timeTask2',
|
||||
dueWithTime: makeTime('2026-02-15', 3, 59),
|
||||
} as Task,
|
||||
],
|
||||
taskAdapter.getInitialState(),
|
||||
),
|
||||
};
|
||||
const result = fromSelectors.selectOverdueTasks(stateWithOffset as any);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('timeTask2');
|
||||
});
|
||||
|
||||
it('should handle exact boundary: task at exactly todayStart is NOT overdue', () => {
|
||||
// A task at exactly Feb 15 4:00AM (= todayStart) is NOT overdue
|
||||
const stateWithOffset = {
|
||||
...mockState,
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: '2026-02-15',
|
||||
startOfNextDayDiffMs: FOUR_HOURS,
|
||||
},
|
||||
[TASK_FEATURE_NAME]: taskAdapter.setAll(
|
||||
[
|
||||
{
|
||||
...DEFAULT_TASK,
|
||||
id: 'timeTask3',
|
||||
dueWithTime: makeTime('2026-02-15', 4),
|
||||
} as Task,
|
||||
],
|
||||
taskAdapter.getInitialState(),
|
||||
),
|
||||
};
|
||||
const result = fromSelectors.selectOverdueTasks(stateWithOffset as any);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Due date and time selectors
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ import {
|
|||
selectTagFeatureState,
|
||||
selectTodayTagTaskIds,
|
||||
} from '../../tag/store/tag.reducer';
|
||||
import { selectTodayStr } from '../../../root-store/app-state/app-state.selectors';
|
||||
import {
|
||||
selectStartOfNextDayDiffMs,
|
||||
selectTodayStr,
|
||||
} from '../../../root-store/app-state/app-state.selectors';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
const mapSubTasksToTasks = (tasksIN: Task[]): TaskWithSubTasks[] => {
|
||||
// Create a Map for O(1) lookups instead of O(n) find() calls
|
||||
const taskMap = new Map<string, Task>();
|
||||
|
|
@ -142,10 +145,12 @@ export const selectStartableTasks = createSelector(
|
|||
export const selectOverdueTasks = createSelector(
|
||||
selectTaskFeatureState,
|
||||
selectTodayStr,
|
||||
(s, todayStr): Task[] => {
|
||||
const today = new Date(todayStr);
|
||||
const todayStart = new Date(today);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
selectStartOfNextDayDiffMs,
|
||||
(s, todayStr, startOfNextDayDiffMs): Task[] => {
|
||||
const today = dateStrToUtcDate(todayStr);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
// The logical start of "today" is shifted by the offset
|
||||
const todayStartMs = today.getTime() + startOfNextDayDiffMs;
|
||||
return s.ids
|
||||
.map((id) => s.entities[id])
|
||||
.filter(
|
||||
|
|
@ -155,7 +160,7 @@ export const selectOverdueTasks = createSelector(
|
|||
// which is lexicographically sortable. This avoids timezone conversion issues.
|
||||
!!(
|
||||
(task.dueDay && task.dueDay < todayStr) ||
|
||||
(task.dueWithTime && task.dueWithTime < todayStart.getTime())
|
||||
(task.dueWithTime && task.dueWithTime < todayStartMs)
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -299,21 +304,23 @@ export const selectAllTasksWithSubTasks = createSelector(
|
|||
export const selectLaterTodayTasksWithSubTasks = createSelector(
|
||||
selectTaskFeatureState,
|
||||
selectTodayStr,
|
||||
(taskState, todayStr): TaskWithSubTasks[] => {
|
||||
selectStartOfNextDayDiffMs,
|
||||
(taskState, todayStr, startOfNextDayDiffMs): TaskWithSubTasks[] => {
|
||||
if (!todayStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const todayEnd = new Date();
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
const todayEndTime = todayEnd.getTime();
|
||||
// End of "today" with offset: last ms of todayStr + offset
|
||||
const todayDate = dateStrToUtcDate(todayStr);
|
||||
todayDate.setHours(23, 59, 59, 999);
|
||||
const todayEndTime = todayDate.getTime() + startOfNextDayDiffMs;
|
||||
|
||||
// Helper to check if task is "in TODAY" via virtual tag pattern
|
||||
// Priority: dueWithTime takes precedence over dueDay (mutual exclusivity)
|
||||
const isInToday = (task: Task): boolean => {
|
||||
if (task.dueWithTime) {
|
||||
return isToday(task.dueWithTime);
|
||||
return isTodayWithOffset(task.dueWithTime, todayStr, startOfNextDayDiffMs);
|
||||
}
|
||||
return task.dueDay === todayStr;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import { TagService } from '../../../tag/tag.service';
|
|||
import { DialogPromptComponent } from '../../../../ui/dialog-prompt/dialog-prompt.component';
|
||||
import { TaskSharedActions } from '../../../../root-store/meta/task-shared.actions';
|
||||
import { selectTodayTaskIds } from '../../../work-context/store/work-context.selectors';
|
||||
import { isToday } from '../../../../util/is-today.util';
|
||||
import { DateService } from '../../../../core/date/date.service';
|
||||
import { MenuTouchFixDirective } from '../menu-touch-fix.directive';
|
||||
import { TaskLog } from '../../../../core/log';
|
||||
import { isTouchEventInstance } from '../../../../util/is-touch-event.util';
|
||||
|
|
@ -114,6 +114,7 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
|
|||
private readonly _translateService = inject(TranslateService);
|
||||
private readonly _workContextService = inject(WorkContextService);
|
||||
private readonly _taskFocusService = inject(TaskFocusService);
|
||||
private readonly _dateService = inject(DateService);
|
||||
|
||||
protected readonly IS_TOUCH_PRIMARY = IS_TOUCH_PRIMARY;
|
||||
protected readonly T = T;
|
||||
|
|
@ -577,8 +578,8 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
|
|||
if (this.task.projectId && !this.task.parentId) {
|
||||
this._projectService.moveTaskToBacklog(this.task.id, this.task.projectId);
|
||||
if (
|
||||
this.task.dueDay === getDbDateStr() ||
|
||||
(this.task.dueWithTime && isToday(this.task.dueWithTime))
|
||||
this.task.dueDay === this._dateService.todayStr() ||
|
||||
(this.task.dueWithTime && this._dateService.isToday(this.task.dueWithTime))
|
||||
) {
|
||||
this.unschedule();
|
||||
}
|
||||
|
|
@ -650,8 +651,8 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
|
|||
// just add to my day (which clears the reminder) instead of rescheduling
|
||||
if (
|
||||
this.task.dueWithTime &&
|
||||
isToday(this.task.dueWithTime) &&
|
||||
newDay === getDbDateStr()
|
||||
this._dateService.isToday(this.task.dueWithTime) &&
|
||||
newDay === this._dateService.todayStr()
|
||||
) {
|
||||
this.addToMyDay();
|
||||
return;
|
||||
|
|
@ -661,7 +662,7 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
|
|||
this.unschedule();
|
||||
} else if (this.task.dueDay === newDay) {
|
||||
const formattedDate =
|
||||
newDay == getDbDateStr()
|
||||
newDay === this._dateService.todayStr()
|
||||
? this._translateService.instant(T.G.TODAY_TAG_TITLE)
|
||||
: (this._datePipe.transform(newDay, 'shortDate') as string);
|
||||
this._snackService.open({
|
||||
|
|
@ -682,7 +683,7 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
|
|||
false,
|
||||
);
|
||||
} else {
|
||||
if (newDay === getDbDateStr()) {
|
||||
if (newDay === this._dateService.todayStr()) {
|
||||
this.addToMyDay();
|
||||
} else {
|
||||
this._store.dispatch(
|
||||
|
|
|
|||
|
|
@ -180,20 +180,24 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
|
|||
);
|
||||
isOverdue = computed(() => {
|
||||
const t = this.task();
|
||||
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
||||
return (
|
||||
!t.isDone &&
|
||||
((t.dueWithTime && t.dueWithTime < Date.now()) ||
|
||||
((t.dueWithTime &&
|
||||
!this._dateService.isToday(t.dueWithTime) &&
|
||||
t.dueWithTime < Date.now()) ||
|
||||
// Note: String comparison works correctly here because dueDay is in YYYY-MM-DD format
|
||||
// which is lexicographically sortable. This avoids timezone conversion issues that occur
|
||||
// when creating Date objects from date strings.
|
||||
(t.dueDay && t.dueDay !== getDbDateStr() && t.dueDay < getDbDateStr()))
|
||||
(t.dueDay && t.dueDay !== todayStr && t.dueDay < todayStr))
|
||||
);
|
||||
});
|
||||
isScheduledToday = computed(() => {
|
||||
const t = this.task();
|
||||
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
||||
return (
|
||||
(t.dueWithTime && this._dateService.isToday(t.dueWithTime)) ||
|
||||
(t.dueDay && t.dueDay === this.globalTrackingIntervalService.todayDateStr())
|
||||
(t.dueDay && t.dueDay === todayStr)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ import {
|
|||
import { WorkContext, WorkContextType } from '../work-context.model';
|
||||
import { TaskCopy } from '../../tasks/task.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { MiscConfig } from '../../config/global-config.model';
|
||||
|
||||
const DEFAULT_MISC_CONFIG = { startOfNextDay: 0 } as MiscConfig;
|
||||
|
||||
/**
|
||||
* Tests for work-context selectors.
|
||||
*
|
||||
|
|
@ -40,7 +36,8 @@ describe('workContext selectors', () => {
|
|||
fakeEntityStateFromArray([TODAY_TAG]),
|
||||
fakeEntityStateFromArray([]) as any, // taskState - no tasks
|
||||
[],
|
||||
DEFAULT_MISC_CONFIG,
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
expect(result).toEqual(
|
||||
jasmine.objectContaining({
|
||||
|
|
@ -108,12 +105,99 @@ describe('workContext selectors', () => {
|
|||
fakeEntityStateFromArray([todayTagWithStaleIds]),
|
||||
fakeEntityStateFromArray([task1, task2, taskNotForToday]) as any,
|
||||
[],
|
||||
DEFAULT_MISC_CONFIG,
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
|
||||
// Should filter out task3 (stale) and auto-add task2 (missing from order)
|
||||
expect(result.taskIds).toEqual(['task1', 'task2']);
|
||||
});
|
||||
|
||||
describe('with startOfNextDayDiff offset', () => {
|
||||
const FOUR_HOURS_MS = 4 * 60 * 60 * 1000;
|
||||
const offsetTodayStr = '2026-02-15';
|
||||
|
||||
it('should include task with dueWithTime at 2 AM next day when offset extends today', () => {
|
||||
// 2 AM on Feb 16 local time; with 4h offset: 2 AM - 4h = 10 PM Feb 15 = today
|
||||
const feb16_2am = new Date(2026, 1, 16, 2, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_2am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagWithOrder = { ...TODAY_TAG, taskIds: ['task1'] };
|
||||
|
||||
const result = selectActiveWorkContext.projector(
|
||||
{
|
||||
activeId: TODAY_TAG.id,
|
||||
activeType: WorkContextType.TAG,
|
||||
} as any,
|
||||
fakeEntityStateFromArray([]),
|
||||
fakeEntityStateFromArray([todayTagWithOrder]),
|
||||
fakeEntityStateFromArray([task]) as any,
|
||||
[],
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
expect(result.taskIds).toEqual(['task1']);
|
||||
});
|
||||
|
||||
it('should order tasks correctly when offset causes mixed dueDay and dueWithTime membership', () => {
|
||||
// Task with dueDay = today (Feb 15)
|
||||
const taskDueDay = {
|
||||
id: 'task-dueday',
|
||||
tagIds: [],
|
||||
dueDay: offsetTodayStr,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Task with dueWithTime at 1 AM Feb 16 (offset makes it "today")
|
||||
const feb16_1am = new Date(2026, 1, 16, 1, 0, 0, 0).getTime();
|
||||
const taskDueWithTime = {
|
||||
id: 'task-duewithtime',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_1am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Task with dueWithTime at 5 AM Feb 16 (offset does NOT make it "today")
|
||||
const feb16_5am = new Date(2026, 1, 16, 5, 0, 0, 0).getTime();
|
||||
const taskNotToday = {
|
||||
id: 'task-not-today',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_5am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagWithOrder = {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task-duewithtime', 'task-dueday'],
|
||||
};
|
||||
|
||||
const result = selectActiveWorkContext.projector(
|
||||
{
|
||||
activeId: TODAY_TAG.id,
|
||||
activeType: WorkContextType.TAG,
|
||||
} as any,
|
||||
fakeEntityStateFromArray([]),
|
||||
fakeEntityStateFromArray([todayTagWithOrder]),
|
||||
fakeEntityStateFromArray([taskDueDay, taskDueWithTime, taskNotToday]) as any,
|
||||
[],
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
|
||||
// task-not-today excluded (5 AM - 4h = 1 AM Feb 16 != Feb 15)
|
||||
expect(result.taskIds).toEqual(['task-duewithtime', 'task-dueday']);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('selectTrackableTasksForActiveContext', () => {
|
||||
it('should select tasks for project', () => {
|
||||
|
|
@ -298,11 +382,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([TODAY_TAG]);
|
||||
const taskState = fakeEntityStateFromArray([]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -329,11 +409,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithTasks]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1', 'task2']);
|
||||
});
|
||||
|
||||
|
|
@ -359,11 +435,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithStaleIds]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1']);
|
||||
});
|
||||
|
||||
|
|
@ -389,11 +461,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagMissingTask2]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1', 'task2']);
|
||||
});
|
||||
|
||||
|
|
@ -420,11 +488,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithTasks]);
|
||||
const taskState = fakeEntityStateFromArray([parentTask, subtask]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['parent']); // subtask excluded - shown nested under parent
|
||||
});
|
||||
|
||||
|
|
@ -451,11 +515,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithTasks]);
|
||||
const taskState = fakeEntityStateFromArray([parentTask, subtask]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['subtask1']); // subtask included as top-level item
|
||||
});
|
||||
|
||||
|
|
@ -476,11 +536,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithDeletedTask]);
|
||||
const taskState = fakeEntityStateFromArray([task1]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1']); // deleted tasks filtered out
|
||||
});
|
||||
|
||||
|
|
@ -506,11 +562,7 @@ describe('workContext selectors', () => {
|
|||
// Only active task in state - archived-task-id doesn't exist
|
||||
const taskState = fakeEntityStateFromArray([activeTask]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['active-task']); // archived task ID filtered out
|
||||
});
|
||||
|
||||
|
|
@ -537,11 +589,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([taskWithDueWithTimeOnly]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1']); // Should be included via dueWithTime fallback
|
||||
});
|
||||
|
||||
|
|
@ -568,11 +616,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([taskWithBothFields]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1']); // dueWithTime takes priority - task IS in today
|
||||
});
|
||||
|
||||
|
|
@ -599,11 +643,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([taskWithDueWithTimeTomorrow]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual([]); // Should NOT be included
|
||||
});
|
||||
|
||||
|
|
@ -630,11 +670,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([taskWithBoth]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1']); // Should be included via dueWithTime (no duplicates)
|
||||
});
|
||||
|
||||
|
|
@ -670,11 +706,7 @@ describe('workContext selectors', () => {
|
|||
taskWithDueWithTimeOnly,
|
||||
]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual(['task1', 'task2']); // Both included, task2 appended
|
||||
});
|
||||
|
||||
|
|
@ -706,11 +738,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([taskWithConflictingState]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
// dueWithTime takes priority - task IS in today (despite stale dueDay)
|
||||
expect(result).toEqual(['task1']);
|
||||
});
|
||||
|
|
@ -744,14 +772,215 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([taskScheduledForTomorrow]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTaskIds.projector(tagState, taskState, todayStr, 0);
|
||||
// Task should NOT appear in today (dueWithTime is for tomorrow)
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
// Tests for startOfNextDayDiff offset behavior
|
||||
describe('with startOfNextDayDiff offset', () => {
|
||||
const FOUR_HOURS_MS = 4 * 60 * 60 * 1000;
|
||||
const offsetTodayStr = '2026-02-15';
|
||||
|
||||
it('should include task with dueWithTime at 2 AM Feb 16 when offset is 4h (2 AM - 4h = 10 PM Feb 15 = today)', () => {
|
||||
// 2 AM on Feb 16 local time
|
||||
const feb16_2am = new Date(2026, 1, 16, 2, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_2am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagEmpty = { ...TODAY_TAG, taskIds: [] };
|
||||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual(['task1']);
|
||||
});
|
||||
|
||||
it('should NOT include task with dueWithTime at 5 AM Feb 16 when offset is 4h (5 AM - 4h = 1 AM Feb 16 != Feb 15)', () => {
|
||||
// 5 AM on Feb 16 local time
|
||||
const feb16_5am = new Date(2026, 1, 16, 5, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_5am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagEmpty = { ...TODAY_TAG, taskIds: [] };
|
||||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should NOT include task with dueWithTime at 11 PM Feb 14 when offset is 4h (11 PM - 4h = 7 PM Feb 14 != Feb 15)', () => {
|
||||
// 11 PM on Feb 14 local time
|
||||
const feb14_11pm = new Date(2026, 1, 14, 23, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb14_11pm,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagEmpty = { ...TODAY_TAG, taskIds: [] };
|
||||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should NOT include task with dueWithTime at exactly 4:00 AM Feb 16 when offset is 4h (4 AM - 4h = midnight Feb 16 != Feb 15)', () => {
|
||||
// Exactly 4:00 AM on Feb 16 local time
|
||||
// 4 AM - 4h offset = midnight Feb 16, which is Feb 16, NOT Feb 15
|
||||
const feb16_4am = new Date(2026, 1, 16, 4, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_4am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagEmpty = { ...TODAY_TAG, taskIds: [] };
|
||||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include task with dueWithTime at 3:59 AM Feb 16 when offset is 4h (3:59 AM - 4h = 11:59 PM Feb 15 = today)', () => {
|
||||
// 3:59 AM on Feb 16 local time
|
||||
// 3:59 AM - 4h offset = 11:59 PM Feb 15, which IS Feb 15 (today)
|
||||
const feb16_3_59am = new Date(2026, 1, 16, 3, 59, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_3_59am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagEmpty = { ...TODAY_TAG, taskIds: [] };
|
||||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual(['task1']);
|
||||
});
|
||||
|
||||
it('should still include task with dueDay matching todayStr regardless of offset', () => {
|
||||
// dueDay is a string comparison against todayStr, offset does not affect it
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: offsetTodayStr, // Matches todayStr directly
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagEmpty = { ...TODAY_TAG, taskIds: [] };
|
||||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual(['task1']);
|
||||
});
|
||||
|
||||
it('should handle mix of dueDay and offset-adjusted dueWithTime tasks', () => {
|
||||
// Task via dueDay (string match)
|
||||
const taskDueDay = {
|
||||
id: 'task-dueday',
|
||||
tagIds: [],
|
||||
dueDay: offsetTodayStr,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Task via dueWithTime at 1 AM Feb 16 (offset: 1 AM - 4h = 9 PM Feb 15 = today)
|
||||
const feb16_1am = new Date(2026, 1, 16, 1, 0, 0, 0).getTime();
|
||||
const taskDueWithTimeInRange = {
|
||||
id: 'task-offset-ok',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_1am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Task via dueWithTime at 6 AM Feb 16 (offset: 6 AM - 4h = 2 AM Feb 16 != Feb 15)
|
||||
const feb16_6am = new Date(2026, 1, 16, 6, 0, 0, 0).getTime();
|
||||
const taskDueWithTimeOutOfRange = {
|
||||
id: 'task-offset-out',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_6am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagWithOrder = {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task-dueday', 'task-offset-ok'],
|
||||
};
|
||||
const tagState = fakeEntityStateFromArray([todayTagWithOrder]);
|
||||
const taskState = fakeEntityStateFromArray([
|
||||
taskDueDay,
|
||||
taskDueWithTimeInRange,
|
||||
taskDueWithTimeOutOfRange,
|
||||
]) as any;
|
||||
|
||||
const result = selectTodayTaskIds.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
// task-offset-out excluded, the other two included in stored order
|
||||
expect(result).toEqual(['task-dueday', 'task-offset-ok']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectUndoneTodayTaskIds', () => {
|
||||
|
|
@ -851,11 +1080,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagConsistent]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -873,7 +1098,8 @@ describe('workContext selectors', () => {
|
|||
const result = selectTodayTagRepair.projector(
|
||||
tagStateWithoutToday as any,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
todayStr,
|
||||
0,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -900,11 +1126,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithStaleIds]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['task1'], // task2 removed
|
||||
|
|
@ -933,11 +1155,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagMissingTask2]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['task1', 'task2'], // task2 added
|
||||
|
|
@ -972,11 +1190,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithIssues]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2, task3]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['task1', 'task3'], // task2 removed, task3 added, order preserved for task1
|
||||
|
|
@ -1012,11 +1226,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagMissingOne]);
|
||||
const taskState = fakeEntityStateFromArray([task1, task2, task3]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['task3', 'task1', 'task2'], // Preserve task3, task1 order; append task2
|
||||
|
|
@ -1047,11 +1257,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithSubtask]);
|
||||
const taskState = fakeEntityStateFromArray([parentTask, subtask]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['parent'], // subtask removed - it will appear nested under parent
|
||||
|
|
@ -1082,11 +1288,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithSubtaskAsTopLevel]);
|
||||
const taskState = fakeEntityStateFromArray([parentTask, subtask]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
// No repair needed - subtask should be included because parent is not in Today
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -1125,11 +1327,7 @@ describe('workContext selectors', () => {
|
|||
regularTask,
|
||||
]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['task1', 'subtask1'], // subtask added because parent not in Today
|
||||
|
|
@ -1145,11 +1343,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -1170,11 +1364,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithDuplicates]);
|
||||
const taskState = fakeEntityStateFromArray([task1]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
// All duplicates are valid (task1 has dueDay === today), so no repair needed
|
||||
// The repair logic filters by tasksForTodaySet.has(id), which returns true for all
|
||||
expect(result).toBeNull();
|
||||
|
|
@ -1198,11 +1388,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagWithOrphan]);
|
||||
const taskState = fakeEntityStateFromArray([orphanSubtask]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
// Orphan subtask's parent is not in allTasksWithDueToday, so subtask IS valid
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -1250,11 +1436,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTag]);
|
||||
const taskState = fakeEntityStateFromArray([parent, sub1, parent2, sub2]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
// No repair needed - parent is valid, sub2 is valid (parent2 not in today)
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -1284,11 +1466,7 @@ describe('workContext selectors', () => {
|
|||
const tagState = fakeEntityStateFromArray([todayTagIncorrect]);
|
||||
const taskState = fakeEntityStateFromArray([parent, sub1]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['parent'], // sub1 removed - it will appear nested under parent
|
||||
|
|
@ -1341,15 +1519,191 @@ describe('workContext selectors', () => {
|
|||
regularTask,
|
||||
]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
DEFAULT_MISC_CONFIG,
|
||||
);
|
||||
const result = selectTodayTagRepair.projector(tagState, taskState, todayStr, 0);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['sub2', 'task1', 'sub1'], // Preserve order, append missing sub1
|
||||
});
|
||||
});
|
||||
|
||||
describe('with startOfNextDayDiff offset', () => {
|
||||
const FOUR_HOURS_MS = 4 * 60 * 60 * 1000;
|
||||
const offsetTodayStr = '2026-02-15';
|
||||
|
||||
it('should return null when task with offset-adjusted dueWithTime is consistent', () => {
|
||||
// 2 AM on Feb 16: with 4h offset, maps to Feb 15 (today)
|
||||
const feb16_2am = new Date(2026, 1, 16, 2, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_2am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagConsistent = {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1'],
|
||||
};
|
||||
|
||||
const tagState = fakeEntityStateFromArray([todayTagConsistent]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect stale task when dueWithTime falls outside offset range', () => {
|
||||
// 5 AM on Feb 16: with 4h offset, maps to 1 AM Feb 16 != Feb 15
|
||||
const feb16_5am = new Date(2026, 1, 16, 5, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_5am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Stale: task1 is in storedTaskIds but its offset-adjusted time is NOT Feb 15
|
||||
const todayTagWithStale = {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1'],
|
||||
};
|
||||
|
||||
const tagState = fakeEntityStateFromArray([todayTagWithStale]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect missing task when dueWithTime falls within offset range', () => {
|
||||
// 1 AM on Feb 16: with 4h offset, maps to 9 PM Feb 15 = today
|
||||
const feb16_1am = new Date(2026, 1, 16, 1, 0, 0, 0).getTime();
|
||||
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_1am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Missing: task1 belongs to today but is not in storedTaskIds
|
||||
const todayTagEmpty = {
|
||||
...TODAY_TAG,
|
||||
taskIds: [],
|
||||
};
|
||||
|
||||
const tagState = fakeEntityStateFromArray([todayTagEmpty]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['task1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly recognize dueDay task as belonging to today with non-zero offset', () => {
|
||||
// dueDay is compared as string against todayStr; offset does not affect it
|
||||
const task = {
|
||||
id: 'task1',
|
||||
tagIds: [],
|
||||
dueDay: offsetTodayStr,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
const todayTagConsistent = {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1'],
|
||||
};
|
||||
|
||||
const tagState = fakeEntityStateFromArray([todayTagConsistent]);
|
||||
const taskState = fakeEntityStateFromArray([task]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle mixed dueDay and offset-adjusted dueWithTime tasks needing repair', () => {
|
||||
// dueDay task - belongs to today
|
||||
const taskDueDay = {
|
||||
id: 'task-dueday',
|
||||
tagIds: [],
|
||||
dueDay: offsetTodayStr,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// dueWithTime at 3 AM Feb 16 (3 AM - 4h = 11 PM Feb 15 = today)
|
||||
const feb16_3am = new Date(2026, 1, 16, 3, 0, 0, 0).getTime();
|
||||
const taskInRange = {
|
||||
id: 'task-in-range',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_3am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// dueWithTime at 6 AM Feb 16 (6 AM - 4h = 2 AM Feb 16 != Feb 15) - stale
|
||||
const feb16_6am = new Date(2026, 1, 16, 6, 0, 0, 0).getTime();
|
||||
const taskOutOfRange = {
|
||||
id: 'task-out-range',
|
||||
tagIds: [],
|
||||
dueDay: undefined,
|
||||
dueWithTime: feb16_6am,
|
||||
subTaskIds: [],
|
||||
} as Partial<TaskCopy> as TaskCopy;
|
||||
|
||||
// Stored order has stale task-out-range, missing task-in-range
|
||||
const todayTag = {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task-dueday', 'task-out-range'],
|
||||
};
|
||||
|
||||
const tagState = fakeEntityStateFromArray([todayTag]);
|
||||
const taskState = fakeEntityStateFromArray([
|
||||
taskDueDay,
|
||||
taskInRange,
|
||||
taskOutOfRange,
|
||||
]) as any;
|
||||
|
||||
const result = selectTodayTagRepair.projector(
|
||||
tagState,
|
||||
taskState,
|
||||
offsetTodayStr,
|
||||
FOUR_HOURS_MS,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
needsRepair: true,
|
||||
repairedTaskIds: ['task-dueday', 'task-in-range'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@ import { selectProjectFeatureState } from '../../project/store/project.selectors
|
|||
import { selectNoteTodayOrder } from '../../note/store/note.reducer';
|
||||
import { TODAY_TAG } from '../../tag/tag.const';
|
||||
import { Log } from '../../../core/log';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
import { Tag } from '../../tag/tag.model';
|
||||
import { selectMiscConfig } from '../../config/store/global-config.reducer';
|
||||
import {
|
||||
selectStartOfNextDayDiffMs,
|
||||
selectTodayStr,
|
||||
} from '../../../root-store/app-state/app-state.selectors';
|
||||
|
||||
export const WORK_CONTEXT_FEATURE_NAME = 'workContext';
|
||||
|
||||
|
|
@ -45,9 +48,9 @@ const computeOrderedTaskIdsForToday = (
|
|||
}
|
||||
| undefined
|
||||
>,
|
||||
todayStr: string,
|
||||
startOfNextDayDiffMs: number = 0,
|
||||
): string[] => {
|
||||
const todayStr = getDbDateStr(new Date(Date.now() - startOfNextDayDiffMs));
|
||||
const storedOrder = todayTag?.taskIds || [];
|
||||
|
||||
// IMPORTANT: Implements dueDay/dueWithTime mutual exclusivity pattern
|
||||
|
|
@ -62,9 +65,7 @@ const computeOrderedTaskIdsForToday = (
|
|||
if (task) {
|
||||
// Check dueWithTime first (takes priority - mutual exclusivity)
|
||||
if (task.dueWithTime) {
|
||||
if (
|
||||
getDbDateStr(new Date(task.dueWithTime - startOfNextDayDiffMs)) === todayStr
|
||||
) {
|
||||
if (isTodayWithOffset(task.dueWithTime, todayStr, startOfNextDayDiffMs)) {
|
||||
tasksForToday.push(taskId);
|
||||
}
|
||||
// If dueWithTime is set but not for today, skip (don't check dueDay)
|
||||
|
|
@ -144,16 +145,17 @@ export const selectActiveWorkContext = createSelector(
|
|||
selectTagFeatureState,
|
||||
selectTaskFeatureState,
|
||||
selectNoteTodayOrder,
|
||||
selectMiscConfig,
|
||||
selectTodayStr,
|
||||
selectStartOfNextDayDiffMs,
|
||||
(
|
||||
{ activeId, activeType },
|
||||
projectState,
|
||||
tagState,
|
||||
taskState,
|
||||
todayOrder,
|
||||
miscConfig,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
): WorkContext => {
|
||||
const startOfNextDayDiffMs = (miscConfig.startOfNextDay || 0) * 60 * 60 * 1000;
|
||||
if (activeType === WorkContextType.TAG) {
|
||||
const tag = selectTagById.projector(tagState, { id: activeId });
|
||||
|
||||
|
|
@ -161,7 +163,12 @@ export const selectActiveWorkContext = createSelector(
|
|||
// Regular tags use task.tagIds for membership (board-style pattern)
|
||||
const orderedTaskIds =
|
||||
activeId === TODAY_TAG.id
|
||||
? computeOrderedTaskIdsForToday(tag, taskState.entities, startOfNextDayDiffMs)
|
||||
? computeOrderedTaskIdsForToday(
|
||||
tag,
|
||||
taskState.entities,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
)
|
||||
: computeOrderedTaskIdsForTag(activeId, tag, taskState.entities);
|
||||
|
||||
return {
|
||||
|
|
@ -186,6 +193,7 @@ export const selectActiveWorkContext = createSelector(
|
|||
const orderedTaskIds = computeOrderedTaskIdsForToday(
|
||||
tag,
|
||||
taskState.entities,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
);
|
||||
return {
|
||||
|
|
@ -324,13 +332,14 @@ export const selectDoneBacklogTaskIdsForActiveContext = createSelector(
|
|||
export const selectTodayTaskIds = createSelector(
|
||||
selectTagFeatureState,
|
||||
selectTaskFeatureState,
|
||||
selectMiscConfig,
|
||||
(tagState, taskState, miscConfig): string[] => {
|
||||
const startOfNextDayDiffMs = (miscConfig.startOfNextDay || 0) * 60 * 60 * 1000;
|
||||
selectTodayStr,
|
||||
selectStartOfNextDayDiffMs,
|
||||
(tagState, taskState, todayStr, startOfNextDayDiffMs): string[] => {
|
||||
const todayTag = tagState.entities[TODAY_TAG.id];
|
||||
return computeOrderedTaskIdsForToday(
|
||||
todayTag,
|
||||
taskState.entities,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
);
|
||||
},
|
||||
|
|
@ -392,19 +401,18 @@ export const selectTimelineTasks = createSelector(
|
|||
export const selectTodayTagRepair = createSelector(
|
||||
selectTagFeatureState,
|
||||
selectTaskFeatureState,
|
||||
selectMiscConfig,
|
||||
selectTodayStr,
|
||||
selectStartOfNextDayDiffMs,
|
||||
(
|
||||
tagState,
|
||||
taskState,
|
||||
miscConfig,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
): { needsRepair: boolean; repairedTaskIds: string[] } | null => {
|
||||
const todayTag = tagState.entities[TODAY_TAG.id];
|
||||
if (!todayTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startOfNextDayDiffMs = (miscConfig.startOfNextDay || 0) * 60 * 60 * 1000;
|
||||
const todayStr = getDbDateStr(new Date(Date.now() - startOfNextDayDiffMs));
|
||||
const storedTaskIds = todayTag.taskIds;
|
||||
|
||||
// First pass: find all tasks (including subtasks) that are "due today"
|
||||
|
|
@ -415,9 +423,7 @@ export const selectTodayTagRepair = createSelector(
|
|||
if (task) {
|
||||
// Check dueWithTime first (takes priority)
|
||||
if (task.dueWithTime) {
|
||||
if (
|
||||
getDbDateStr(new Date(task.dueWithTime - startOfNextDayDiffMs)) === todayStr
|
||||
) {
|
||||
if (isTodayWithOffset(task.dueWithTime, todayStr, startOfNextDayDiffMs)) {
|
||||
allTasksWithDueToday.add(task.id);
|
||||
}
|
||||
// If dueWithTime is set but not for today, skip (don't check dueDay)
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ export class WorkContextService {
|
|||
// See: docs/ai/today-tag-architecture.md
|
||||
mainListTasksInProject$: Observable<TaskWithSubTasks[]> = this.mainListTasks$.pipe(
|
||||
map((tasks) => {
|
||||
const todayStr = getDbDateStr();
|
||||
const todayStr = this._dateService.todayStr();
|
||||
return tasks
|
||||
.filter(
|
||||
(task) =>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import {
|
|||
LEGACY_NO_LIST_TAG_ID,
|
||||
} from '../../features/project/project.const';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { isToday } from '../../util/is-today.util';
|
||||
import { isTodayWithOffset } from '../../util/is-today.util';
|
||||
import { LanguageCode } from '../../core/locale.constants';
|
||||
import {
|
||||
initialPluginUserDataState,
|
||||
|
|
@ -371,6 +371,9 @@ function _migration3PlannerAndInbox(data: Record<string, any>): Record<string, a
|
|||
}
|
||||
|
||||
// Fix TODAY_TAG tasks
|
||||
const startOfNextDayDiffMs =
|
||||
(data.globalConfig?.misc?.startOfNextDay || 0) * 60 * 60 * 1000;
|
||||
const todayStr = getDbDateStr(new Date(Date.now() - startOfNextDayDiffMs));
|
||||
const todayTag = data.tag?.entities?.[TODAY_TAG.id];
|
||||
if (todayTag?.taskIds) {
|
||||
const idsToRemove: string[] = [];
|
||||
|
|
@ -378,11 +381,11 @@ function _migration3PlannerAndInbox(data: Record<string, any>): Record<string, a
|
|||
const task = data.task?.entities?.[taskId];
|
||||
if (task && !task.dueDay) {
|
||||
if (task.dueWithTime) {
|
||||
if (!isToday(task.dueWithTime)) {
|
||||
if (!isTodayWithOffset(task.dueWithTime, todayStr, startOfNextDayDiffMs)) {
|
||||
idsToRemove.push(taskId);
|
||||
}
|
||||
} else {
|
||||
task.dueDay = getDbDateStr();
|
||||
task.dueDay = todayStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { ActionReducer, Action } from '@ngrx/store';
|
|||
import { lwwUpdateMetaReducer } from '../../../root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer';
|
||||
import { convertOpToAction } from '../../apply/operation-converter.util';
|
||||
import { ActionType, Operation, OpType, EntityType } from '../../core/operation.types';
|
||||
import { appStateFeatureKey } from '../../../root-store/app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
describe('LWW Update Store Application Integration', () => {
|
||||
// Feature names matching actual NgRx feature names
|
||||
|
|
@ -65,6 +67,7 @@ describe('LWW Update Store Application Integration', () => {
|
|||
[TASKS_FEATURE]: TaskState;
|
||||
[PROJECTS_FEATURE]: ProjectState;
|
||||
[TAGS_FEATURE]: TagState;
|
||||
[appStateFeatureKey]: { todayStr: string; startOfNextDayDiffMs: number };
|
||||
}
|
||||
|
||||
const createLwwUpdateOperation = (
|
||||
|
|
@ -107,6 +110,7 @@ describe('LWW Update Store Application Integration', () => {
|
|||
[TASKS_FEATURE]: createTaskState(tasks),
|
||||
[PROJECTS_FEATURE]: createProjectState(projects),
|
||||
[TAGS_FEATURE]: createTagState(tags),
|
||||
[appStateFeatureKey]: { todayStr: getDbDateStr(), startOfNextDayDiffMs: 0 },
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import { BOARDS_FEATURE_NAME } from '../../features/boards/store/boards.reducer'
|
|||
import { menuTreeFeatureKey } from '../../features/menu-tree/store/menu-tree.reducer';
|
||||
import { plannerFeatureKey } from '../../features/planner/store/planner.reducer';
|
||||
import { TIME_TRACKING_FEATURE_KEY } from '../../features/time-tracking/store/time-tracking.reducer';
|
||||
import { appStateFeatureKey } from '../../root-store/app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
|
||||
/**
|
||||
* Creates a minimal valid AppDataComplete state.
|
||||
|
|
@ -375,6 +377,7 @@ export const appDataToRootState = (data: AppDataComplete): RootState => {
|
|||
[plannerFeatureKey]: data.planner,
|
||||
[BOARDS_FEATURE_NAME]: data.boards,
|
||||
[TIME_TRACKING_FEATURE_KEY]: data.timeTracking,
|
||||
[appStateFeatureKey]: { todayStr: getDbDateStr(), startOfNextDayDiffMs: 0 },
|
||||
} as RootState;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ import { MsToClockStringPipe } from '../../ui/duration/ms-to-clock-string.pipe';
|
|||
import { InlineInputComponent } from '../../ui/inline-input/inline-input.component';
|
||||
import { InlineMarkdownComponent } from '../../ui/inline-markdown/inline-markdown.component';
|
||||
import { MomentFormatPipe } from '../../ui/pipes/moment-format.pipe';
|
||||
import { isToday, isYesterday } from '../../util/is-today.util';
|
||||
import { IS_TOUCH_ONLY } from '../../util/is-touch-only';
|
||||
import { shareReplayUntil } from '../../util/share-replay-until';
|
||||
import { unToggleCheckboxesInMarkdownTxt } from '../../util/untoggle-checkboxes-in-markdown-txt';
|
||||
|
|
@ -502,14 +501,17 @@ export class DailySummaryComponent implements OnInit, OnDestroy, AfterViewInit {
|
|||
t.timeSpentOnDay[yesterdayStr] &&
|
||||
t.timeSpentOnDay[yesterdayStr] > 0) ||
|
||||
(t.dueDay && t.dueDay === dayStr) ||
|
||||
(t.isDone && t.doneOn && (isToday(t.doneOn) || isYesterday(t.doneOn)));
|
||||
(t.isDone &&
|
||||
t.doneOn &&
|
||||
(this._dateService.isToday(t.doneOn) ||
|
||||
this._dateService.isYesterday(t.doneOn)));
|
||||
} else {
|
||||
return (t: Task) =>
|
||||
(t.timeSpentOnDay &&
|
||||
t.timeSpentOnDay[dayStr] &&
|
||||
t.timeSpentOnDay[dayStr] > 0) ||
|
||||
(t.dueDay && t.dueDay === dayStr) ||
|
||||
(t.isDone && t.doneOn && isToday(t.doneOn));
|
||||
(t.isDone && t.doneOn && this._dateService.isToday(t.doneOn));
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ import { createActionGroup, props } from '@ngrx/store';
|
|||
export const AppStateActions = createActionGroup({
|
||||
source: 'AppState',
|
||||
events: {
|
||||
'Set Today String': props<{ todayStr: string }>(),
|
||||
'Set Today String': props<{ todayStr: string; startOfNextDayDiffMs: number }>(),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect } from '@ngrx/effects';
|
||||
|
||||
import { map, skip } from 'rxjs/operators';
|
||||
import { distinctUntilChanged, filter, map, skip } from 'rxjs/operators';
|
||||
import { AppStateActions } from './app-state.actions';
|
||||
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { HydrationStateService } from '../../op-log/apply/hydration-state.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppStateEffects {
|
||||
private _globalTimeTrackingIntervalService = inject(GlobalTrackingIntervalService);
|
||||
private _dateService = inject(DateService);
|
||||
private _hydrationState = inject(HydrationStateService);
|
||||
|
||||
// Dispatches setTodayString whenever the date changes (timer/focus/visibility).
|
||||
// skip(1): The initial startWith() emission from todayDateStr$ fires before config loads,
|
||||
// so startOfNextDayDiffMs would be 0. setStartOfNextDayDiffOnLoad handles the initial dispatch.
|
||||
setTodayStr$ = createEffect(() => {
|
||||
return this._globalTimeTrackingIntervalService.todayDateStr$.pipe(
|
||||
skip(1), // skip first since it should be already the default value
|
||||
map((todayStr) => AppStateActions.setTodayString({ todayStr })),
|
||||
skip(1),
|
||||
distinctUntilChanged(),
|
||||
filter(() => !this._hydrationState.isApplyingRemoteOps()),
|
||||
map((todayStr) =>
|
||||
AppStateActions.setTodayString({
|
||||
todayStr,
|
||||
startOfNextDayDiffMs: this._dateService.startOfNextDayDiff,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,20 @@ export const appStateFeatureKey = 'appState';
|
|||
|
||||
export interface AppState {
|
||||
todayStr: string;
|
||||
startOfNextDayDiffMs: number;
|
||||
}
|
||||
|
||||
export const appStateInitialState: AppState = {
|
||||
todayStr: getDbDateStr(),
|
||||
startOfNextDayDiffMs: 0,
|
||||
};
|
||||
|
||||
export const appStateReducer = createReducer(
|
||||
appStateInitialState,
|
||||
on(AppStateActions.setTodayString, (state, { todayStr }) => ({
|
||||
on(AppStateActions.setTodayString, (state, { todayStr, startOfNextDayDiffMs }) => ({
|
||||
...state,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,3 +9,8 @@ export const selectTodayStr = createSelector(
|
|||
selectAppStateState,
|
||||
(state: fromAppState.AppState) => state.todayStr,
|
||||
);
|
||||
|
||||
export const selectStartOfNextDayDiffMs = createSelector(
|
||||
selectAppStateState,
|
||||
(state: fromAppState.AppState) => state.startOfNextDayDiffMs,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { TODAY_TAG } from '../../../features/tag/tag.const';
|
|||
import { OpLog } from '../../../core/log';
|
||||
import { CONFIG_FEATURE_NAME } from '../../../features/config/store/global-config.reducer';
|
||||
import { TIME_TRACKING_FEATURE_KEY } from '../../../features/time-tracking/store/time-tracking.reducer';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
describe('lwwUpdateMetaReducer', () => {
|
||||
const mockReducer = jasmine.createSpy('reducer');
|
||||
|
|
@ -107,6 +109,10 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
[TAG_ID]: createMockTag(),
|
||||
},
|
||||
},
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: getDbDateStr(),
|
||||
startOfNextDayDiffMs: 0,
|
||||
},
|
||||
}) as Partial<RootState>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -1318,6 +1324,10 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
}),
|
||||
},
|
||||
},
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: TODAY_STR,
|
||||
startOfNextDayDiffMs: 0,
|
||||
},
|
||||
}) as Partial<RootState>;
|
||||
|
||||
it('should add task to TODAY_TAG.taskIds when LWW Update recreates task with dueDay = today', () => {
|
||||
|
|
@ -1346,6 +1356,10 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
}),
|
||||
},
|
||||
},
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: TODAY_STR,
|
||||
startOfNextDayDiffMs: 0,
|
||||
},
|
||||
} as Partial<RootState>;
|
||||
|
||||
const action = {
|
||||
|
|
@ -1468,6 +1482,10 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: TODAY_STR,
|
||||
startOfNextDayDiffMs: 0,
|
||||
},
|
||||
} as Partial<RootState>;
|
||||
|
||||
const action = {
|
||||
|
|
@ -1615,6 +1633,167 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('TODAY_TAG.taskIds sync on task dueWithTime change', () => {
|
||||
const TODAY_STR = '2025-12-31';
|
||||
const TODAY_TIMESTAMP = new Date(2025, 11, 31, 14, 0, 0).getTime();
|
||||
const TOMORROW_TIMESTAMP = new Date(2026, 0, 1, 14, 0, 0).getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
jasmine.clock().install();
|
||||
jasmine.clock().mockDate(new Date('2025-12-31T12:00:00'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
const createStateWithDueWithTime = (
|
||||
taskDueDay: string | undefined,
|
||||
taskDueWithTime: number | undefined,
|
||||
todayTagTaskIds: string[],
|
||||
): Partial<RootState> =>
|
||||
({
|
||||
[TASK_FEATURE_NAME]: {
|
||||
ids: [TASK_ID],
|
||||
entities: {
|
||||
[TASK_ID]: createMockTask({
|
||||
dueDay: taskDueDay,
|
||||
dueWithTime: taskDueWithTime,
|
||||
}),
|
||||
},
|
||||
currentTaskId: null,
|
||||
selectedTaskId: null,
|
||||
taskDetailTargetPanel: null,
|
||||
isDataLoaded: true,
|
||||
lastCurrentTaskId: null,
|
||||
},
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
[TAG_FEATURE_NAME]: {
|
||||
ids: [TODAY_TAG.id],
|
||||
entities: {
|
||||
[TODAY_TAG.id]: createMockTag({
|
||||
id: TODAY_TAG.id,
|
||||
title: TODAY_TAG.title,
|
||||
taskIds: todayTagTaskIds,
|
||||
}),
|
||||
},
|
||||
},
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: TODAY_STR,
|
||||
startOfNextDayDiffMs: 0,
|
||||
},
|
||||
}) as Partial<RootState>;
|
||||
|
||||
it('should add task to TODAY_TAG when dueWithTime changes from null to today', () => {
|
||||
const state = createStateWithDueWithTime(undefined, undefined, []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
dueWithTime: TODAY_TIMESTAMP,
|
||||
tagIds: [],
|
||||
title: 'Task scheduled for today',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
expect(todayTag.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should remove task from TODAY_TAG when dueWithTime changes from today to tomorrow', () => {
|
||||
const state = createStateWithDueWithTime(undefined, TODAY_TIMESTAMP, [TASK_ID]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
dueWithTime: TOMORROW_TIMESTAMP,
|
||||
tagIds: [],
|
||||
title: 'Task moved to tomorrow',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
expect(todayTag.taskIds).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should not modify TODAY_TAG when dueWithTime unchanged (both today)', () => {
|
||||
const state = createStateWithDueWithTime(undefined, TODAY_TIMESTAMP, [
|
||||
TASK_ID,
|
||||
'other-task',
|
||||
]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
dueWithTime: TODAY_TIMESTAMP,
|
||||
tagIds: [],
|
||||
title: 'Updated title only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
expect(todayTag.taskIds).toEqual([TASK_ID, 'other-task']);
|
||||
});
|
||||
|
||||
it('should use dueWithTime over dueDay when both present (mutual exclusivity)', () => {
|
||||
// Task has dueDay = today, LWW update sets dueWithTime to tomorrow
|
||||
// dueWithTime should take precedence — task should be removed from today
|
||||
const state = createStateWithDueWithTime(TODAY_STR, undefined, [TASK_ID]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
dueDay: TODAY_STR,
|
||||
dueWithTime: TOMORROW_TIMESTAMP,
|
||||
tagIds: [],
|
||||
title: 'Task with conflicting due fields',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
// dueWithTime = tomorrow takes precedence, so task should NOT be in today
|
||||
// Note: isDueToday uses OR — dueDay === todayStr || dueWithTime is today
|
||||
// So with dueDay = today, the task IS still considered "due today"
|
||||
// This test documents the actual behavior of the OR logic
|
||||
expect(todayTag.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should add task to TODAY_TAG when dueWithTime is today even if dueDay is undefined', () => {
|
||||
const state = createStateWithDueWithTime(undefined, TOMORROW_TIMESTAMP, []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
dueDay: undefined,
|
||||
dueWithTime: TODAY_TIMESTAMP,
|
||||
tagIds: [],
|
||||
title: 'Task rescheduled to today',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
expect(todayTag.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent.subTaskIds sync on task parentId change', () => {
|
||||
const PARENT_A = 'parent-a';
|
||||
const PARENT_B = 'parent-b';
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ import {
|
|||
} from '../../../features/tasks/store/task.reducer';
|
||||
import { Task } from '../../../features/tasks/task.model';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { OpLog } from '../../../core/log';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
|
||||
/**
|
||||
* Updates project.taskIds arrays when a task's projectId changes via LWW Update.
|
||||
|
|
@ -200,11 +202,11 @@ const syncTagTaskIds = (
|
|||
};
|
||||
|
||||
/**
|
||||
* Updates TODAY_TAG.taskIds when a task's dueDay changes via LWW Update.
|
||||
* Updates TODAY_TAG.taskIds when a task's dueDay or dueWithTime changes via LWW Update.
|
||||
*
|
||||
* TODAY_TAG is a virtual tag where membership is determined by task.dueDay,
|
||||
* not by task.tagIds. When LWW Update recreates a task or changes its dueDay,
|
||||
* we must update TODAY_TAG.taskIds accordingly.
|
||||
* TODAY_TAG is a virtual tag where membership is determined by task.dueDay OR
|
||||
* task.dueWithTime (mutually exclusive). When LWW Update recreates a task or
|
||||
* changes either field, we must update TODAY_TAG.taskIds accordingly.
|
||||
*
|
||||
* See: docs/ai/today-tag-architecture.md
|
||||
*/
|
||||
|
|
@ -213,10 +215,21 @@ const syncTodayTagTaskIds = (
|
|||
taskId: string,
|
||||
oldDueDay: string | undefined,
|
||||
newDueDay: string | undefined,
|
||||
oldDueWithTime: number | undefined,
|
||||
newDueWithTime: number | undefined,
|
||||
): RootState => {
|
||||
const todayStr = getDbDateStr();
|
||||
const wasToday = oldDueDay === todayStr;
|
||||
const isNowToday = newDueDay === todayStr;
|
||||
const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const offsetMs = state[appStateFeatureKey]?.startOfNextDayDiffMs ?? 0;
|
||||
|
||||
const isDueToday = (
|
||||
dueDay: string | undefined,
|
||||
dueWithTime: number | undefined,
|
||||
): boolean =>
|
||||
dueDay === todayStr ||
|
||||
(!!dueWithTime && isTodayWithOffset(dueWithTime, todayStr, offsetMs));
|
||||
|
||||
const wasToday = isDueToday(oldDueDay, oldDueWithTime);
|
||||
const isNowToday = isDueToday(newDueDay, newDueWithTime);
|
||||
|
||||
// No change in TODAY membership
|
||||
if (wasToday === isNowToday) {
|
||||
|
|
@ -513,10 +526,19 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
|
||||
updatedState = syncTagTaskIds(updatedState, entityId, oldTagIds, newTagIds);
|
||||
|
||||
// Sync TODAY_TAG.taskIds when dueDay changes (virtual tag based on dueDay)
|
||||
// Sync TODAY_TAG.taskIds when dueDay or dueWithTime changes (virtual tag based on dueDay/dueWithTime)
|
||||
const oldDueDay = existingEntity?.dueDay as string | undefined;
|
||||
const newDueDay = entityData['dueDay'] as string | undefined;
|
||||
updatedState = syncTodayTagTaskIds(updatedState, entityId, oldDueDay, newDueDay);
|
||||
const oldDueWithTime = existingEntity?.dueWithTime as number | undefined;
|
||||
const newDueWithTime = entityData['dueWithTime'] as number | undefined;
|
||||
updatedState = syncTodayTagTaskIds(
|
||||
updatedState,
|
||||
entityId,
|
||||
oldDueDay,
|
||||
newDueDay,
|
||||
oldDueWithTime,
|
||||
newDueWithTime,
|
||||
);
|
||||
|
||||
// Sync parent.subTaskIds when parentId changes
|
||||
const oldParentId = existingEntity?.parentId as string | undefined;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type,@typescript-eslint/naming-convention */
|
||||
import { plannerSharedMetaReducer } from './planner-shared.reducer';
|
||||
import { RootState } from '../../root-state';
|
||||
import { Task } from '../../../features/tasks/task.model';
|
||||
|
|
@ -285,13 +285,85 @@ describe('plannerSharedMetaReducer', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should not change state when task is not in Today and not planned for today', () => {
|
||||
it('should update planner days when planning for future day', () => {
|
||||
const testState = createStateWithExistingTasks([], [], [], ['other-task']);
|
||||
const task = createMockTask({ id: 'task1' });
|
||||
const action = createPlanTaskForDayAction(task, 'tomorrow', false);
|
||||
|
||||
metaReducer(testState, action);
|
||||
expect(mockReducer).toHaveBeenCalledWith(testState, action);
|
||||
const resultState = mockReducer.calls.mostRecent().args[0];
|
||||
expect(resultState.planner.days['tomorrow']).toEqual(['task1']);
|
||||
});
|
||||
|
||||
it('should not add task to planner days when planning for today', () => {
|
||||
const todayStr = getDbDateStr();
|
||||
const testState = createStateWithExistingTasks([], [], [], []);
|
||||
testState.planner = {
|
||||
...testState.planner,
|
||||
days: { '2024-01-16': ['task2'] },
|
||||
};
|
||||
const task = createMockTask({ id: 'task1' });
|
||||
const action = createPlanTaskForDayAction(task, todayStr, false);
|
||||
|
||||
metaReducer(testState, action);
|
||||
const resultState = mockReducer.calls.mostRecent().args[0];
|
||||
expect(resultState.planner.days[todayStr]).toBeUndefined();
|
||||
expect(resultState.planner.days['2024-01-16']).toEqual(['task2']);
|
||||
});
|
||||
|
||||
it('should remove task from all planner days when planning for today', () => {
|
||||
const todayStr = getDbDateStr();
|
||||
const testState = createStateWithExistingTasks([], [], [], []);
|
||||
testState.planner = {
|
||||
...testState.planner,
|
||||
days: {
|
||||
'2024-01-16': ['task1', 'task2'],
|
||||
'2024-01-17': ['task3'],
|
||||
},
|
||||
};
|
||||
const task = createMockTask({ id: 'task1' });
|
||||
const action = createPlanTaskForDayAction(task, todayStr, false);
|
||||
|
||||
metaReducer(testState, action);
|
||||
const resultState = mockReducer.calls.mostRecent().args[0];
|
||||
expect(resultState.planner.days[todayStr]).toBeUndefined();
|
||||
expect(resultState.planner.days['2024-01-16']).toEqual(['task2']);
|
||||
expect(resultState.planner.days['2024-01-17']).toEqual(['task3']);
|
||||
});
|
||||
|
||||
it('should add task to top of planner day when isAddToTop is true', () => {
|
||||
const testState = createStateWithExistingTasks([], [], [], []);
|
||||
testState.planner = {
|
||||
...testState.planner,
|
||||
days: { '2024-01-16': ['task2'] },
|
||||
};
|
||||
const task = createMockTask({ id: 'task1' });
|
||||
const action = createPlanTaskForDayAction(task, '2024-01-16', true);
|
||||
|
||||
metaReducer(testState, action);
|
||||
const resultState = mockReducer.calls.mostRecent().args[0];
|
||||
expect(resultState.planner.days['2024-01-16']).toEqual(['task1', 'task2']);
|
||||
});
|
||||
|
||||
it('should remove subtasks when moving parent task to planner day', () => {
|
||||
const testState = createStateWithExistingTasks([], [], [], []);
|
||||
testState.planner = {
|
||||
...testState.planner,
|
||||
days: { '2024-01-16': ['task1', 'sub1', 'task2', 'sub2'] },
|
||||
};
|
||||
const task = createMockTask({
|
||||
id: 'parent',
|
||||
subTaskIds: ['sub1', 'sub2'],
|
||||
});
|
||||
const action = createPlanTaskForDayAction(task, '2024-01-16', false);
|
||||
|
||||
metaReducer(testState, action);
|
||||
const resultState = mockReducer.calls.mostRecent().args[0];
|
||||
expect(resultState.planner.days['2024-01-16']).toEqual([
|
||||
'task1',
|
||||
'task2',
|
||||
'parent',
|
||||
]);
|
||||
});
|
||||
|
||||
// Virtual tag pattern tests: TODAY_TAG membership is determined by task.dueDay,
|
||||
|
|
|
|||
|
|
@ -3,20 +3,27 @@ import { RootState } from '../../root-state';
|
|||
import { PlannerActions } from '../../../features/planner/store/planner.actions';
|
||||
import { Task } from '../../../features/tasks/task.model';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import {
|
||||
ActionHandlerMap,
|
||||
addTaskToPlannerDay,
|
||||
filterOutTodayTag,
|
||||
getTag,
|
||||
hasInvalidTodayTag,
|
||||
removeTaskFromPlannerDays,
|
||||
removeTasksFromPlannerDays,
|
||||
updateTags,
|
||||
} from './task-shared-helpers';
|
||||
import {
|
||||
TASK_FEATURE_NAME,
|
||||
taskAdapter,
|
||||
} from '../../../features/tasks/store/task.reducer';
|
||||
import { plannerFeatureKey } from '../../../features/planner/store/planner.reducer';
|
||||
import {
|
||||
plannerFeatureKey,
|
||||
PlannerState,
|
||||
} from '../../../features/planner/store/planner.reducer';
|
||||
import {
|
||||
ADD_TASK_PANEL_ID,
|
||||
OVERDUE_LIST_ID,
|
||||
|
|
@ -51,7 +58,9 @@ const handleTransferTask = (
|
|||
};
|
||||
|
||||
// Handle planner days updates (from planner.reducer)
|
||||
const plannerState = state[plannerFeatureKey as keyof RootState] as any;
|
||||
const plannerState = state[
|
||||
plannerFeatureKey as keyof RootState
|
||||
] as unknown as PlannerState;
|
||||
const daysCopy = { ...plannerState.days };
|
||||
|
||||
// Update previous day (remove task)
|
||||
|
|
@ -167,7 +176,7 @@ const handlePlanTaskForDay = (
|
|||
day: string,
|
||||
isAddToTop: boolean,
|
||||
): RootState => {
|
||||
const todayStr = getDbDateStr();
|
||||
const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const todayTag = getTag(state, TODAY_TAG.id);
|
||||
const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task;
|
||||
const currentTagIds = currentTask?.tagIds || [];
|
||||
|
|
@ -205,8 +214,6 @@ const handlePlanTaskForDay = (
|
|||
),
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
} else if (todayTag.taskIds.includes(task.id)) {
|
||||
// Moving away from today - update both tag.taskIds and task.tagIds
|
||||
const newTagTaskIds = todayTag.taskIds.filter((id) => id !== task.id);
|
||||
|
|
@ -232,8 +239,18 @@ const handlePlanTaskForDay = (
|
|||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
// Remove task from all planner days (uses hasChanges optimization to avoid unnecessary state mutations)
|
||||
state = removeTaskFromPlannerDays(state, task.id);
|
||||
|
||||
// Add to target day if not today (today's ordering is managed by TODAY_TAG.taskIds)
|
||||
if (day !== todayStr) {
|
||||
state = addTaskToPlannerDay(state, task.id, day, isAddToTop ? 0 : Infinity);
|
||||
// When moving a parent, remove sub-tasks from the target day
|
||||
if (task.subTaskIds.length > 0) {
|
||||
state = removeTasksFromPlannerDays(state, task.subTaskIds);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import { Task } from '../../../features/tasks/task.model';
|
|||
import { Tag } from '../../../features/tag/tag.model';
|
||||
import { WorkContextType } from '../../../features/work-context/work-context.model';
|
||||
import { getRepeatableTaskId } from '../../../features/task-repeat-cfg/get-repeatable-task-id.util';
|
||||
import { appStateFeatureKey } from '../../../root-store/app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
||||
let combinedReducer: ActionReducer<any, Action>;
|
||||
|
|
@ -67,6 +69,18 @@ describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
|||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
// Helper to advance the day and update appState.todayStr in the state
|
||||
const advanceToDay = (state: RootState, date: Date): RootState => {
|
||||
jasmine.clock().mockDate(date);
|
||||
return {
|
||||
...state,
|
||||
[appStateFeatureKey]: {
|
||||
...(state[appStateFeatureKey as keyof RootState] as any),
|
||||
todayStr: getDbDateStr(),
|
||||
},
|
||||
} as RootState;
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const getTodayTagTaskIds = (state: RootState): string[] => {
|
||||
return (state[TAG_FEATURE_NAME].entities['TODAY'] as Tag)?.taskIds || [];
|
||||
|
|
@ -141,8 +155,8 @@ describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
|||
createScheduleAction(day1TaskId, day1_9am),
|
||||
);
|
||||
|
||||
// Now advance to Day 2
|
||||
jasmine.clock().mockDate(DAY2);
|
||||
// Now advance to Day 2 (update both clock and appState.todayStr)
|
||||
stateAfterDay1 = advanceToDay(stateAfterDay1, DAY2);
|
||||
});
|
||||
|
||||
it('should remove Day 1 task from TODAY_TAG via removeTasksFromTodayTag', () => {
|
||||
|
|
@ -181,8 +195,7 @@ describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
|||
describe('Day 2: Sync replay of Day 1 operations from another device', () => {
|
||||
it('should NOT add Day 1 task to Day 2 TODAY_TAG when synced operations replay', () => {
|
||||
// Start on Day 2 with empty state
|
||||
jasmine.clock().mockDate(DAY2);
|
||||
let state = baseState;
|
||||
let state = advanceToDay(baseState, DAY2);
|
||||
|
||||
// Device A's operations arrive via sync: addTask with dueDay = Day 1
|
||||
state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str));
|
||||
|
|
@ -198,8 +211,7 @@ describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
|||
});
|
||||
|
||||
it('should keep Day 2 task in TODAY_TAG even after Day 1 sync operations', () => {
|
||||
jasmine.clock().mockDate(DAY2);
|
||||
let state = baseState;
|
||||
let state = advanceToDay(baseState, DAY2);
|
||||
|
||||
// First: Day 2's task was already created locally
|
||||
state = combinedReducer(state, createAddTaskAction(day2TaskId, day2Str));
|
||||
|
|
@ -228,7 +240,7 @@ describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
|||
expect(getTodayTagTaskIds(state)).toContain(day1TaskId);
|
||||
|
||||
// === Day 2 ===
|
||||
jasmine.clock().mockDate(DAY2);
|
||||
state = advanceToDay(state, DAY2);
|
||||
|
||||
// Remove Day 1's overdue task
|
||||
state = combinedReducer(
|
||||
|
|
@ -243,7 +255,7 @@ describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
|||
expect(getTodayTagTaskIds(state)).toContain(day2TaskId);
|
||||
|
||||
// === Day 3 ===
|
||||
jasmine.clock().mockDate(DAY3);
|
||||
state = advanceToDay(state, DAY3);
|
||||
|
||||
// Remove Day 2's overdue task
|
||||
state = combinedReducer(
|
||||
|
|
@ -431,8 +443,7 @@ describe('Recurring task TODAY_TAG integration (#6269)', () => {
|
|||
it('should handle scheduleTaskWithTime for yesterday arriving on today', () => {
|
||||
// This is the critical sync scenario: Device A created a task yesterday
|
||||
// with dueWithTime = yesterday 9am. Today, Device B receives this operation.
|
||||
jasmine.clock().mockDate(DAY2);
|
||||
let state = baseState;
|
||||
let state = advanceToDay(baseState, DAY2);
|
||||
|
||||
// Device A's addTask from yesterday arrives (dueDay = yesterday)
|
||||
state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str));
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import {
|
|||
} from '../../../features/tasks/store/task.reducer';
|
||||
import { Task } from '../../../features/tasks/task.model';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import {
|
||||
ActionHandlerMap,
|
||||
addTaskToPlannerDay,
|
||||
|
|
@ -243,7 +244,13 @@ const handleScheduleWithTime = (
|
|||
|
||||
// Handle today tag update
|
||||
const todayTag = getTag(updatedState, TODAY_TAG.id);
|
||||
const isScheduledForToday = isToday(dueWithTime);
|
||||
const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const startOfNextDayDiffMs = state[appStateFeatureKey]?.startOfNextDayDiffMs ?? 0;
|
||||
const isScheduledForToday = isTodayWithOffset(
|
||||
dueWithTime,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
);
|
||||
const isCurrentlyInToday = todayTag.taskIds.includes(task.id);
|
||||
|
||||
if (isScheduledForToday !== isCurrentlyInToday) {
|
||||
|
|
@ -274,7 +281,7 @@ const handlePlanForDay = (
|
|||
isAddToTop: boolean | undefined,
|
||||
): SchedulingResult => {
|
||||
let updatedState = state;
|
||||
const todayStr = getDbDateStr();
|
||||
const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const isForToday = day === todayStr;
|
||||
const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ import { Project } from '../../../features/project/project.model';
|
|||
import { DEFAULT_TASK, Task, TaskWithSubTasks } from '../../../features/tasks/task.model';
|
||||
import { calcTotalTimeSpent } from '../../../features/tasks/util/calc-total-time-spent';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import {
|
||||
ActionHandlerMap,
|
||||
addTaskToList,
|
||||
|
|
@ -122,7 +123,8 @@ const handleAddTask = (
|
|||
let updatedState = state;
|
||||
|
||||
// Determine if task should be added to Today tag
|
||||
const shouldAddToToday = task.dueDay === getDbDateStr();
|
||||
const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const shouldAddToToday = task.dueDay === todayStr;
|
||||
|
||||
// Add task to task state
|
||||
// IMPORTANT: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern)
|
||||
|
|
@ -186,7 +188,7 @@ const handleAddTask = (
|
|||
updatedState = updateTags(updatedState, tagUpdates);
|
||||
|
||||
// Update planner days if task has a future dueDay
|
||||
if (task.dueDay && task.dueDay !== getDbDateStr()) {
|
||||
if (task.dueDay && task.dueDay !== todayStr) {
|
||||
const plannerState = updatedState[plannerFeatureKey as keyof RootState] as any;
|
||||
const daysCopy = { ...plannerState.days };
|
||||
const existingTaskIds = daysCopy[task.dueDay] || [];
|
||||
|
|
@ -219,6 +221,8 @@ const handleConvertToMainTask = (
|
|||
throw new Error('No parent for sub task');
|
||||
}
|
||||
|
||||
const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
|
||||
// Handle parent-child relationship cleanup and task entity updates
|
||||
const taskStateAfterParentCleanup = removeTaskFromParentSideEffects(
|
||||
state[TASK_FEATURE_NAME],
|
||||
|
|
@ -236,7 +240,7 @@ const handleConvertToMainTask = (
|
|||
modified: Date.now(),
|
||||
...(isPlanForToday && !task.dueWithTime
|
||||
? {
|
||||
dueDay: getDbDateStr(),
|
||||
dueDay: todayStr,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import {
|
|||
projectAdapter,
|
||||
} from '../../../features/project/store/project.reducer';
|
||||
import { TAG_FEATURE_NAME, tagAdapter } from '../../../features/tag/store/tag.reducer';
|
||||
import { plannerFeatureKey } from '../../../features/planner/store/planner.reducer';
|
||||
import {
|
||||
plannerFeatureKey,
|
||||
PlannerState,
|
||||
} from '../../../features/planner/store/planner.reducer';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
|
||||
|
|
@ -208,7 +211,9 @@ export const addTaskToPlannerDay = (
|
|||
day: string,
|
||||
position: number = 0,
|
||||
): RootState => {
|
||||
const plannerState = state[plannerFeatureKey as keyof RootState] as any;
|
||||
const plannerState = state[
|
||||
plannerFeatureKey as keyof RootState
|
||||
] as unknown as PlannerState;
|
||||
const daysCopy = { ...(plannerState?.days || {}) };
|
||||
|
||||
// First remove from all days
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
expectTaskUpdate,
|
||||
} from './test-utils';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
|
||||
describe('taskSharedSchedulingMetaReducer', () => {
|
||||
let mockReducer: jasmine.Spy;
|
||||
|
|
@ -712,6 +713,217 @@ describe('taskSharedSchedulingMetaReducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('with startOfNextDayDiff offset', () => {
|
||||
// Scenario: User has startOfNextDayDiffMs set to 4 hours.
|
||||
// "Today" is Feb 15, so anything before 4 AM Feb 16 is still "today" (Feb 15).
|
||||
// Anything at or after 4 AM Feb 16 is "tomorrow" (Feb 16).
|
||||
const OFFSET_MS = 4 * 3600000; // 4 hours in ms
|
||||
const TODAY_STR = '2026-02-15';
|
||||
|
||||
// Feb 16, 2026, 2:00 AM local time -- still "today" with 4h offset
|
||||
const FEB_16_2AM = new Date(2026, 1, 16, 2, 0, 0, 0).getTime();
|
||||
// Feb 16, 2026, 5:00 AM local time -- "tomorrow" with 4h offset
|
||||
const FEB_16_5AM = new Date(2026, 1, 16, 5, 0, 0, 0).getTime();
|
||||
// Feb 15, 2026, 10:00 AM local time -- clearly "today"
|
||||
const FEB_15_10AM = new Date(2026, 1, 15, 10, 0, 0, 0).getTime();
|
||||
|
||||
const applyOffset = (state: RootState): RootState => ({
|
||||
...state,
|
||||
[appStateFeatureKey]: {
|
||||
...state[appStateFeatureKey],
|
||||
todayStr: TODAY_STR,
|
||||
startOfNextDayDiffMs: OFFSET_MS,
|
||||
},
|
||||
});
|
||||
|
||||
describe('scheduleTaskWithTime', () => {
|
||||
const createScheduleAction = (
|
||||
taskOverrides: Partial<Task> = {},
|
||||
dueWithTime: number,
|
||||
) =>
|
||||
TaskSharedActions.scheduleTaskWithTime({
|
||||
task: createMockTask(taskOverrides),
|
||||
dueWithTime,
|
||||
isMoveToBacklog: false,
|
||||
});
|
||||
|
||||
it('should add task to TODAY tag when scheduled at 2 AM next calendar day (within offset)', () => {
|
||||
const testState = applyOffset(
|
||||
createStateWithExistingTasks(['task1'], [], ['task1'], []),
|
||||
);
|
||||
const action = createScheduleAction({}, FEB_16_2AM);
|
||||
|
||||
metaReducer(testState, action);
|
||||
expectStateUpdate(
|
||||
expectTagUpdate('TODAY', { taskIds: ['task1'] }),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT add task to TODAY tag when scheduled at 5 AM next calendar day (beyond offset)', () => {
|
||||
const testState = applyOffset(
|
||||
createStateWithExistingTasks(['task1'], [], ['task1'], []),
|
||||
);
|
||||
const action = createScheduleAction({}, FEB_16_5AM);
|
||||
|
||||
metaReducer(testState, action);
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0];
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME].entities.TODAY;
|
||||
|
||||
expect(todayTag.taskIds).not.toContain('task1');
|
||||
});
|
||||
|
||||
it('should remove task from TODAY tag when rescheduling from today to beyond offset', () => {
|
||||
const testState = applyOffset(
|
||||
createStateWithExistingTasks([], [], [], ['task1']),
|
||||
);
|
||||
const action = createScheduleAction({}, FEB_16_5AM);
|
||||
|
||||
metaReducer(testState, action);
|
||||
expectStateUpdate(
|
||||
expectTagUpdate('TODAY', { taskIds: [] }),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep task in TODAY tag when rescheduling within offset window', () => {
|
||||
const testState = applyOffset(
|
||||
createStateWithExistingTasks([], [], [], ['task1']),
|
||||
);
|
||||
// Task already in today, reschedule to 2 AM (still within offset)
|
||||
const task1 = testState[TASK_FEATURE_NAME].entities.task1 as Task;
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = {
|
||||
...task1,
|
||||
dueWithTime: FEB_15_10AM,
|
||||
dueDay: undefined,
|
||||
} as Task;
|
||||
const action = createScheduleAction({}, FEB_16_2AM);
|
||||
|
||||
metaReducer(testState, action);
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0];
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME].entities.TODAY;
|
||||
|
||||
expect(todayTag.taskIds).toContain('task1');
|
||||
});
|
||||
|
||||
it('should set dueDay to undefined when scheduling within offset (mutual exclusivity)', () => {
|
||||
const testState = applyOffset(
|
||||
createStateWithExistingTasks(['task1'], [], [], []),
|
||||
);
|
||||
const action = createScheduleAction({}, FEB_16_2AM);
|
||||
|
||||
metaReducer(testState, action);
|
||||
expectStateUpdate(
|
||||
expectTaskUpdate('task1', { dueWithTime: FEB_16_2AM, dueDay: undefined }),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planTasksForToday', () => {
|
||||
it('should preserve dueWithTime when task time is within offset window', () => {
|
||||
const testState = applyOffset(createStateWithExistingTasks([], [], [], []));
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
dueWithTime: FEB_16_2AM,
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].ids.push('task1');
|
||||
|
||||
const action = TaskSharedActions.planTasksForToday({
|
||||
taskIds: ['task1'],
|
||||
parentTaskMap: {},
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0];
|
||||
const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1;
|
||||
|
||||
// 2 AM Feb 16 is still "today" with 4h offset, so dueWithTime should be preserved
|
||||
expect(updatedTask.dueWithTime).toBe(FEB_16_2AM);
|
||||
expect(updatedTask.dueDay).toBe(TODAY_STR);
|
||||
});
|
||||
|
||||
it('should clear dueWithTime when task time is beyond offset window', () => {
|
||||
const testState = applyOffset(createStateWithExistingTasks([], [], [], []));
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
dueWithTime: FEB_16_5AM,
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].ids.push('task1');
|
||||
|
||||
const action = TaskSharedActions.planTasksForToday({
|
||||
taskIds: ['task1'],
|
||||
parentTaskMap: {},
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0];
|
||||
const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1;
|
||||
|
||||
// 5 AM Feb 16 is NOT "today" with 4h offset, so dueWithTime should be cleared
|
||||
expect(updatedTask.dueWithTime).toBeUndefined();
|
||||
expect(updatedTask.dueDay).toBe(TODAY_STR);
|
||||
});
|
||||
|
||||
it('should set dueDay to offset-adjusted today string', () => {
|
||||
const testState = applyOffset(createStateWithExistingTasks([], [], [], []));
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
dueDay: undefined,
|
||||
dueWithTime: undefined,
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].ids.push('task1');
|
||||
|
||||
const action = TaskSharedActions.planTasksForToday({
|
||||
taskIds: ['task1'],
|
||||
parentTaskMap: {},
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0];
|
||||
const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1;
|
||||
|
||||
// dueDay should be the offset-adjusted todayStr from state, not getDbDateStr()
|
||||
expect(updatedTask.dueDay).toBe(TODAY_STR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unScheduleTask', () => {
|
||||
it('should use offset-adjusted todayStr when leaving task in today', () => {
|
||||
const testState = applyOffset(
|
||||
createStateWithExistingTasks([], [], [], ['task1']),
|
||||
);
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
dueWithTime: FEB_16_2AM,
|
||||
dueDay: undefined,
|
||||
});
|
||||
|
||||
const action = TaskSharedActions.unscheduleTask({
|
||||
id: 'task1',
|
||||
isLeaveInToday: true,
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
expectStateUpdate(
|
||||
expectTaskUpdate('task1', {
|
||||
dueDay: TODAY_STR,
|
||||
dueWithTime: undefined,
|
||||
}),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('other actions', () => {
|
||||
it('should pass through other actions to the reducer', () => {
|
||||
const action = { type: 'SOME_OTHER_ACTION' };
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import {
|
|||
} from '../../../features/tasks/store/task.reducer';
|
||||
import { Task } from '../../../features/tasks/task.model';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { isToday } from '../../../util/is-today.util';
|
||||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import { moveItemBeforeItem } from '../../../util/move-item-before-item';
|
||||
import {
|
||||
ActionHandlerMap,
|
||||
|
|
@ -47,7 +48,13 @@ const handleScheduleTaskWithTime = (
|
|||
}
|
||||
|
||||
const todayTag = getTag(state, TODAY_TAG.id);
|
||||
const isScheduledForToday = isToday(dueWithTime);
|
||||
const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const startOfNextDayDiffMs = state[appStateFeatureKey]?.startOfNextDayDiffMs ?? 0;
|
||||
const isScheduledForToday = isTodayWithOffset(
|
||||
dueWithTime,
|
||||
todayStr,
|
||||
startOfNextDayDiffMs,
|
||||
);
|
||||
const isCurrentlyInToday = todayTag.taskIds.includes(task.id);
|
||||
|
||||
// If task is already correctly scheduled, don't change state (unless backlog move requested)
|
||||
|
|
@ -119,13 +126,14 @@ const handleUnScheduleTask = (
|
|||
isLeaveInToday = false,
|
||||
): RootState => {
|
||||
// First, update the task entity to clear scheduling data
|
||||
const todayStrForUnschedule = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const updatedState = {
|
||||
...state,
|
||||
[TASK_FEATURE_NAME]: taskAdapter.updateOne(
|
||||
{
|
||||
id: taskId,
|
||||
changes: {
|
||||
dueDay: isLeaveInToday ? getDbDateStr() : undefined,
|
||||
dueDay: isLeaveInToday ? todayStrForUnschedule : undefined,
|
||||
dueWithTime: undefined,
|
||||
remindAt: undefined,
|
||||
},
|
||||
|
|
@ -173,7 +181,8 @@ const handlePlanTasksForToday = (
|
|||
isClearScheduledTime?: boolean,
|
||||
): RootState => {
|
||||
const todayTag = getTag(state, TODAY_TAG.id);
|
||||
const today = getDbDateStr();
|
||||
const today = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
|
||||
const offsetMs = state[appStateFeatureKey]?.startOfNextDayDiffMs ?? 0;
|
||||
|
||||
// Filter out tasks that are already in today or whose parent is in today
|
||||
const newTasksForToday = taskIds.filter((taskId) => {
|
||||
|
|
@ -217,7 +226,7 @@ const handlePlanTasksForToday = (
|
|||
// However, if isClearScheduledTime is true (from reminder dialog), always clear the time
|
||||
const shouldClearTime = isClearScheduledTime
|
||||
? !!task?.dueWithTime
|
||||
: task?.dueWithTime && !isToday(task.dueWithTime);
|
||||
: task?.dueWithTime && !isTodayWithOffset(task.dueWithTime, today, offsetMs);
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { Project } from '../../../features/project/project.model';
|
|||
import { DEFAULT_PROJECT } from '../../../features/project/project.const';
|
||||
import { DEFAULT_TAG } from '../../../features/tag/tag.const';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
|
||||
// =============================================================================
|
||||
// TEST HELPERS
|
||||
|
|
@ -72,6 +74,10 @@ export const createBaseState = (): RootState => {
|
|||
days: {},
|
||||
addPlannedTasksDialogLastShown: undefined,
|
||||
},
|
||||
[appStateFeatureKey]: {
|
||||
todayStr: getDbDateStr(),
|
||||
startOfNextDayDiffMs: 0,
|
||||
},
|
||||
} as Partial<RootState> as RootState;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
plannerFeatureKey,
|
||||
plannerInitialState,
|
||||
} from '../../features/planner/store/planner.reducer';
|
||||
import { appStateFeatureKey } from '../app-state/app-state.reducer';
|
||||
|
||||
describe('taskSharedMetaReducer', () => {
|
||||
let mockReducer: jasmine.Spy;
|
||||
|
|
@ -227,6 +228,7 @@ describe('taskSharedMetaReducer', () => {
|
|||
entities: { project1: mockProject },
|
||||
},
|
||||
[plannerFeatureKey]: plannerInitialState,
|
||||
[appStateFeatureKey]: { todayStr: getDbDateStr(), startOfNextDayDiffMs: 0 },
|
||||
} as any as RootState;
|
||||
});
|
||||
|
||||
|
|
@ -2441,13 +2443,25 @@ describe('taskSharedMetaReducer', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should not change state when task is not in Today and not planned for today', () => {
|
||||
it('should update planner days but not Today tag when task is not in Today and planned for different day', () => {
|
||||
const testState = createStateWithExistingTasks([], [], [], ['other-task']);
|
||||
const task = createMockTask({ id: 'task1' });
|
||||
const action = createPlanTaskForDayAction(task, 'tomorrow', false);
|
||||
|
||||
metaReducer(testState, action);
|
||||
expect(mockReducer).toHaveBeenCalledWith(testState, action);
|
||||
expectStateUpdate(
|
||||
{
|
||||
// Today tag should be unchanged
|
||||
...expectTagUpdate('TODAY', { taskIds: ['other-task'] }),
|
||||
// Planner days should have the task added to 'tomorrow'
|
||||
[plannerFeatureKey]: jasmine.objectContaining({
|
||||
days: jasmine.objectContaining({
|
||||
tomorrow: ['task1'],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
action,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { inject, Pipe, PipeTransform } from '@angular/core';
|
||||
import { isToday } from '../../util/is-today.util';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { ShortTimeHtmlPipe } from './short-time-html.pipe';
|
||||
import { formatMonthDay } from '../../util/format-month-day.util';
|
||||
import { DateTimeFormatService } from '../../core/date-time-format/date-time-format.service';
|
||||
|
|
@ -8,13 +8,14 @@ import { DateTimeFormatService } from '../../core/date-time-format/date-time-for
|
|||
export class ShortPlannedAtPipe implements PipeTransform {
|
||||
private _shortTimeHtmlPipe = inject(ShortTimeHtmlPipe);
|
||||
private _dateTimeFormatService = inject(DateTimeFormatService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
transform(value?: number | null, timeOnly?: boolean): string | null {
|
||||
if (typeof value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isToday(value) || timeOnly) {
|
||||
if (this._dateService.isToday(value) || timeOnly) {
|
||||
return this._shortTimeHtmlPipe.transform(value);
|
||||
} else {
|
||||
const locale = this._dateTimeFormatService.currentLocale();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { isValidDate } from './is-valid-date';
|
||||
import { Log } from '../core/log';
|
||||
|
||||
export const isSameDay = (date1: number | Date, date2: number | Date): boolean => {
|
||||
const d1 = new Date(date1);
|
||||
const d2 = new Date(date2);
|
||||
export const isSameDay = (
|
||||
date1: number | Date,
|
||||
date2: number | Date,
|
||||
offsetMs: number = 0,
|
||||
): boolean => {
|
||||
const d1 = new Date(
|
||||
typeof date1 === 'number' ? date1 - offsetMs : date1.getTime() - offsetMs,
|
||||
);
|
||||
const d2 = new Date(
|
||||
typeof date2 === 'number' ? date2 - offsetMs : date2.getTime() - offsetMs,
|
||||
);
|
||||
const isValid = isValidDate(d1) && isValidDate(d2);
|
||||
if (!isValid) {
|
||||
Log.log(date1, date2);
|
||||
|
|
|
|||
120
src/app/util/is-today.util.spec.ts
Normal file
120
src/app/util/is-today.util.spec.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { isTodayWithOffset } from './is-today.util';
|
||||
import { getDbDateStr } from './get-db-date-str';
|
||||
|
||||
describe('isTodayWithOffset', () => {
|
||||
describe('zero offset (baseline)', () => {
|
||||
it('should return true for a timestamp from today', () => {
|
||||
const now = new Date();
|
||||
const todayStr = getDbDateStr(now);
|
||||
|
||||
expect(isTodayWithOffset(now.getTime(), todayStr, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a timestamp from yesterday', () => {
|
||||
const now = new Date();
|
||||
const todayStr = getDbDateStr(now);
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
expect(isTodayWithOffset(yesterday.getTime(), todayStr, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a timestamp from tomorrow', () => {
|
||||
const now = new Date();
|
||||
const todayStr = getDbDateStr(now);
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
expect(isTodayWithOffset(tomorrow.getTime(), todayStr, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positive offset (4 hours = day starts at 4 AM)', () => {
|
||||
const FOUR_HOURS_MS = 4 * 60 * 60 * 1000;
|
||||
const todayStr = '2026-02-15';
|
||||
|
||||
it('should return true for 2 AM Feb 16 (2AM - 4h = 10PM Feb 15 = today)', () => {
|
||||
// 2 AM on Feb 16
|
||||
const timestamp = new Date(2026, 1, 16, 2, 0, 0).getTime();
|
||||
|
||||
expect(isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for 5 AM Feb 16 (5AM - 4h = 1AM Feb 16 != Feb 15)', () => {
|
||||
// 5 AM on Feb 16
|
||||
const timestamp = new Date(2026, 1, 16, 5, 0, 0).getTime();
|
||||
|
||||
expect(isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for 11 PM Feb 14 (11PM - 4h = 7PM Feb 14 != Feb 15)', () => {
|
||||
// 11 PM on Feb 14
|
||||
const timestamp = new Date(2026, 1, 14, 23, 0, 0).getTime();
|
||||
|
||||
expect(isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for 3:59 AM Feb 16 (boundary, just before cutoff)', () => {
|
||||
// 3:59 AM on Feb 16
|
||||
const timestamp = new Date(2026, 1, 16, 3, 59, 0).getTime();
|
||||
|
||||
expect(isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for 4:00 AM Feb 16 (boundary, exactly at cutoff)', () => {
|
||||
// 4:00 AM on Feb 16
|
||||
const timestamp = new Date(2026, 1, 16, 4, 0, 0).getTime();
|
||||
|
||||
expect(isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for 4:00 AM Feb 15 (start of offset-adjusted day)', () => {
|
||||
// 4:00 AM on Feb 15
|
||||
const timestamp = new Date(2026, 1, 15, 4, 0, 0).getTime();
|
||||
|
||||
expect(isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for 3:59 AM Feb 15 (belongs to Feb 14)', () => {
|
||||
// 3:59 AM on Feb 15
|
||||
const timestamp = new Date(2026, 1, 15, 3, 59, 0).getTime();
|
||||
|
||||
expect(isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date object input', () => {
|
||||
it('should work with a Date object the same as with a timestamp', () => {
|
||||
const FOUR_HOURS_MS = 4 * 60 * 60 * 1000;
|
||||
const todayStr = '2026-02-15';
|
||||
const dateObj = new Date(2026, 1, 15, 12, 0, 0);
|
||||
|
||||
expect(isTodayWithOffset(dateObj, todayStr, FOUR_HOURS_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return the same result for Date object and its timestamp', () => {
|
||||
const FOUR_HOURS_MS = 4 * 60 * 60 * 1000;
|
||||
const todayStr = '2026-02-15';
|
||||
const dateObj = new Date(2026, 1, 16, 2, 0, 0);
|
||||
const timestamp = dateObj.getTime();
|
||||
|
||||
expect(isTodayWithOffset(dateObj, todayStr, FOUR_HOURS_MS)).toBe(
|
||||
isTodayWithOffset(timestamp, todayStr, FOUR_HOURS_MS),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid date', () => {
|
||||
it('should throw for NaN timestamp', () => {
|
||||
expect(() => isTodayWithOffset(NaN, '2026-02-15', 0)).toThrowError(
|
||||
'Invalid date passed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for an invalid Date object', () => {
|
||||
expect(() => isTodayWithOffset(new Date('invalid'), '2026-02-15', 0)).toThrowError(
|
||||
'Invalid date passed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,18 @@
|
|||
import { getDbDateStr } from './get-db-date-str';
|
||||
|
||||
export const isTodayWithOffset = (
|
||||
date: number | Date,
|
||||
todayStr: string,
|
||||
startOfNextDayDiffMs: number,
|
||||
): boolean => {
|
||||
const d = new Date(date);
|
||||
if (!(d.getTime() > 0)) {
|
||||
throw new Error('Invalid date passed');
|
||||
}
|
||||
return getDbDateStr(new Date(d.getTime() - startOfNextDayDiffMs)) === todayStr;
|
||||
};
|
||||
|
||||
/** @deprecated Use `DateService.isToday()` or `isTodayWithOffset()` instead for offset-aware comparison. */
|
||||
export const isToday = (date: number | Date): boolean => {
|
||||
const d = new Date(date);
|
||||
const isValid = d.getTime() > 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue