fix(calendar): use actual time spent for done time-block events

When a scheduled task was marked Done, the Google Calendar time-block
event's duration was recomputed with the app-wide remaining-estimate
formula `max(timeEstimate - timeSpent, 0)`. For a completed task that is
meaningless: it shrank the slot to a leftover sliver (estimate > spent)
or collapsed to 0 and hit the flat 30-min default (spent >= estimate).

Branch on `isDone`: done tasks now reflect the actual time spent
(falling back to the estimate, then the 30-min default), while active
tasks keep the remaining-estimate model that mirrors the schedule view.

Closes #7949
This commit is contained in:
Johannes Millan 2026-06-02 12:00:48 +02:00
parent 9a7d1d488e
commit 95537b9dee
2 changed files with 94 additions and 3 deletions

View file

@ -415,6 +415,79 @@ describe('TimeBlockSyncEffects', () => {
flush();
}));
describe('event duration', () => {
const triggerUpsertForTask = (task: Task): void => {
getByIdOnce$Spy.and.callFake(() => of(task));
actions$.next(
TaskSharedActions.updateTask({
task: { id: task.id, changes: { isDone: task.isDone } },
}),
);
tick(COALESCE_MS);
};
const lastDurationMs = (): number =>
upsertEventSpy.calls.mostRecent().args[1].durationMs;
it('uses the remaining estimate (estimate - spent) for an active task', fakeAsync(() => {
triggerUpsertForTask(
createTask('task-1', {
timeEstimate: 60 * 60 * 1000,
timeSpent: 51 * 60 * 1000,
isDone: false,
}),
);
expect(lastDurationMs()).toBe(9 * 60 * 1000);
flush();
}));
it('reflects the actual time spent when a task is done (estimate > spent)', fakeAsync(() => {
// Regression for #7949 scenario A: must not shrink to estimate - spent.
triggerUpsertForTask(
createTask('task-1', {
timeEstimate: 60 * 60 * 1000,
timeSpent: 51 * 60 * 1000,
isDone: true,
}),
);
expect(lastDurationMs()).toBe(51 * 60 * 1000);
flush();
}));
it('reflects the actual time spent when a done task overran its estimate (spent > estimate)', fakeAsync(() => {
// Regression for #7949 scenario B: must not collapse to the 30min default.
triggerUpsertForTask(
createTask('task-1', {
timeEstimate: 30 * 60 * 1000,
timeSpent: 50 * 60 * 1000,
isDone: true,
}),
);
expect(lastDurationMs()).toBe(50 * 60 * 1000);
flush();
}));
it('falls back to the estimate for a done task with no tracked time', fakeAsync(() => {
triggerUpsertForTask(
createTask('task-1', {
timeEstimate: 45 * 60 * 1000,
timeSpent: 0,
isDone: true,
}),
);
expect(lastDurationMs()).toBe(45 * 60 * 1000);
flush();
}));
it('falls back to the 30min default for a done task with no estimate and no tracked time', fakeAsync(() => {
triggerUpsertForTask(
createTask('task-1', { timeEstimate: 0, timeSpent: 0, isDone: true }),
);
expect(lastDurationMs()).toBe(30 * 60 * 1000);
flush();
}));
});
it('caps bulk-delete HTTP fan-out so it does not burst rate limits', fakeAsync(() => {
const bulkSub = effects.deleteOnBulkTaskDelete$.subscribe();
const taskIds = ['t-1', 't-2', 't-3', 't-4', 't-5'];

View file

@ -435,9 +435,7 @@ export class TimeBlockSyncEffects {
task: Task,
dueWithTime: number,
): Promise<void> {
const durationMs = task.isDone
? task.timeSpent || task.timeEstimate || 30 * 60 * 1000
: Math.max(task.timeEstimate, task.timeSpent) || 30 * 60 * 1000;
const durationMs = this._calcDurationMs(task);
await ctx.definition.timeBlock!.upsertEvent(
task.id,
{
@ -451,6 +449,26 @@ export class TimeBlockSyncEffects {
);
}
/**
* Duration of the calendar block for a task.
*
* - Active task: the *remaining* estimated work (`timeEstimate - timeSpent`),
* mirroring the in-app schedule view (planner.selectors.ts).
* - Done task: "remaining work" is meaningless once finished, so reflect the
* *actual* time spent instead. Otherwise completing a task would shrink the
* block to a leftover sliver (estimate > spent) or collapse to 0 and hit the
* default (spent >= estimate) see #7949.
*
* Falls back to a sensible non-zero default so the event is never zero-length.
*/
private _calcDurationMs(task: Task): number {
const DEFAULT_DURATION_MS = 30 * 60 * 1000;
if (task.isDone) {
return task.timeSpent || task.timeEstimate || DEFAULT_DURATION_MS;
}
return Math.max(task.timeEstimate - task.timeSpent, 0) || DEFAULT_DURATION_MS;
}
private _handleError(err: unknown): Observable<never> {
Log.err('[TimeBlock] Failed to sync time block', err);
this._snackService.open({