test(oplog): add persistent-action tests and fix compilation/lint errors

- Add unit tests for isPersistentAction type guard.

- Fix compilation errors in task scheduling components caused by removed reminderId/removeReminderFromTask.

- Fix type error in create-sorted-blocker-blocks.spec.ts.

- Fix lint errors in various files.
This commit is contained in:
Johannes Millan 2025-12-04 17:24:40 +01:00
parent b383025fc1
commit 3129c1dbca
30 changed files with 465 additions and 630 deletions

View file

@ -0,0 +1,80 @@
# Reminder Refactoring Plan
## Objective
Refactor the Reminder system to eliminate the standalone `reminders` model and persistence file. Instead, reminder data (specifically `remindAt`) will be stored directly on the `Task` model. Reminders for Notes will be discontinued.
## Motivation
1. **Operation Log Consistency**: Currently, reminders are stored independently and changes bypass the main NgRx/Operation Log system used for sync. Integrating them into `Task` ensures all reminder changes are properly tracked and synced.
2. **Performance**: Reducing the number of files synced (removing `reminders.json`) improves performance, especially for WebDAV users.
3. **Simplicity**: Reduces complexity by removing an entire state slice and its associated management logic.
## Proposed Architecture
### 1. Data Model Changes
**Remove**:
- `Reminder` model (standalone entity).
- `RecurringConfig` (found to be unused).
- `Note` reminders support.
**Update**:
- **Task Model**:
- Remove `reminderId: string`.
- Add `remindAt: number | null` (timestamp).
### 2. Persistence Layer
- Remove `reminders` from `BASE_MODEL_CFGS` in `src/app/core/persistence/persistence.const.ts`.
- Ensure `Task` persistence (which already exists) captures the new `remindAt` field.
### 3. Service Layer (`ReminderService`)
The `ReminderService` will transition from a state-holding service to a facade that interacts with the Task Store.
- **State Management**: Remove local `_reminders` array, `BehaviorSubject`, and manual persistence calls (`_saveModel`, `loadFromDatabase`).
- **Data Source**:
- Inject `Store`.
- Create a selector `selectTasksWithReminders` in Task Store.
- `onRemindersActive$` will subscribe to this selector (mapped to the format expected by the worker).
- **Actions**:
- `addReminder(taskId, remindAt)`: Dispatch `updateTask({ id: taskId, changes: { remindAt } })`.
- `updateReminder(taskId, changes)`: Dispatch `updateTask({ id: taskId, changes: { remindAt: changes.remindAt } })`.
- `removeReminder(taskId)`: Dispatch `updateTask({ id: taskId, changes: { remindAt: null } })`.
- `snooze(taskId, snoozeTime)`: Dispatch `updateTask` with new calculated time.
- **Worker Integration**:
- Continue to use `reminder.worker.ts`.
- The service will map `Task` objects to the lightweight structure expected by the worker (id, title, remindAt, type='TASK').
### 4. Migration Strategy
A migration must run **once** on startup to transfer existing reminder data to tasks.
**Migration Logic**:
1. Load the legacy `reminders` state (from `reminders.json` or IndexedDB).
2. Load the `task` state.
3. Iterate through all legacy reminders:
- If `type === 'NOTE'`: Discard (log warning if needed).
- If `type === 'TASK'`: Find the corresponding Task by `relatedId`. Update its `remindAt` property with the reminder's `remindAt`.
4. Save the updated `task` state.
5. Delete/Clear the `reminders` state to prevent future conflicts.
### 5. UI & Cleanup
- **Dialogs**: Update `DialogViewTaskReminders` and "Add/Edit Reminder" dialogs to read/write `task.remindAt` instead of looking up a separate reminder object.
- **Note UI**: Remove "Add Reminder" options from Note context menus and buttons.
- **Code Removal**: Delete unused `reminder.model.ts` (or reduce to Worker interface), remove Note-specific reminder logic.
## Implementation Steps
1. **Backup**: Ensure data is backed up before applying changes.
2. **Model Update**: specificy `remindAt` in `TaskCopy` / `Task`.
3. **Migration Script**: Implement the logic to merge `reminders` into `tasks`.
4. **Service Refactoring**: Rewrite `ReminderService` to use `TaskStore`.
5. **UI Adjustments**: Fix compilation errors in components accessing `reminderId`.
6. **Cleanup**: Remove dead code and `reminders` persistence config.
7. **Verification**: Test adding, snoozing, completing, and removing reminders. Verify sync behavior.

View file

@ -216,7 +216,9 @@ export interface Task {
timeSpentOnDay?: { [key: string]: number };
doneOn?: number | null;
attachments?: any[];
/** @deprecated Use remindAt instead */
reminderId?: string | null;
remindAt?: number | null;
repeatCfgId?: string | null;
// Issue tracking fields (optional)

View file

@ -0,0 +1,59 @@
import { Action } from '@ngrx/store';
import { isPersistentAction, PersistentAction } from './persistent-action.interface';
import { OpType } from './operation.types';
describe('PersistentAction Interface', () => {
describe('isPersistentAction', () => {
it('should return true for a valid PersistentAction', () => {
const action: PersistentAction = {
type: '[Test] Action',
meta: {
isPersistent: true,
entityType: 'TASK',
opType: OpType.Create,
},
};
expect(isPersistentAction(action)).toBe(true);
});
it('should return false for an action without meta', () => {
const action: Action = {
type: '[Test] Simple Action',
};
expect(isPersistentAction(action)).toBe(false);
});
it('should return false for an action with meta but isPersistent false', () => {
const action = {
type: '[Test] Non-persistent Action',
meta: {
isPersistent: false,
entityType: 'TASK',
opType: OpType.Create,
},
};
expect(isPersistentAction(action as unknown as Action)).toBe(false);
});
it('should return false for an action with meta but isPersistent undefined', () => {
const action = {
type: '[Test] Undefined Persistence Action',
meta: {
entityType: 'TASK',
opType: OpType.Create,
},
};
expect(isPersistentAction(action as unknown as Action)).toBe(false);
});
it('should return false if action is null or undefined', () => {
expect(isPersistentAction(null as any)).toBe(false);
expect(isPersistentAction(undefined as any)).toBe(false);
});
it('should return false if action is not an object', () => {
expect(isPersistentAction('string' as any)).toBe(false);
expect(isPersistentAction(123 as any)).toBe(false);
});
});
});

View file

@ -20,5 +20,5 @@ export interface PersistentAction extends Action {
// Helper type guard - only actions with explicit isPersistent: true are persisted
export const isPersistentAction = (action: Action): action is PersistentAction => {
const a = action as PersistentAction;
return !!a.meta && a.meta.isPersistent === true;
return !!a && !!a.meta && a.meta.isPersistent === true;
};

View file

@ -331,15 +331,9 @@ export class BoardPanelComponent {
}
}
if (panelCfg.scheduledState === BoardPanelCfgScheduledState.NotScheduled) {
const task = await this.store
.select(selectTaskById, { id: taskId })
.pipe(first())
.toPromise();
this.store.dispatch(
TaskSharedActions.unscheduleTask({
id: taskId,
reminderId: task.reminderId,
isSkipToast: false,
}),
);

View file

@ -16,6 +16,6 @@ export const updateGlobalConfigSection = createAction(
entityType: 'GLOBAL_CONFIG',
entityId: configProps.sectionKey, // Use section key as entity ID
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);

View file

@ -19,7 +19,7 @@ export const IssueProviderActions = createActionGroup({
entityType: 'ISSUE_PROVIDER',
entityId: providerProps.issueProvider.id,
opType: OpType.Create,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
// Upsert is typically used for sync/import, so no persistence metadata
@ -40,7 +40,7 @@ export const IssueProviderActions = createActionGroup({
entityType: 'ISSUE_PROVIDER',
entityId: providerProps.issueProvider.id as string,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
// Bulk update is typically used for sync/import, so no persistence metadata
@ -54,7 +54,7 @@ export const IssueProviderActions = createActionGroup({
entityIds: providerProps.ids,
opType: OpType.Move,
isBulk: true,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
'Delete IssueProvider': (providerProps: { id: string }) => ({
@ -64,7 +64,7 @@ export const IssueProviderActions = createActionGroup({
entityType: 'ISSUE_PROVIDER',
entityId: providerProps.id,
opType: OpType.Delete,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
'Delete IssueProviders': (providerProps: { ids: string[] }) => ({
@ -75,7 +75,7 @@ export const IssueProviderActions = createActionGroup({
entityIds: providerProps.ids,
opType: OpType.Delete,
isBulk: true,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
// Internal cleanup action - no persistence

View file

@ -19,7 +19,7 @@ export const updateNoteOrder = createAction(
entityIds: noteProps.ids,
opType: OpType.Move,
isBulk: true,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -32,7 +32,7 @@ export const addNote = createAction(
entityType: 'NOTE',
entityId: noteProps.note.id,
opType: OpType.Create,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -45,7 +45,7 @@ export const updateNote = createAction(
entityType: 'NOTE',
entityId: noteProps.note.id as string,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -58,7 +58,7 @@ export const deleteNote = createAction(
entityType: 'NOTE',
entityId: noteProps.id,
opType: OpType.Delete,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -71,6 +71,6 @@ export const moveNoteToOtherProject = createAction(
entityType: 'NOTE',
entityId: noteProps.note.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);

View file

@ -25,7 +25,6 @@ import { PlannerActions } from '../store/planner.actions';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
import { SnackService } from '../../../core/snack/snack.service';
import { removeReminderFromTask } from '../../tasks/store/task.actions';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { truncate } from '../../../util/truncate';
import { TASK_REMINDER_OPTIONS } from './task-reminder-options.const';
@ -278,7 +277,6 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: this.data.task.id,
reminderId: this.data.task.reminderId,
}),
);
} else if (this.plannedDayForTask === getDbDateStr()) {
@ -286,7 +284,6 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: this.data.task.id,
reminderId: this.data.task.reminderId,
isSkipToast: true,
}),
);
@ -300,7 +297,6 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: this.data.task.id,
reminderId: this.data.task.reminderId,
isSkipToast: true,
}),
);
@ -392,11 +388,13 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
typeof this.data.task.reminderId === 'string'
) {
this._store.dispatch(
removeReminderFromTask({
id: this.data.task.id,
reminderId: this.data.task.reminderId,
isSkipToast: true,
isLeaveDueTime: true,
TaskSharedActions.updateTask({
task: {
id: this.data.task.id,
changes: {
remindAt: undefined,
},
},
}),
);
}

View file

@ -16,7 +16,7 @@ export const PlannerActions = createActionGroup({
entityType: 'PLANNER',
entityId: plannerProps.day,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
// Internal cleanup action - no persistence
@ -41,7 +41,7 @@ export const PlannerActions = createActionGroup({
entityType: 'PLANNER',
entityId: plannerProps.task.id,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
'Move In List': (plannerProps: {
@ -55,7 +55,7 @@ export const PlannerActions = createActionGroup({
entityType: 'PLANNER',
entityId: plannerProps.targetDay,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
'Move Before Task': (plannerProps: { fromTask: TaskCopy; toTaskId: string }) => ({
@ -65,7 +65,7 @@ export const PlannerActions = createActionGroup({
entityType: 'PLANNER',
entityId: plannerProps.fromTask.id,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
'Plan Task for Day': (plannerProps: {
@ -80,7 +80,7 @@ export const PlannerActions = createActionGroup({
entityType: 'PLANNER',
entityId: plannerProps.task.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
// UI state action - no persistence

View file

@ -25,7 +25,7 @@ export const addProject = createAction(
entityType: 'PROJECT',
entityId: projectProps.project.id,
opType: OpType.Create,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -48,7 +48,7 @@ export const updateProject = createAction(
entityType: 'PROJECT',
entityId: projectProps.project.id as string,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -65,7 +65,7 @@ export const updateProjectAdvancedCfg = createAction(
entityType: 'PROJECT',
entityId: projectProps.projectId,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -79,7 +79,7 @@ export const updateProjectOrder = createAction(
entityIds: projectProps.ids,
opType: OpType.Move,
isBulk: true,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -92,7 +92,7 @@ export const archiveProject = createAction(
entityType: 'PROJECT',
entityId: projectProps.id,
opType: OpType.Update, // Archiving is an update
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -105,7 +105,7 @@ export const unarchiveProject = createAction(
entityType: 'PROJECT',
entityId: projectProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -118,7 +118,7 @@ export const toggleHideFromMenu = createAction(
entityType: 'PROJECT',
entityId: projectProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -133,7 +133,7 @@ export const moveProjectTaskToBacklogListAuto = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -146,7 +146,7 @@ export const moveProjectTaskToRegularListAuto = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -163,7 +163,7 @@ export const moveProjectTaskUpInBacklogList = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -180,7 +180,7 @@ export const moveProjectTaskDownInBacklogList = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -197,7 +197,7 @@ export const moveProjectTaskToTopInBacklogList = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -214,7 +214,7 @@ export const moveProjectTaskToBottomInBacklogList = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -227,7 +227,7 @@ export const moveProjectTaskInBacklogList = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -240,7 +240,7 @@ export const moveProjectTaskToBacklogList = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -259,7 +259,7 @@ export const moveProjectTaskToRegularList = createAction(
entityType: 'TASK',
entityId: taskProps.taskId,
opType: OpType.Move,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -272,6 +272,6 @@ export const moveAllProjectBacklogTasksToRegularList = createAction(
entityType: 'PROJECT',
entityId: taskProps.projectId,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);

View file

@ -1,35 +1,36 @@
import { nanoid } from 'nanoid';
import { inject, Injectable } from '@angular/core';
import { RecurringConfig, Reminder, ReminderCopy, ReminderType } from './reminder.model';
import { SnackService } from '../../core/snack/snack.service';
import { BehaviorSubject, Observable, ReplaySubject, Subject } from 'rxjs';
import { dirtyDeepCopy } from '../../util/dirtyDeepCopy';
import { Observable, Subject } from 'rxjs';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
import { TaskService } from '../tasks/task.service';
import { Task } from '../tasks/task.model';
import { NoteService } from '../note/note.service';
import { T } from '../../t.const';
import { filter, first, skipUntil } from 'rxjs/operators';
import { devError } from '../../util/dev-error';
import { Note } from '../note/note.model';
import { filter, map, skipUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { PfapiService } from '../../pfapi/pfapi.service';
import { Log } from '../../core/log';
import { GlobalConfigService } from '../config/global-config.service';
import { Store } from '@ngrx/store';
import { selectAllTasksWithReminder } from '../tasks/store/task.selectors';
import { TaskWithReminder, TaskWithReminderData } from '../tasks/task.model';
interface WorkerReminder {
id: string;
remindAt: number;
title: string;
type: 'TASK';
}
@Injectable({
providedIn: 'root',
})
export class ReminderService {
private readonly _pfapiService = inject(PfapiService);
private readonly _snackService = inject(SnackService);
private readonly _taskService = inject(TaskService);
private readonly _noteService = inject(NoteService);
private readonly _imexMetaService = inject(ImexViewService);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _store = inject(Store);
private _onRemindersActive$: Subject<Reminder[]> = new Subject<Reminder[]>();
onRemindersActive$: Observable<Reminder[]> = this._onRemindersActive$.pipe(
private _onRemindersActive$: Subject<TaskWithReminderData[]> = new Subject<
TaskWithReminderData[]
>();
onRemindersActive$: Observable<TaskWithReminderData[]> = this._onRemindersActive$.pipe(
skipUntil(
this._imexMetaService.isDataImportInProgress$.pipe(
filter((isInProgress) => !isInProgress),
@ -37,26 +38,10 @@ export class ReminderService {
),
);
private _reminders$: ReplaySubject<Reminder[]> = new ReplaySubject(1);
reminders$: Observable<Reminder[]> = this._reminders$.asObservable();
private _onReloadModel$: Subject<Reminder[]> = new Subject();
onReloadModel$: Observable<Reminder[]> = this._onReloadModel$.asObservable();
private _isRemindersLoaded$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(
false,
);
private _w: Worker;
private _reminders: Reminder[] = [];
constructor() {
// this._triggerPauseAfterUpdate$.subscribe((v) => Log.log('_triggerPauseAfterUpdate$', v));
// this._pauseAfterUpdate$.subscribe((v) => Log.log('_pauseAfterUpdate$', v));
// this._onRemindersActive$.subscribe((v) => Log.log('_onRemindersActive$', v));
// this.onRemindersActive$.subscribe((v) => Log.log('onRemindersActive$', v));
if (typeof (Worker as any) === 'undefined') {
if (typeof (Worker as unknown) === 'undefined') {
throw new Error('No service workers supported :(');
}
@ -66,202 +51,56 @@ export class ReminderService {
});
}
async init(): Promise<void> {
init(): void {
this._w.addEventListener('message', this._onReminderActivated.bind(this));
this._w.addEventListener('error', this._handleError.bind(this));
await this.reloadFromDatabase();
this._isRemindersLoaded$.next(true);
}
async reloadFromDatabase(): Promise<void> {
const fromDb = await this._loadFromDatabase();
if (!fromDb || !Array.isArray(fromDb)) {
this._saveModel([]);
}
this._reminders = await this._loadFromDatabase();
if (!Array.isArray(this._reminders)) {
Log.log(this._reminders);
devError('Something went wrong with the reminders');
this._reminders = [];
}
this._updateRemindersInWorker(this._reminders);
this._onReloadModel$.next(this._reminders);
this._reminders$.next(this._reminders);
if (environment.production) {
Log.log('loaded reminders from database', this._reminders);
}
}
// TODO maybe refactor to observable, because models can differ to sync value for yet unknown reasons
getById(reminderId: string): ReminderCopy | null {
const _foundReminder =
this._reminders && this._reminders.find((reminder) => reminder.id === reminderId);
return !!_foundReminder ? dirtyDeepCopy<ReminderCopy>(_foundReminder) : null;
}
getByRelatedId(relatedId: string): ReminderCopy | null {
const _foundReminder =
this._reminders &&
this._reminders.find((reminder) => reminder.relatedId === relatedId);
return !!_foundReminder ? dirtyDeepCopy<ReminderCopy>(_foundReminder) : null;
}
addReminder(
type: ReminderType,
relatedId: string,
title: string,
remindAt: number,
recurringConfig?: RecurringConfig,
isWaitForReady: boolean = false,
): string {
// make sure that there is always only a single reminder with a particular relatedId as there might be race conditions
this.removeReminderByRelatedIdIfSet(relatedId);
const id = nanoid();
const existingInstanceForEntry = this.getByRelatedId(relatedId);
if (existingInstanceForEntry) {
devError('A reminder for this ' + type + ' already exists');
this.updateReminder(existingInstanceForEntry.id, {
relatedId,
title,
remindAt,
type,
recurringConfig,
// Subscribe to tasks with reminders and update worker whenever they change
this._store
.select(selectAllTasksWithReminder)
.pipe(map((tasks) => this._mapTasksToWorkerReminders(tasks)))
.subscribe((reminders) => {
this._updateRemindersInWorker(reminders);
if (!environment.production) {
Log.log('Updated reminders in worker', reminders);
}
});
return existingInstanceForEntry.id;
} else {
// TODO find out why we need to do this
this._reminders = dirtyDeepCopy(this._reminders);
this._reminders.push({
id,
relatedId,
title,
remindAt,
type,
recurringConfig,
});
this._saveModel(this._reminders, isWaitForReady);
return id;
}
}
snooze(reminderId: string, snoozeTime: number): void {
const remindAt = new Date().getTime() + snoozeTime;
this.updateReminder(reminderId, { remindAt });
private _mapTasksToWorkerReminders(tasks: TaskWithReminder[]): WorkerReminder[] {
return tasks.map((task) => ({
id: task.id,
remindAt: task.remindAt,
title: task.title,
type: 'TASK' as const,
}));
}
updateReminder(reminderId: string, reminderChanges: Partial<Reminder>): void {
const i = this._reminders.findIndex((reminder) => reminder.id === reminderId);
if (i > -1) {
// TODO find out why we need to do this
this._reminders = dirtyDeepCopy(this._reminders);
this._reminders[i] = Object.assign({}, this._reminders[i], reminderChanges);
}
this._saveModel(this._reminders);
}
removeReminder(reminderIdToRemove: string): void {
const i = this._reminders.findIndex((reminder) => reminder.id === reminderIdToRemove);
if (i > -1) {
// TODO find out why we need to do this
this._reminders = dirtyDeepCopy(this._reminders);
this._reminders.splice(i, 1);
this._saveModel(this._reminders);
} else {
// throw new Error('Unable to find reminder with id ' + reminderIdToRemove);
}
}
removeReminderByRelatedIdIfSet(relatedId: string): void {
const reminder = this._reminders.find(
(reminderIN) => reminderIN.relatedId === relatedId,
);
if (reminder) {
this.removeReminder(reminder.id);
}
}
removeRemindersByRelatedIds(relatedIds: string[]): void {
const reminders = this._reminders.filter((reminderIN) =>
relatedIds.includes(reminderIN.relatedId),
);
if (reminders && reminders.length) {
reminders.forEach((reminder) => {
this.removeReminder(reminder.id);
});
}
}
private async _onReminderActivated(msg: MessageEvent): Promise<void> {
const reminders = msg.data as Reminder[];
Log.log(`ReminderService: Worker activated ${reminders.length} reminder(s)`);
private _onReminderActivated(msg: MessageEvent): void {
const reminders = msg.data as WorkerReminder[];
Log.log(`ReminderService: Worker activated ${reminders.length} reminder(s)`);
if (this._globalConfigService.cfg()?.reminder?.disableReminders) {
Log.log('ReminderService: reminders are disabled, not sending to UI');
return;
}
const remindersWithData: Reminder[] = (await Promise.all(
reminders.map(async (reminder) => {
const relatedModel = await this._getRelatedDataForReminder(reminder);
// Log.log('RelatedModel for Reminder', relatedModel);
// only show when not currently syncing and related model still exists
if (!relatedModel) {
Log.warn(
`ReminderService: No related data found for reminder ${reminder.id} (${reminder.type}: ${reminder.relatedId}), removing...`,
);
this.removeReminder(reminder.id);
return null;
} else {
// Check if task is already done (defensive check)
if (reminder.type === 'TASK' && (relatedModel as Task).isDone) {
Log.warn(
`ReminderService: Task ${relatedModel.id} is already done but reminder ${reminder.id} still exists, removing...`,
);
this.removeReminder(reminder.id);
return null;
}
return reminder;
}
}),
)) as Reminder[];
const finalReminders = remindersWithData.filter((reminder) => !!reminder);
// Map worker reminders back to TaskWithReminderData format
const taskReminders: TaskWithReminderData[] = reminders.map((r) => ({
id: r.id,
title: r.title,
reminderData: { remindAt: r.remindAt },
// These fields will be populated by the component that consumes this
// by looking up the full task from the store
})) as TaskWithReminderData[];
Log.log(`ReminderService: ${finalReminders.length} valid reminder(s) to show`);
if (finalReminders.length > 0) {
this._onRemindersActive$.next(finalReminders);
Log.log(`ReminderService: ${taskReminders.length} valid reminder(s) to show`);
if (taskReminders.length > 0) {
this._onRemindersActive$.next(taskReminders);
}
}
private async _loadFromDatabase(): Promise<Reminder[]> {
return (await this._pfapiService.m.reminders.load()) || [];
}
private async _saveModel(
reminders: Reminder[],
isWaitForReady: boolean = false,
): Promise<void> {
if (isWaitForReady) {
await this._isRemindersLoaded$
.pipe(
filter((v) => !!v),
first(),
)
.toPromise();
} else if (!this._isRemindersLoaded$.getValue()) {
throw new Error('Reminders not loaded initially when trying to save model');
}
Log.log('saveReminders', reminders);
await this._pfapiService.m.reminders.save(reminders, {
isUpdateRevAndLastUpdate: true,
});
this._updateRemindersInWorker(this._reminders);
this._reminders$.next(this._reminders);
}
private _updateRemindersInWorker(reminders: Reminder[]): void {
private _updateRemindersInWorker(reminders: WorkerReminder[]): void {
this._w.postMessage(reminders);
}
@ -269,16 +108,4 @@ export class ReminderService {
Log.err(err);
this._snackService.open({ type: 'ERROR', msg: T.F.REMINDER.S_REMINDER_ERR });
}
private async _getRelatedDataForReminder(reminder: Reminder): Promise<Task | Note> {
switch (reminder.type) {
case 'NOTE':
return await this._noteService.getByIdOnce$(reminder.relatedId).toPromise();
case 'TASK':
// NOTE: remember we don't want archive tasks to pop up here
return await this._taskService.getByIdOnce$(reminder.relatedId).toPromise();
}
throw new Error('Cannot get related model for reminder');
}
}

View file

@ -1,5 +1,5 @@
import { createSortedBlockerBlocks } from './create-sorted-blocker-blocks';
import { TaskReminderOptionId, TaskWithReminder } from '../../tasks/task.model';
import { TaskReminderOptionId } from '../../tasks/task.model';
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
import {
DEFAULT_TASK_REPEAT_CFG,
@ -108,7 +108,7 @@ const generateBlockedBlocks = (
describe('createBlockerBlocks()', () => {
it('should merge into single block if all overlapping', () => {
const fakeTasks: TaskWithReminder[] = [
const fakeTasks: any[] = [
{
id: 'S1',
subTaskIds: [],
@ -144,7 +144,7 @@ describe('createBlockerBlocks()', () => {
});
it('should merge into multiple blocks if not overlapping', () => {
const fakeTasks: TaskWithReminder[] = [
const fakeTasks: any[] = [
{
id: 'S1',
subTaskIds: [],
@ -201,7 +201,7 @@ describe('createBlockerBlocks()', () => {
});
it('should work for advanced scenario', () => {
const fakeTasks: TaskWithReminder[] = [
const fakeTasks: any[] = [
{
id: 'S1',
subTaskIds: [],
@ -237,7 +237,7 @@ describe('createBlockerBlocks()', () => {
});
it('should merge multiple times', () => {
const fakeTasks: TaskWithReminder[] = [
const fakeTasks: any[] = [
{
...BASE_REMINDER_TASK('16:00', 'no duration'),
timeEstimate: 0,
@ -310,7 +310,7 @@ describe('createBlockerBlocks()', () => {
});
it('should work with far future entries', () => {
const fakeTasks: TaskWithReminder[] = [
const fakeTasks: any[] = [
{
...BASE_REMINDER_TASK('16:00', 'no duration'),
timeEstimate: 0,
@ -352,7 +352,7 @@ describe('createBlockerBlocks()', () => {
});
it('should work for advanced scenario', () => {
const fakeTasks: TaskWithReminder[] = [
const fakeTasks: any[] = [
{
id: '0LtuSnH8s',
projectId: null,
@ -1001,7 +1001,7 @@ describe('createBlockerBlocks()', () => {
],
},
];
const fakeTasks: TaskWithReminder[] = [
const fakeTasks: any[] = [
{
id: 'S1',
timeSpent: 0,

View file

@ -13,7 +13,7 @@ export const addSimpleCounter = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.simpleCounter.id,
opType: OpType.Create,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -26,7 +26,7 @@ export const updateSimpleCounter = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.simpleCounter.id as string,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -45,7 +45,7 @@ export const deleteSimpleCounter = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Delete,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -59,7 +59,7 @@ export const deleteSimpleCounters = createAction(
entityIds: counterProps.ids,
opType: OpType.Delete,
isBulk: true,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -78,7 +78,7 @@ export const setSimpleCounterCounterToday = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -91,7 +91,7 @@ export const setSimpleCounterCounterForDate = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -104,7 +104,7 @@ export const increaseSimpleCounterCounterToday = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -117,7 +117,7 @@ export const decreaseSimpleCounterCounterToday = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -130,7 +130,7 @@ export const toggleSimpleCounterCounter = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -148,7 +148,7 @@ export const setSimpleCounterCounterOff = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -161,6 +161,6 @@ export const setSimpleCounterCounterOn = createAction(
entityType: 'SIMPLE_COUNTER',
entityId: counterProps.id,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);

View file

@ -1,4 +1,4 @@
import { createAction, props } from '@ngrx/store';
import { createAction } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { Tag } from '../tag.model';
import { WorkContextAdvancedCfgKey } from '../../work-context/work-context.model';
@ -12,7 +12,7 @@ export const addTag = createAction('[Tag] Add Tag', (tagProps: { tag: Tag }) =>
entityType: 'TAG',
entityId: tagProps.tag.id,
opType: OpType.Create,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}));
export const updateTag = createAction(
@ -24,12 +24,10 @@ export const updateTag = createAction(
entityType: 'TAG',
entityId: tagProps.tag.id as string,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
export const upsertTag = createAction('[Tag] Upsert Tag', props<{ tag: Tag }>());
export const deleteTag = createAction('[Tag] Delete Tag', (tagProps: { id: string }) => ({
...tagProps,
meta: {
@ -37,7 +35,7 @@ export const deleteTag = createAction('[Tag] Delete Tag', (tagProps: { id: strin
entityType: 'TAG',
entityId: tagProps.id,
opType: OpType.Delete,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}));
export const deleteTags = createAction(
@ -50,7 +48,7 @@ export const deleteTags = createAction(
entityIds: tagProps.ids,
opType: OpType.Delete,
isBulk: true,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -64,7 +62,7 @@ export const updateTagOrder = createAction(
entityIds: tagProps.ids,
opType: OpType.Move,
isBulk: true,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);
@ -77,6 +75,6 @@ export const updateAdvancedConfigForTag = createAction(
entityType: 'TAG',
entityId: tagProps.tagId,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);

View file

@ -26,7 +26,6 @@ import {
updateAdvancedConfigForTag,
updateTag,
updateTagOrder,
upsertTag,
} from './tag.actions';
import { PlannerActions } from '../../planner/store/planner.actions';
import { getDbDateStr } from '../../../util/get-db-date-str';
@ -298,8 +297,6 @@ export const tagReducer = createReducer<TagState>(
on(updateTag, (state: TagState, { tag }) => tagAdapter.updateOne(tag, state)),
on(upsertTag, (state: TagState, { tag }) => tagAdapter.upsertOne(tag, state)),
on(deleteTag, (state: TagState, { id }) => tagAdapter.removeOne(id, state)),
on(deleteTags, (state: TagState, { ids }) => tagAdapter.removeMany(ids, state)),

View file

@ -12,7 +12,6 @@ import {
deleteTags,
updateTag,
updateTagOrder,
upsertTag,
} from './store/tag.actions';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@ -82,10 +81,6 @@ export class TagService {
this._store$.dispatch(updateTag({ tag: { id, changes } }));
}
upsertTag(tag: Tag): void {
this._store$.dispatch(upsertTag({ tag }));
}
createTagObject(tag: Partial<Tag>): Tag {
const id = tag.id || nanoid();
return {

View file

@ -13,7 +13,6 @@ import {
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { Reminder } from '../../reminder/reminder.model';
import { Task, TaskWithReminderData } from '../task.model';
import { TaskService } from '../task.service';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
@ -71,23 +70,25 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
private _store = inject(Store);
private _reminderService = inject(ReminderService);
data = inject<{
reminders: Reminder[];
reminders: TaskWithReminderData[];
}>(MAT_DIALOG_DATA);
T: typeof T = T;
isDisableControls: boolean = false;
reminders$: BehaviorSubject<Reminder[]> = new BehaviorSubject(this.data.reminders);
tasks$: Observable<TaskWithReminderData[]> = this.reminders$.pipe(
switchMap((reminders) =>
this._taskService.getByIdsLive$(reminders.map((r) => r.relatedId)).pipe(
taskIds$: BehaviorSubject<string[]> = new BehaviorSubject(
this.data.reminders.map((r) => r.id),
);
tasks$: Observable<TaskWithReminderData[]> = this.taskIds$.pipe(
switchMap((taskIds) =>
this._taskService.getByIdsLive$(taskIds).pipe(
first(),
map((tasks: Task[]) =>
tasks
.filter((task) => !!task)
.filter((task) => !!task && typeof task.remindAt === 'number')
.map(
(task): TaskWithReminderData => ({
...task,
reminderData: reminders.find((r) => r.relatedId === task.id) as Reminder,
reminderData: { remindAt: task.remindAt as number },
}),
),
),
@ -114,15 +115,9 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
private _subs: Subscription = new Subscription();
constructor() {
// this._matDialogRef.disableClose = true;
this._subs.add(
this._reminderService.onReloadModel$.subscribe(() => {
this._close();
}),
);
this._subs.add(
this._reminderService.onRemindersActive$.subscribe((reminders) => {
this.reminders$.next(reminders);
this.taskIds$.next(reminders.map((r) => r.id));
}),
);
this._subs.add(
@ -143,42 +138,41 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
},
}),
);
if (task.reminderId) {
this._removeReminderFromList(task.reminderId as string);
}
this._removeTaskFromList(task.id);
}
dismiss(task: TaskWithReminderData): void {
// const now = Date.now();
if (task.projectId || task.parentId || task.tagIds.length > 0) {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: task.id,
reminderId: task.reminderId as string,
}),
);
this._removeReminderFromList(task.reminderId as string);
this._removeTaskFromList(task.id);
}
}
dismissReminderOnly(task: TaskWithReminderData): void {
if (task.reminderId) {
this._store.dispatch(
TaskSharedActions.dismissReminderOnly({
id: task.id,
reminderId: task.reminderId as string,
}),
);
this._removeReminderFromList(task.reminderId as string);
}
this._store.dispatch(
TaskSharedActions.dismissReminderOnly({
id: task.id,
}),
);
this._removeTaskFromList(task.id);
}
snooze(task: TaskWithReminderData, snoozeInMinutes: number): void {
this._reminderService.updateReminder(task.reminderData.id, {
// prettier-ignore
remindAt: Date.now() + (snoozeInMinutes * MINUTES_TO_MILLISECONDS),
});
this._removeReminderFromList(task.reminderId as string);
const snoozeMs = snoozeInMinutes * MINUTES_TO_MILLISECONDS;
const newRemindAt = Date.now() + snoozeMs;
this._store.dispatch(
TaskSharedActions.reScheduleTaskWithTime({
task,
dueWithTime: task.dueWithTime || newRemindAt,
remindAt: newRemindAt,
isMoveToBacklog: false,
}),
);
this._removeTaskFromList(task.id);
}
planForTomorrow(task: TaskWithReminderData): void {
@ -189,7 +183,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
isShowSnack: true,
}),
);
this._removeReminderFromList(task.reminderId as string);
this._removeTaskFromList(task.id);
}
editReminder(task: TaskWithReminderData, isCloseAfter: boolean = false): void {
@ -202,7 +196,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
.afterClosed()
.subscribe((wasEdited) => {
if (wasEdited) {
this._removeReminderFromList(task.reminderId as string);
this._removeTaskFromList(task.id);
}
if (isCloseAfter) {
this._close();
@ -219,13 +213,12 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
// ------------
snoozeAll(snoozeInMinutes: number): void {
this._prepareForBulkAction();
this.reminders$.getValue().forEach((reminder) => {
this._reminderService.updateReminder(reminder.id, {
// prettier-ignore
remindAt: Date.now() + (snoozeInMinutes * MINUTES_TO_MILLISECONDS),
});
});
this._finalizeBulkAction();
this._subs.add(
this.tasks$.pipe(first()).subscribe((tasks) => {
tasks.forEach((task) => this.snooze(task, snoozeInMinutes));
this._finalizeBulkAction();
}),
);
}
rescheduleAllUntilTomorrow(): void {
@ -251,7 +244,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
async addAllToToday(): Promise<void> {
this._prepareForBulkAction();
const selectedTasks = await this._getTasksFromReminderList();
const selectedTasks = await this._getTasksFromList();
this._store.dispatch(
TaskSharedActions.planTasksForToday({
@ -268,7 +261,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
async dismissAll(): Promise<void> {
this._prepareForBulkAction();
const tasks = await this._getTasksFromReminderList();
const tasks = await this._getTasksFromList();
tasks.forEach((task) => {
if (task.projectId || task.parentId || task.tagIds.length > 0) {
this.dismiss(task);
@ -279,7 +272,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
async dismissAllRemindersOnly(): Promise<void> {
this._prepareForBulkAction();
const tasks = await this._getTasksFromReminderList();
const tasks = await this._getTasksFromList();
tasks.forEach((task) => {
this.dismissReminderOnly(task);
});
@ -307,9 +300,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
markTaskAsDone(task: TaskWithReminderData): void {
this._taskService.setDone(task.id);
if (task.reminderId) {
this._removeReminderFromList(task.reminderId as string);
}
this._removeTaskFromList(task.id);
}
async markAllAsDone(): Promise<void> {
@ -318,7 +309,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
async markAllTasksAsDone(): Promise<void> {
this._prepareForBulkAction();
const tasks = await this._getTasksFromReminderList();
const tasks = await this._getTasksFromList();
tasks.forEach((task) => {
this._taskService.setDone(task.id);
});
@ -332,18 +323,16 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
this._matDialogRef.close();
}
private _removeReminderFromList(reminderId: string): void {
const newReminders = this.reminders$
.getValue()
.filter((reminder) => reminder.id !== reminderId);
if (newReminders.length <= 0) {
private _removeTaskFromList(taskId: string): void {
const newTaskIds = this.taskIds$.getValue().filter((id) => id !== taskId);
if (newTaskIds.length <= 0) {
this._close();
} else {
this.reminders$.next(newReminders);
this.taskIds$.next(newTaskIds);
}
}
private async _getTasksFromReminderList(): Promise<TaskWithReminderData[]> {
private async _getTasksFromList(): Promise<TaskWithReminderData[]> {
return (await this.tasks$.pipe(first()).toPromise()) as TaskWithReminderData[];
}

View file

@ -1,24 +1,19 @@
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { addReminderIdToTask, removeReminderFromTask } from './task.actions';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { concatMap, filter, first, map, mergeMap, tap } from 'rxjs/operators';
import { ReminderService } from '../../reminder/reminder.service';
import { concatMap, filter, map, mergeMap, tap } from 'rxjs/operators';
import { truncate } from '../../../util/truncate';
import { T } from '../../../t.const';
import { SnackService } from '../../../core/snack/snack.service';
import { EMPTY } from 'rxjs';
import { TaskService } from '../task.service';
import { moveProjectTaskToBacklogListAuto } from '../../project/store/project.actions';
import { flattenTasks } from './task.selectors';
import { Store } from '@ngrx/store';
import { PlannerActions } from '../../planner/store/planner.actions';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
@Injectable()
export class TaskReminderEffects {
private _actions$ = inject(Actions);
private _reminderService = inject(ReminderService);
private _snackService = inject(SnackService);
private _taskService = inject(TaskService);
private _store = inject(Store);
@ -44,27 +39,6 @@ export class TaskReminderEffects {
{ dispatch: false },
);
createReminderAndAddToTask$ = createEffect(() =>
this._actions$.pipe(
ofType(TaskSharedActions.scheduleTaskWithTime),
filter(({ task, remindAt }) => typeof remindAt === 'number'),
map(({ task, remindAt }) => {
const reminderId = this._reminderService.addReminder(
'TASK',
task.id,
truncate(task.title),
remindAt as number,
undefined,
true,
);
return addReminderIdToTask({
taskId: task.id,
reminderId,
});
}),
),
);
autoMoveToBacklog$ = createEffect(() =>
this._actions$.pipe(
ofType(TaskSharedActions.scheduleTaskWithTime),
@ -81,91 +55,43 @@ export class TaskReminderEffects {
),
);
updateTaskReminder$ = createEffect(() =>
updateTaskReminderSnack$ = createEffect(
() =>
this._actions$.pipe(
ofType(TaskSharedActions.reScheduleTaskWithTime),
filter(({ remindAt }) => typeof remindAt === 'number'),
tap(({ task }) =>
this._snackService.open({
type: 'SUCCESS',
translateParams: {
title: truncate(task.title),
},
msg: T.F.TASK.S.REMINDER_UPDATED,
ico: 'schedule',
}),
),
),
{ dispatch: false },
);
autoMoveToBacklogOnReschedule$ = createEffect(() =>
this._actions$.pipe(
ofType(TaskSharedActions.reScheduleTaskWithTime),
filter(({ task, remindAt }) => typeof remindAt === 'number' && !!task.reminderId),
tap(({ task, remindAt }) => {
this._reminderService.updateReminder(task.reminderId as string, {
remindAt,
title: task.title,
});
}),
tap(({ task }) =>
this._snackService.open({
type: 'SUCCESS',
translateParams: {
title: truncate(task.title),
},
msg: T.F.TASK.S.REMINDER_UPDATED,
ico: 'schedule',
}),
),
mergeMap(({ task, remindAt, dueWithTime, isMoveToBacklog }) => {
if (isMoveToBacklog && !task.projectId) {
throw new Error('Move to backlog not possible for non project tasks');
}
if (typeof remindAt !== 'number') {
filter(({ isMoveToBacklog }) => isMoveToBacklog),
mergeMap(({ task, isMoveToBacklog }) => {
if (!task.projectId) {
return EMPTY;
}
return [
...(isMoveToBacklog
? [
moveProjectTaskToBacklogListAuto({
taskId: task.id,
projectId: task.projectId as string,
}),
]
: []),
moveProjectTaskToBacklogListAuto({
taskId: task.id,
projectId: task.projectId,
}),
];
}),
),
);
clearRemindersOnDelete$ = createEffect(
() =>
this._actions$.pipe(
ofType(TaskSharedActions.deleteTask),
tap(({ task }) => {
const deletedTaskIds = [task.id, ...task.subTaskIds];
deletedTaskIds.forEach((id) => {
this._reminderService.removeReminderByRelatedIdIfSet(id);
});
}),
),
{ dispatch: false },
);
clearRemindersForArchivedTasks$ = createEffect(
() =>
this._actions$.pipe(
ofType(TaskSharedActions.moveToArchive),
tap(({ tasks }) => {
const flatTasks = flattenTasks(tasks);
if (!flatTasks.length) {
return;
}
flatTasks.forEach((t) => {
if (t.reminderId) {
this._reminderService.removeReminder(t.reminderId);
}
});
}),
),
{ dispatch: false },
);
clearMultipleReminders = createEffect(
() =>
this._actions$.pipe(
ofType(TaskSharedActions.deleteTasks),
tap(({ taskIds }) => {
this._reminderService.removeRemindersByRelatedIds(taskIds);
}),
),
{ dispatch: false },
);
unscheduleDoneTask$ = createEffect(
() =>
this._actions$.pipe(
@ -173,12 +99,10 @@ export class TaskReminderEffects {
filter(({ task }) => !!task.changes.isDone),
concatMap(({ task }) => this._taskService.getByIdOnce$(task.id as string)),
tap((task) => {
if (task.reminderId) {
// TODO refactor to map with dispatch
if (task.remindAt) {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: task.id,
reminderId: task.reminderId,
}),
);
}
@ -187,99 +111,34 @@ export class TaskReminderEffects {
{ dispatch: false },
);
unschedulePlannedForDayTasks$ = createEffect(() =>
this._actions$.pipe(
ofType(PlannerActions.transferTask),
filter(({ task }) => !!task.reminderId),
// delay(100),
map(({ task }) => {
return removeReminderFromTask({
id: task.id,
reminderId: task.reminderId!,
isSkipToast: true,
});
}),
),
);
// ---------------------------------------
// ---------------------------------------
removeTaskReminderSideEffects$ = createEffect(
unscheduleSnack$ = createEffect(
() =>
this._actions$.pipe(
ofType(removeReminderFromTask),
filter(({ reminderId }) => !!reminderId),
tap(({ isSkipToast }) => {
if (!isSkipToast) {
this._snackService.open({
type: 'SUCCESS',
msg: T.F.TASK.S.REMINDER_DELETED,
ico: 'schedule',
});
}
}),
tap(({ id, reminderId }) => {
this._reminderService.removeReminder(reminderId as string);
ofType(TaskSharedActions.unscheduleTask),
filter(({ isSkipToast }) => !isSkipToast),
tap(() => {
this._snackService.open({
type: 'SUCCESS',
msg: T.F.TASK.S.REMINDER_DELETED,
ico: 'schedule',
});
}),
),
{ dispatch: false },
);
removeTaskReminderTrigger1$ = createEffect(() =>
this._actions$.pipe(
ofType(TaskSharedActions.planTasksForToday),
filter(({ isSkipRemoveReminder }) => !isSkipRemoveReminder),
concatMap(({ taskIds }) => this._taskService.getByIdsLive$(taskIds).pipe(first())),
mergeMap((tasks) =>
tasks
.filter((task) => !!task.reminderId)
.map((task) =>
removeReminderFromTask({
id: task.id,
reminderId: task.reminderId as string,
isSkipToast: true,
}),
),
dismissReminderSnack$ = createEffect(
() =>
this._actions$.pipe(
ofType(TaskSharedActions.dismissReminderOnly),
tap(() => {
this._snackService.open({
type: 'SUCCESS',
msg: T.F.TASK.S.REMINDER_DELETED,
ico: 'schedule',
});
}),
),
),
{ dispatch: false },
);
removeTaskReminderTrigger2$ = createEffect(() =>
this._actions$.pipe(
ofType(TaskSharedActions.unscheduleTask),
filter(({ reminderId }) => !!reminderId),
map(({ id, reminderId }) => {
return removeReminderFromTask({
id,
reminderId: reminderId as string,
isSkipToast: true,
});
}),
),
);
removeTaskReminderForDismissOnly$ = createEffect(() =>
this._actions$.pipe(
ofType(TaskSharedActions.dismissReminderOnly),
map(({ id, reminderId }) => {
return removeReminderFromTask({
id,
reminderId: reminderId,
isSkipToast: false,
});
}),
),
);
removeTaskReminderTrigger3$ = createEffect(() => {
return this._actions$.pipe(
ofType(PlannerActions.planTaskForDay),
filter(({ task, day }) => !!task.reminderId),
map(({ task }) => {
return removeReminderFromTask({
id: task.id,
reminderId: task.reminderId as string,
isSkipToast: true,
});
}),
);
});
}

View file

@ -23,14 +23,6 @@ export const setSelectedTask = createAction(
export const unsetCurrentTask = createAction('[Task] UnsetCurrentTask');
export const addReminderIdToTask = createAction(
'[Task] Add ReminderId to Task',
props<{
taskId: string;
reminderId: string;
}>(),
);
export const __updateMultipleTaskSimple = createAction(
'[Task] Update multiple Tasks (simple)',
props<{
@ -139,17 +131,6 @@ export const removeTimeSpent = createAction(
}),
);
export const removeReminderFromTask = createAction(
'[Task] Remove Reminder',
props<{
id: string;
reminderId: string;
isSkipToast?: boolean;
isLeaveDueTime?: boolean;
}>(),
);
export const addSubTask = createAction(
'[Task] Add SubTask',
(taskProps: { task: Task; parentId: string }) => ({

View file

@ -1,13 +1,11 @@
import {
__updateMultipleTaskSimple,
addReminderIdToTask,
addSubTask,
moveSubTask,
moveSubTaskDown,
moveSubTaskToBottom,
moveSubTaskToTop,
moveSubTaskUp,
removeReminderFromTask,
removeTimeSpent,
roundTimeSpentForDay,
setCurrentTask,
@ -646,35 +644,4 @@ export const taskReducer = createReducer<TaskState>(
ids: [...taskIds, ...state.ids.filter((id) => !taskIds.includes(id))],
};
}),
// REMINDER STUFF
// --------------
on(addReminderIdToTask, (state, { taskId, reminderId }) => {
return taskAdapter.updateOne(
{
id: taskId,
changes: {
reminderId,
},
},
state,
);
}),
on(removeReminderFromTask, (state, { id, isLeaveDueTime }) => {
return taskAdapter.updateOne(
{
id,
changes: {
reminderId: undefined,
...(isLeaveDueTime
? {}
: {
dueWithTime: undefined,
}),
},
},
state,
);
}),
);

View file

@ -5,6 +5,7 @@ import {
TaskState,
TaskWithDueDay,
TaskWithDueTime,
TaskWithReminder,
TaskWithSubTasks,
} from '../task.model';
import { taskAdapter } from './task.adapter';
@ -481,6 +482,15 @@ export const selectAllTasksWithDueTimeSorted = createSelector(
},
);
export const selectAllTasksWithReminder = createSelector(
selectAllTasks,
(tasks: Task[]): TaskWithReminder[] => {
return tasks.filter(
(task) => typeof task.remindAt === 'number' && !task.isDone,
) as TaskWithReminder[];
},
);
export const selectTasksWithDueTimeUntil = createSelector(
selectAllTasks,
(tasks: Task[], props: { end: number }): TaskWithDueTime[] => {

View file

@ -402,7 +402,6 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: this.task.id,
reminderId: this.task.reminderId,
}),
);
}
@ -656,7 +655,6 @@ export class TaskContextMenuInnerComponent implements AfterViewInit {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: this.task.id,
reminderId: this.task.reminderId,
}),
);
}

View file

@ -1,5 +1,4 @@
import { IssueProviderKey } from '../issue/issue.model';
import { Reminder } from '../reminder/reminder.model';
import { EntityState } from '@ngrx/entity';
import { TaskAttachment } from './task-attachment/task-attachment.model';
// Import the unified Task type from plugin-api
@ -97,7 +96,9 @@ export interface TaskCopy
modified?: number;
doneOn?: number;
parentId?: string;
/** @deprecated Use remindAt instead. Kept for migration compatibility. */
reminderId?: string;
remindAt?: number;
repeatCfgId?: string;
_hideSubTasksMode?: HideSubTasksMode;
}
@ -113,13 +114,12 @@ export type ArchiveTask = Readonly<TaskCopy>;
export type Task = Readonly<TaskCopy>;
export interface TaskWithReminderData extends Task {
readonly reminderData: Reminder;
readonly reminderData: { remindAt: number };
readonly parentData?: Task;
}
export interface TaskWithReminder extends Task {
reminderId: string;
dueWithTime: number;
remindAt: number;
}
export interface TaskWithDueTime extends Task {
@ -133,8 +133,7 @@ export interface TaskWithDueDay extends Task {
export type TaskPlannedWithDayOrTime = TaskWithDueTime | TaskWithDueDay;
export interface TaskWithoutReminder extends Task {
reminderId: undefined;
due: undefined;
remindAt: undefined;
}
export interface TaskWithPlannedForDayIndication extends TaskWithoutReminder {

View file

@ -650,7 +650,6 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
this._store.dispatch(
TaskSharedActions.unscheduleTask({
id: this.task().id,
reminderId: this.task().reminderId,
}),
);
}

View file

@ -23,6 +23,6 @@ export const flushYoungToOld = createAction(
isPersistent: true,
entityType: 'ALL', // Affects multiple entity types (tasks, timeTracking)
opType: OpType.Batch, // Bulk operation affecting multiple entities
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);

View file

@ -42,6 +42,6 @@ export const syncTimeSpent = createAction(
entityType: 'TASK',
entityId: actionProps.taskId,
opType: OpType.Update,
} as PersistentActionMeta,
} satisfies PersistentActionMeta,
}),
);

View file

@ -0,0 +1,80 @@
import { AppDataCompleteNew } from '../pfapi-config';
import { CrossModelMigrateFn } from '../api';
import { PFLog } from '../../core/log';
interface LegacyReminder {
id: string;
remindAt: number;
title: string;
type: 'NOTE' | 'TASK';
relatedId: string;
}
/**
* Migration 4.6: Move reminders to task.remindAt
*
* This migration:
* 1. Reads all reminders from the legacy reminders array
* 2. For TASK reminders, updates the corresponding task's remindAt field
* 3. Discards NOTE reminders (feature discontinued)
* 4. Clears the reminders array
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const crossModelMigration4_6: CrossModelMigrateFn = ((
fullData: AppDataCompleteNew,
): AppDataCompleteNew => {
PFLog.log('____________________Migrate4.6 (Reminder Migration)__________________');
const copy = fullData;
const reminders = (copy.reminders || []) as LegacyReminder[];
if (reminders.length === 0) {
PFLog.log('No reminders to migrate');
return copy;
}
PFLog.log(`Migrating ${reminders.length} reminders to task.remindAt`);
let migratedCount = 0;
let skippedNotes = 0;
let skippedMissing = 0;
for (const reminder of reminders) {
if (reminder.type === 'NOTE') {
// Note reminders are discontinued
skippedNotes++;
PFLog.log(`Skipping NOTE reminder: ${reminder.id}`);
continue;
}
if (reminder.type === 'TASK') {
const task = copy.task?.entities?.[reminder.relatedId];
if (task) {
// Update the task with remindAt
// @ts-ignore - modifying readonly entity
copy.task.entities[reminder.relatedId] = {
...task,
remindAt: reminder.remindAt,
// Clear the old reminderId
reminderId: undefined,
};
migratedCount++;
PFLog.log(`Migrated reminder for task: ${reminder.relatedId}`);
} else {
// Task not found (might be archived or deleted)
skippedMissing++;
PFLog.log(`Task not found for reminder: ${reminder.relatedId}`);
}
}
}
// Clear the reminders array
// @ts-ignore
copy.reminders = [];
PFLog.log(
`Migration complete: ${migratedCount} migrated, ${skippedNotes} NOTE reminders skipped, ${skippedMissing} missing tasks`,
);
PFLog.log(copy);
return copy;
}) as CrossModelMigrateFn;

View file

@ -27,6 +27,7 @@ const handleScheduleTaskWithTime = (
state: RootState,
task: { id: string },
dueWithTime: number,
remindAt?: number,
): RootState => {
// Check if task already has the same dueWithTime
const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task;
@ -41,6 +42,7 @@ const handleScheduleTaskWithTime = (
// If task is already correctly scheduled, don't change state
if (
currentTask.dueWithTime === dueWithTime &&
currentTask.remindAt === remindAt &&
isScheduledForToday === isCurrentlyInToday
) {
return state;
@ -55,6 +57,7 @@ const handleScheduleTaskWithTime = (
changes: {
dueWithTime,
dueDay: undefined,
remindAt,
},
},
state[TASK_FEATURE_NAME],
@ -92,6 +95,7 @@ const handleUnScheduleTask = (
changes: {
dueDay: isLeaveInToday ? getDbDateStr() : undefined,
dueWithTime: undefined,
remindAt: undefined,
},
},
state[TASK_FEATURE_NAME],
@ -115,14 +119,14 @@ const handleUnScheduleTask = (
};
const handleDismissReminderOnly = (state: RootState, taskId: string): RootState => {
// Only clear the dueWithTime (reminder time) but keep dueDay and Today tag
// Only clear remindAt (the reminder notification) but keep dueWithTime, dueDay, and Today tag
return {
...state,
[TASK_FEATURE_NAME]: taskAdapter.updateOne(
{
id: taskId,
changes: {
dueWithTime: undefined,
remindAt: undefined,
},
},
state[TASK_FEATURE_NAME],
@ -157,7 +161,7 @@ const handlePlanTasksForToday = (
id: taskId,
changes: {
dueDay: today,
...(shouldClearTime ? { dueWithTime: undefined } : {}),
...(shouldClearTime ? { dueWithTime: undefined, remindAt: undefined } : {}),
},
};
});
@ -243,16 +247,16 @@ const handleMoveTaskInTodayTagList = (
const createActionHandlers = (state: RootState, action: Action): ActionHandlerMap => ({
[TaskSharedActions.scheduleTaskWithTime.type]: () => {
const { task, dueWithTime } = action as ReturnType<
const { task, dueWithTime, remindAt } = action as ReturnType<
typeof TaskSharedActions.scheduleTaskWithTime
>;
return handleScheduleTaskWithTime(state, task, dueWithTime);
return handleScheduleTaskWithTime(state, task, dueWithTime, remindAt);
},
[TaskSharedActions.reScheduleTaskWithTime.type]: () => {
const { task, dueWithTime } = action as ReturnType<
const { task, dueWithTime, remindAt } = action as ReturnType<
typeof TaskSharedActions.reScheduleTaskWithTime
>;
return handleScheduleTaskWithTime(state, task, dueWithTime);
return handleScheduleTaskWithTime(state, task, dueWithTime, remindAt);
},
[TaskSharedActions.unscheduleTask.type]: () => {
const { id, isLeaveInToday } = action as ReturnType<

View file

@ -126,7 +126,6 @@ export const TaskSharedActions = createActionGroup({
unscheduleTask: (taskProps: {
id: string;
reminderId?: string;
isSkipToast?: boolean;
isLeaveInToday?: boolean;
}) => ({
@ -139,7 +138,7 @@ export const TaskSharedActions = createActionGroup({
} satisfies PersistentActionMeta,
}),
dismissReminderOnly: (taskProps: { id: string; reminderId: string }) => ({
dismissReminderOnly: (taskProps: { id: string }) => ({
...taskProps,
meta: {
isPersistent: true,