mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-22 23:47:28 +00:00
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:
parent
9a7d1d488e
commit
95537b9dee
2 changed files with 94 additions and 3 deletions
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue