super-productivity/src/app/features/project/project.service.ts
Johannes Millan bd67174863
fix(sync): make task and time replay deterministic (#8979)
* fix(sync): make task replay values deterministic

Capture logical dates and timestamps in task actions and backfill legacy operations. Carry per-day task totals so own-operation replay is idempotent while foreign time remains additive.

Fixes #8957

* fix(sync): make time snapshots replay-safe

Exclude pending task-time batches from op-log and file-sync snapshots so their later additive operations cannot overlap snapshot state. Preserve concurrent direct credits and normalize legacy replay dates deterministically.

Fixes #8957

* fix(sync): align snapshots with queued task time

Exclude accumulator and in-flight task-time deltas from operation-log snapshots so later delta operations cannot double-count them. Capture file and direct-upload snapshots at the same operation-log boundary, and flush or clear queued time around destructive state replacement.

* fix(tasks): flush queued time at task boundaries

Persist queued timer deltas before absolute short-syntax edits, and clear them whenever project, schedule, or repeat cleanup deletes the owning task. This prevents stale batches from recreating or overwriting removed task time.

* fix(sync): preserve concurrent task time deltas

Treat concurrent task-time batches as commuting updates on both client and server, while retaining causal stale-operation checks. Reject malformed identities, dates, durations, and unsafe timestamps before replay or persistence.

* test(sync): cover task time snapshot replay

Exercise seeded snapshot and restart invariants plus a real three-client SuperSync convergence path with the initial time inside the snapshot.

* fix(sync): select action type for legacy conflicts
2026-07-13 21:53:46 +02:00

651 lines
22 KiB
TypeScript

import { computed, inject, Injectable } from '@angular/core';
import { combineLatest, firstValueFrom, Observable, of } from 'rxjs';
import { SnackService } from '../../core/snack/snack.service';
import { Project } from './project.model';
import { select, Store } from '@ngrx/store';
import { nanoid } from 'nanoid';
import { ofType } from '@ngrx/effects';
import { map, shareReplay, switchMap, take } from 'rxjs/operators';
import {
BreakNr,
BreakNrCopy,
BreakTime,
BreakTimeCopy,
WorkContextType,
} from '../work-context/work-context.model';
import { MatDialog } from '@angular/material/dialog';
import { TaskService } from '../tasks/task.service';
import { addSubTask } from '../tasks/store/task.actions';
import { Task, TaskState } from '../tasks/task.model';
import { WorkContextService } from '../work-context/work-context.service';
import {
addProject,
completeProject,
moveProjectTaskToBacklogList,
moveProjectTaskToBacklogListAuto,
moveProjectTaskToRegularListAuto,
reopenProject,
toggleHideFromMenu,
unarchiveProject,
updateProject,
updateProjectOrder,
} from './store/project.actions';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { DEFAULT_PROJECT, INBOX_PROJECT } from './project.const';
import {
selectArchivedProjects,
selectProjectById,
selectUnarchivedProjects,
selectUnarchivedProjectsWithoutCurrent,
} from './store/project.selectors';
import { selectTaskFeatureState } from '../tasks/store/task.selectors';
import { getTaskById } from '../tasks/store/task.reducer.util';
import { TimeTrackingService } from '../time-tracking/time-tracking.service';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { T } from 'src/app/t.const';
import { sortByTitle } from '../../util/sort-by-title';
import { Note } from '../note/note.model';
import { selectNoteFeatureState } from '../note/store/note.reducer';
import { addNote } from '../note/store/note.actions';
import { Section } from '../section/section.model';
import { addSection } from '../section/store/section.actions';
import { selectSectionsByContextIdMap } from '../section/store/section.selectors';
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
import { LOCAL_ACTIONS } from '../../util/local-actions.token';
import { DateService } from '../../core/date/date.service';
import { getDeadlineAutoPlanFields } from '../tasks/util/get-deadline-auto-plan-fields';
import { MenuTreeService } from '../menu-tree/menu-tree.service';
import { selectMenuTreeProjectTree } from '../menu-tree/store/menu-tree.selectors';
import { TaskTimeSyncService } from '../tasks/task-time-sync.service';
export interface ProjectCompletionInfo {
topLevelTasks: Task[];
allTasks: Task[];
unfinishedTasks: Task[];
topLevelTasksWithUnfinishedWork: Task[];
}
/**
* Depth-first flatten of a task tree into a flat list (each parent immediately
* before its subtasks), de-duplicated via a shared `seen` set so a task reached
* from more than one parent is included only once.
*/
const flattenTaskTree = (
topLevelTasks: Task[],
entities: Record<string, Task | undefined>,
): Task[] => {
const result: Task[] = [];
const seen = new Set<string>();
const visit = (task: Task): void => {
if (seen.has(task.id)) {
return;
}
seen.add(task.id);
result.push(task);
(task.subTaskIds ?? []).forEach((subId) => {
const sub = entities[subId];
if (sub) {
visit(sub);
}
});
};
topLevelTasks.forEach(visit);
return result;
};
/** True if the task itself or any of its (live) subtasks is still unfinished. */
const hasUnfinishedWork = (
task: Task,
unfinishedTaskIds: Set<string>,
entities: Record<string, Task | undefined>,
): boolean =>
unfinishedTaskIds.has(task.id) ||
(task.subTaskIds ?? []).some((subId) => {
const sub = entities[subId];
return !!sub && hasUnfinishedWork(sub, unfinishedTaskIds, entities);
});
@Injectable({
providedIn: 'root',
})
export class ProjectService {
private readonly _workContextService = inject(WorkContextService);
private readonly _store$ = inject<Store<any>>(Store);
private readonly _actions$ = inject(LOCAL_ACTIONS);
private readonly _timeTrackingService = inject(TimeTrackingService);
private readonly _taskService = inject(TaskService);
private readonly _taskTimeSync = inject(TaskTimeSyncService);
private readonly _translate = inject(TranslateService);
private readonly _matDialog = inject(MatDialog);
private readonly _dateService = inject(DateService);
private readonly _snackService = inject(SnackService);
private readonly _menuTreeService = inject(MenuTreeService);
list$: Observable<Project[]> = this._store$.pipe(select(selectUnarchivedProjects));
list = toSignal(this.list$, { initialValue: [] });
private _listInTreeOrder = computed(() =>
this._menuTreeService.buildProjectListInTreeOrder(this.list()),
);
listInTreeOrder$ = toObservable(this._listInTreeOrder);
listSorted$: Observable<Project[]> = this.list$.pipe(
map((projects) => sortByTitle(projects)),
);
listSorted = toSignal(this.listSorted$, { initialValue: [] });
// Filtered and sorted list for UI (excludes archived and hidden projects)
listSortedForUI$: Observable<Project[]> = this.listSorted$.pipe(
map((projects) => projects.filter((p) => !p.isArchived && !p.isHiddenFromMenu)),
);
listSortedForUI = toSignal(this.listSortedForUI$, { initialValue: [] });
listInTreeOrderForUI = computed(() =>
this._listInTreeOrder().filter((p) => !p.isArchived && !p.isHiddenFromMenu),
);
archived$: Observable<Project[]> = this._store$.pipe(select(selectArchivedProjects));
currentProject$: Observable<Project | null | undefined> =
this._workContextService.activeWorkContextTypeAndId$.pipe(
switchMap(({ activeId, activeType }) =>
activeType === WorkContextType.PROJECT ? this.getByIdLive$(activeId) : of(null),
),
shareReplay(1),
);
onMoveToBacklog$: Observable<any> = this._actions$.pipe(
ofType(moveProjectTaskToBacklogList),
);
getProjectsWithoutId$(projectId: string | null): Observable<Project[]> {
return this._store$.pipe(
select(selectUnarchivedProjectsWithoutCurrent, { currentId: projectId }),
);
}
getProjectsWithoutIdInTreeOrder$(projectId: string | null): Observable<Project[]> {
return combineLatest([
this.getProjectsWithoutId$(projectId),
this._store$.pipe(select(selectMenuTreeProjectTree)),
]).pipe(
map(([projects]) => this._menuTreeService.buildProjectListInTreeOrder(projects)),
);
}
getBreakNrForProject$(projectId: string): Observable<BreakNr> {
return this._timeTrackingService.state$.pipe(
map((current) => {
const dataForProject = current.project[projectId];
const breakNr: BreakNrCopy = {};
if (dataForProject) {
Object.keys(dataForProject).forEach((dateStr) => {
const dateData = dataForProject[dateStr];
if (typeof dateData?.b === 'number') {
breakNr[dateStr] = dateData.b;
}
});
}
return breakNr;
}),
);
}
getBreakTimeForProject$(projectId: string): Observable<BreakTime> {
return this._timeTrackingService.state$.pipe(
map((current) => {
const dataForProject = current.project[projectId];
const breakTime: BreakTimeCopy = {};
if (dataForProject) {
Object.keys(dataForProject).forEach((dateStr) => {
const dateData = dataForProject[dateStr];
if (typeof dateData?.bt === 'number') {
breakTime[dateStr] = dateData.bt;
}
});
}
return breakTime;
}),
);
}
async unarchive(projectId: string): Promise<void> {
const project = await firstValueFrom(this.getByIdOnce$(projectId));
this._store$.dispatch(unarchiveProject({ id: projectId }));
this._snackService.open(
project?.isHiddenFromMenu
? {
ico: 'unarchive',
msg: T.F.PROJECT.S.UNARCHIVED_HIDDEN_FROM_MENU,
actionStr: T.F.PROJECT.S.SHOW_IN_MENU,
actionFn: () => this._store$.dispatch(toggleHideFromMenu({ id: projectId })),
}
: {
ico: 'unarchive',
msg: T.F.PROJECT.S.UNARCHIVED,
},
);
}
complete(projectId: string, doneOn: number): void {
// Single-entity flag flip. Unfinished-task resolution (move-to-inbox /
// mark-done) is dispatched separately as normal per-task actions before
// this call — see moveTasksToInbox / markTasksDone and the completion flow
// in work-context-menu. No undo affordance: that resolution can't be fully
// restored by reopen, so the fullscreen celebration is the feedback and
// reactivation lives on the archived-projects page.
this._store$.dispatch(completeProject({ id: projectId, doneOn }));
}
/**
* Carry unfinished work forward into the Inbox before completing a project.
* Uses the normal per-task move action so every downstream effect (issue
* sync, reminders, repeat-cfg) and per-entity conflict detection fires
* naturally. Done tasks are explicitly re-opened (setUnDone) so carried-over
* work is actionable again in the Inbox — the move itself keeps isDone.
* The trailing flush is the bulk-dispatch guard (sync-model Rule #6).
*/
async moveTasksToInbox(tasks: Task[]): Promise<void> {
for (const task of tasks) {
const withSubTasks = await firstValueFrom(
this._taskService.getByIdWithSubTaskData$(task.id),
);
this._taskService.moveToProject(withSubTasks, INBOX_PROJECT.id);
if (task.isDone) {
this._taskService.setUnDone(task.id);
}
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
/** Mark a project's unfinished tasks done via the normal per-task action. */
async markTasksDone(tasks: Task[]): Promise<void> {
tasks.forEach((task) => this._taskService.setDone(task.id));
await new Promise((resolve) => setTimeout(resolve, 0));
}
reopen(projectId: string, project?: Pick<Project, 'isHiddenFromMenu'>): void {
this._store$.dispatch(reopenProject({ id: projectId }));
this._snackService.open(
project?.isHiddenFromMenu
? {
ico: 'replay',
msg: T.F.PROJECT.S.REOPENED,
actionStr: T.F.PROJECT.S.SHOW_IN_MENU,
actionFn: () => this._store$.dispatch(toggleHideFromMenu({ id: projectId })),
}
: {
ico: 'replay',
msg: T.F.PROJECT.S.REOPENED,
},
);
}
/**
* Tasks of a project for the completion flow. Stats include live and archived
* project tasks, while unfinished resolution only considers active tasks that
* can still be moved or marked done from the live store.
*/
async getCompletionInfo(projectId: string): Promise<ProjectCompletionInfo> {
const project = await firstValueFrom(this.getByIdOnce$(projectId));
if (!project) {
return {
topLevelTasks: [],
allTasks: [],
unfinishedTasks: [],
topLevelTasksWithUnfinishedWork: [],
};
}
const stats = await this._getCompletionStatsTasks(projectId, project);
const resolvable = await this._getResolvableTasks(project);
return { ...stats, ...resolvable };
}
/**
* Stats lists for the celebration: count live AND archived project tasks, so
* work the user archived earlier still counts toward the finished total.
*/
private async _getCompletionStatsTasks(
projectId: string,
project: Project,
): Promise<Pick<ProjectCompletionInfo, 'topLevelTasks' | 'allTasks'>> {
const ids = [...(project.taskIds ?? []), ...(project.backlogTaskIds ?? [])];
const idSet = new Set(ids);
const projectTasks = await this._taskService.getAllTasksForProject(projectId);
const projectTaskById = new Map(projectTasks.map((task) => [task.id, task]));
const topLevelTasks = [
...ids,
...projectTasks
.filter((task) => !task.parentId && !idSet.has(task.id))
.map((task) => task.id),
]
.map((id) => projectTaskById.get(id))
.filter((t): t is Task => !!t);
const allTasks = flattenTaskTree(topLevelTasks, Object.fromEntries(projectTaskById));
return { topLevelTasks, allTasks };
}
/**
* Resolvable lists for the unfinished-task prompt: only LIVE tasks can still
* be moved or marked done, so the archive is intentionally ignored here.
*/
private async _getResolvableTasks(
project: Project,
): Promise<
Pick<ProjectCompletionInfo, 'unfinishedTasks' | 'topLevelTasksWithUnfinishedWork'>
> {
const taskState = await firstValueFrom(this._store$.select(selectTaskFeatureState));
const ids = [...(project.taskIds ?? []), ...(project.backlogTaskIds ?? [])];
const activeTopLevelTasks = ids
.map((id) => taskState.entities[id])
.filter((t): t is Task => !!t);
const activeAllTasks = flattenTaskTree(activeTopLevelTasks, taskState.entities);
const unfinishedTasks = activeAllTasks.filter((t) => !t.isDone);
const unfinishedTaskIds = new Set(unfinishedTasks.map((t) => t.id));
return {
unfinishedTasks,
topLevelTasksWithUnfinishedWork: activeTopLevelTasks.filter((t) =>
hasUnfinishedWork(t, unfinishedTaskIds, taskState.entities),
),
};
}
getByIdOnce$(id: string): Observable<Project | undefined> {
if (!id) {
throw new Error('No id given');
}
return this._store$.pipe(select(selectProjectById, { id }), take(1));
}
getByIdOnceCatchError$(id: string): Observable<Project | undefined> {
if (!id) {
throw new Error('No id given');
}
return this._store$.pipe(select(selectProjectById, { id }), take(1));
}
getByIdLive$(id: string): Observable<Project | undefined> {
if (!id) {
throw new Error('No id given');
}
return this._store$.pipe(select(selectProjectById, { id }));
}
add(project: Partial<Project>): string {
const id = nanoid();
this._store$.dispatch(
addProject({
project: {
...DEFAULT_PROJECT,
...project,
id,
},
}),
);
return id;
}
async remove(project: Project): Promise<void> {
const taskState = await this._store$
.select(selectTaskFeatureState)
.pipe(take(1))
.toPromise();
const subTaskIdsForProject: string[] = [];
const allParentTaskIds = [...project.taskIds, ...project.backlogTaskIds];
allParentTaskIds.forEach((id) => {
const task = getTaskById(id, taskState);
if (task.projectId && task.subTaskIds.length > 0) {
subTaskIdsForProject.push(...task.subTaskIds);
}
});
const allTaskIds = [...allParentTaskIds, ...subTaskIdsForProject];
allTaskIds.forEach((taskId) => this._taskTimeSync.clearOne(taskId));
this._store$.dispatch(
TaskSharedActions.deleteProject({
projectId: project.id,
noteIds: project.noteIds,
allTaskIds,
}),
);
}
update(projectId: string, changedFields: Partial<Project>): void {
this._store$.dispatch(
updateProject({
project: {
id: projectId,
changes: changedFields,
},
}),
);
}
moveTaskToTodayList(id: string, projectId: string, isMoveToTop: boolean = false): void {
this._store$.dispatch(
moveProjectTaskToRegularListAuto({
taskId: id,
isMoveToTop,
projectId,
}),
);
}
moveTaskToBacklog(taskId: string, projectId: string): void {
this._store$.dispatch(moveProjectTaskToBacklogListAuto({ taskId, projectId }));
}
updateOrder(ids: string[]): void {
this._store$.dispatch(updateProjectOrder({ ids }));
}
async duplicateProject(templateProjectId: string): Promise<string> {
if (!templateProjectId) {
throw new Error('No template project id given');
}
const template = await firstValueFrom(this.getByIdOnce$(templateProjectId));
if (!template) {
throw new Error('Template project not found');
}
const taskState = await firstValueFrom(this._store$.select(selectTaskFeatureState));
const parentTasks = template.taskIds
.map((id) => taskState.entities[id])
.filter((t): t is Task => !!t);
const backlogTasks = template.backlogTaskIds
.map((id) => taskState.entities[id])
.filter((t): t is Task => !!t);
let totalTaskCount = parentTasks.length + backlogTasks.length;
parentTasks.forEach((p) => (totalTaskCount += p.subTaskIds.length));
backlogTasks.forEach((p) => (totalTaskCount += p.subTaskIds.length));
if (totalTaskCount > 50) {
const isConfirmed = await firstValueFrom(
this._matDialog
.open(DialogConfirmComponent, {
restoreFocus: true,
data: {
title: this._translate.instant(
T.F.PROJECT.D_CONFIRM_DUPLICATE_BIG_PROJECT.TITLE,
),
message: this._translate.instant(
T.F.PROJECT.D_CONFIRM_DUPLICATE_BIG_PROJECT.MSG,
{
taskCount: totalTaskCount,
},
),
okTxt: T.F.PROJECT.D_CONFIRM_DUPLICATE_BIG_PROJECT.OK,
cancelTxt: T.F.PROJECT.D_CONFIRM_DUPLICATE_BIG_PROJECT.CANCEL,
},
})
.afterClosed(),
);
if (!isConfirmed) {
return Promise.reject('User cancelled duplication of large project');
}
}
// Create new project with copied basic cfg but empty task lists (tasks are duplicated separately)
const newProjectId = this.add({
...template,
title: `${template.title}${this._translate.instant(T.GLOBAL.COPY_SUFFIX)}`,
taskIds: [],
backlogTaskIds: [],
noteIds: [],
// A duplicate is a fresh, active project — never inherit completed/archived state.
isDone: false,
doneOn: null,
isArchived: false,
});
const noteState = await firstValueFrom(this._store$.select(selectNoteFeatureState));
const notesToCopy = template.noteIds
.map((noteId) => noteState.entities[noteId])
.filter((note): note is Note => !!note);
const newNoteIds = this._duplicateNotesToProject(notesToCopy, newProjectId);
this.update(newProjectId, { noteIds: newNoteIds });
const sectionsMap = await firstValueFrom(
this._store$.select(selectSectionsByContextIdMap),
);
const sectionsToCopy = sectionsMap.get(templateProjectId) ?? [];
const taskIdMap = new Map<string, string>();
this._duplicateTasksToProject(parentTasks, newProjectId, false, taskState, taskIdMap);
this._duplicateTasksToProject(backlogTasks, newProjectId, true, taskState, taskIdMap);
this._duplicateSectionsToProject(sectionsToCopy, newProjectId, taskIdMap);
return newProjectId;
}
private _duplicateTasksToProject(
tasks: Task[],
newProjectId: string,
isBacklog: boolean,
taskState: TaskState,
taskIdMap: Map<string, string>,
): void {
// For each parent task create a copy in the new project and then copy its subtasks
for (const p of tasks) {
const subTasks = p.subTaskIds
.map((id) => taskState.entities[id])
.filter((t): t is Task => t !== undefined && t !== null);
// copy and remove meta fields we don't want to pass as "additional"
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
id,
parentId,
subTaskIds,
projectId,
created,
timeSpent,
timeSpentOnDay,
...taskDataToCopy
} = p;
/* eslint-enable @typescript-eslint/no-unused-vars */
const newParentTask = this._taskService.createNewTaskWithDefaults({
title: p.title,
additional: taskDataToCopy,
workContextType: WorkContextType.PROJECT,
workContextId: newProjectId,
});
taskIdMap.set(p.id, newParentTask.id);
// dispatch addTask for the parent task
this._store$.dispatch(
TaskSharedActions.addTask({
task: newParentTask,
workContextId: newProjectId,
workContextType: WorkContextType.PROJECT,
isAddToBacklog: isBacklog,
isAddToBottom: true,
...getDeadlineAutoPlanFields(
this._dateService,
newParentTask.deadlineDay,
newParentTask.deadlineWithTime,
),
}),
);
// create subtasks
if (subTasks && subTasks.length > 0) {
for (const st of subTasks) {
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
id: _id,
parentId: _parentId,
subTaskIds: _subTaskIds,
projectId: _projectId,
created: _created,
timeSpent: _timeSpent,
timeSpentOnDay: _timeSpentOnDay,
...subTaskDataToCopy
} = st;
/* eslint-enable @typescript-eslint/no-unused-vars */
const newSub = this._taskService.createNewTaskWithDefaults({
title: st.title,
additional: subTaskDataToCopy,
workContextType: WorkContextType.PROJECT,
workContextId: newProjectId,
});
taskIdMap.set(st.id, newSub.id);
this._store$.dispatch(addSubTask({ task: newSub, parentId: newParentTask.id }));
}
}
}
}
private _duplicateNotesToProject(notes: Note[], newProjectId: string): string[] {
const newNoteIds: string[] = [];
for (const note of notes) {
const newNoteId = nanoid();
newNoteIds.push(newNoteId);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, projectId, isPinnedToToday, created, modified, ...noteToCopy } = note;
const newNote: Note = {
...noteToCopy,
id: newNoteId,
projectId: newProjectId,
isPinnedToToday: false,
created: Date.now(),
modified: Date.now(),
};
this._store$.dispatch(addNote({ note: newNote, isPreventFocus: true }));
}
return newNoteIds;
}
private _duplicateSectionsToProject(
sections: Section[],
newProjectId: string,
taskIdMap: Map<string, string>,
): void {
for (const section of sections) {
const newTaskIds = section.taskIds
.map((id) => taskIdMap.get(id))
.filter((id): id is string => !!id);
this._store$.dispatch(
addSection({
section: {
id: nanoid(),
contextId: newProjectId,
contextType: WorkContextType.PROJECT,
title: section.title,
isExpanded: section.isExpanded,
taskIds: newTaskIds,
},
}),
);
}
}
}