Skip to content

Commit b181354

Browse files
authored
Fix/desktop timer stopped offline (#9608)
* fix: desktop timer stopped offline * fix: desktop timer offline handle * fix: remove logs * fix: change timelogid source
1 parent c0df495 commit b181354

File tree

3 files changed

+157
-45
lines changed

3 files changed

+157
-45
lines changed

packages/desktop-ui-lib/src/lib/offline-sync/concretes/sequence-queue.ts

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ITimeSlot, TimerSyncStateEnum, TimerActionTypeEnum } from '@gauzy/contracts';
2-
import { asapScheduler, concatMap, defer, of, repeat, timer as synchronizer } from 'rxjs';
2+
import { concatMap, defer, of, repeat, timer as synchronizer } from 'rxjs';
33
import { BACKGROUND_SYNC_OFFLINE_INTERVAL } from '../../constants/app.constants';
44
import { ElectronService } from '../../electron/services';
55
import { ErrorHandlerService, Store } from '../../services';
@@ -86,7 +86,8 @@ export class SequenceQueue extends OfflineQueue<ISequence> {
8686
this._timeTrackerService,
8787
this._timeSlotQueueService,
8888
this._electronService,
89-
this._store
89+
this._store,
90+
latest ? latest.id : timer.timelogId
9091
);
9192

9293
// append data to queue;
@@ -119,7 +120,38 @@ export class SequenceQueue extends OfflineQueue<ISequence> {
119120
});
120121
} else if (currentTimeLog.id && timer.stoppedAt) {
121122
latest = await this._timeTrackerService.updateTimeLog(timer.timelogId, {
122-
startedAt: timer.startedAt || currentTimeLog.startedAt,
123+
startedAt: timer.startedAt,
124+
stoppedAt: timer.stoppedAt,
125+
description: timer.description,
126+
projectId: timer.projectId,
127+
taskId: timer.taskId
128+
});
129+
await this._electronService.ipcRenderer.invoke('UPDATE_SYNC_STATE', {
130+
actionType: TimerActionTypeEnum.STOP_TIMER,
131+
data: {
132+
state: TimerSyncStateEnum.SYNCED,
133+
duration: latest.duration || null,
134+
timerId: timer.id
135+
}
136+
});
137+
}
138+
} else if (latest && latest.id) {
139+
if (latest.isRunning) {
140+
latest = await this._timeTrackerService.toggleApiStop({
141+
...timer,
142+
...params
143+
});
144+
await this._electronService.ipcRenderer.invoke('UPDATE_SYNC_STATE', {
145+
actionType: TimerActionTypeEnum.STOP_TIMER,
146+
data: {
147+
state: TimerSyncStateEnum.SYNCED,
148+
duration: latest.duration || null,
149+
timerId: timer.id
150+
}
151+
});
152+
} else if (timer.stoppedAt && !latest.isRunning) {
153+
latest = await this._timeTrackerService.updateTimeLog(latest.id, {
154+
startedAt: timer.startedAt,
123155
stoppedAt: timer.stoppedAt,
124156
description: timer.description,
125157
projectId: timer.projectId,
@@ -134,41 +166,31 @@ export class SequenceQueue extends OfflineQueue<ISequence> {
134166
}
135167
});
136168
}
137-
} else if (latest && latest.id && latest.isRunning) {
138-
latest = await this._timeTrackerService.toggleApiStop({
139-
...timer,
140-
...params
141-
});
142-
await this._electronService.ipcRenderer.invoke('UPDATE_SYNC_STATE', {
143-
actionType: TimerActionTypeEnum.STOP_TIMER,
144-
data: {
145-
state: TimerSyncStateEnum.SYNCED,
146-
duration: latest.duration || null,
147-
timerId: timer.id
148-
}
149-
});
150169
}
151170
}
152171

153172
const status = await this._timeTrackerStatusService.status();
154173

155-
asapScheduler.schedule(async () => {
156-
try {
157-
await this._electronService.ipcRenderer.invoke('UPDATE_SYNCED_TIMER', {
158-
lastTimer: latest
159-
? latest
160-
: {
161-
...timer,
162-
id: status?.lastLog?.id
163-
},
164-
...timer
165-
});
166-
console.log('⏱ - local database updated');
167-
} catch (error) {
168-
console.error('🚨 - Error updating local database', error);
169-
this._errorHandlerService.handleError(error);
170-
}
171-
});
174+
/* Await directly instead of using asapScheduler (fire-and-forget).
175+
The asapScheduler deferral allowed the next queue item to start processing
176+
before this item's local DB update completed, causing out-of-order writes
177+
that corrupted syncDuration and timeslotId for concurrent offline sessions.
178+
*/
179+
try {
180+
await this._electronService.ipcRenderer.invoke('UPDATE_SYNCED_TIMER', {
181+
lastTimer: latest
182+
? latest
183+
: {
184+
...timer,
185+
id: status?.lastLog?.id
186+
},
187+
...timer
188+
});
189+
console.log('⏱ - local database updated');
190+
} catch (error) {
191+
console.error('🚨 - Error updating local database', error);
192+
this._errorHandlerService.handleError(error);
193+
}
172194
} catch (error) {
173195
console.error('🚨 - Error processing time slot queue', error);
174196
this._timeSlotQueueService.viewQueueStateUpdater = {

packages/desktop-ui-lib/src/lib/offline-sync/concretes/time-slot-queue.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export class TimeSlotQueue extends OfflineQueue<ITimeSlot> {
1313
private _timeTrackerService: TimeTrackerService,
1414
private _timeSlotQueueService: TimeSlotQueueService,
1515
private _electronService: ElectronService,
16-
private _store: Store
16+
private _store: Store,
17+
private _timeLogId?: string
1718
) {
1819
super();
1920
this.state = new BlockedTimeSlotState(this);
@@ -26,6 +27,7 @@ export class TimeSlotQueue extends OfflineQueue<ITimeSlot> {
2627
recordedAt: interval.startedAt,
2728
organizationId: this._store.organizationId,
2829
tenantId: this._store.tenantId,
30+
...(this._timeLogId ? { timeLogId: this._timeLogId } : {})
2931
});
3032
console.log('backup', activities);
3133
const timeSlotId = activities.id;

packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.service.ts

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,72 @@ export class TimeTrackerService {
5353
userId = '';
5454
employeeId = '';
5555

56+
/**
57+
* Promise-chain mutex that serializes timer state mutations.
58+
* toggleApiStart / toggleApiStop / updateTimeLog / addTimeLog all funnel
59+
* through _timerMutex so no two of them can run concurrently. Without this
60+
* guard, an offline-sync stop and an online session start can reach the server
61+
* simultaneously, causing stopPreviousRunningTimers to override stoppedAt and
62+
* trigger cascade deletion of adjacent timelogs.
63+
*/
64+
private _timerMutex: Promise<unknown> = Promise.resolve();
65+
66+
/**
67+
* Failsafe timeout for a single enqueued operation.
68+
* Set to 2× the HTTP layer timeout so legitimate slow requests are never
69+
* killed early, while a genuinely hung call still releases the chain.
70+
*/
71+
private static readonly _OP_TIMEOUT_MS = 120_000;
72+
73+
/**
74+
* Enqueue a timer API operation onto the serialization chain.
75+
*
76+
* Guarantees:
77+
* - Operations execute one at a time, in call order.
78+
* - A per-operation timeout (_OP_TIMEOUT_MS) releases the chain if the
79+
* underlying HTTP call never settles, preventing a permanent deadlock.
80+
* - Errors thrown by `fn` propagate to the caller unchanged; the mutex
81+
* chain itself never gets stuck regardless of failures.
82+
*
83+
* @param label Short human-readable name logged at start / end / error.
84+
* @param fn Factory that returns the Promise to await.
85+
*/
86+
private _serialized<T>(label: string, fn: () => Promise<T>): Promise<T> {
87+
const result: Promise<T> = this._timerMutex.then(async () => {
88+
this._loggerService.info(`[TimerMutex] ▶ ${label}`);
89+
90+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
91+
92+
const timeoutRace = new Promise<never>((_, reject) => {
93+
timeoutId = setTimeout(
94+
() =>
95+
reject(
96+
new Error(
97+
`[TimerMutex] "${label}" timed out after ${TimeTrackerService._OP_TIMEOUT_MS}ms — mutex released`
98+
)
99+
),
100+
TimeTrackerService._OP_TIMEOUT_MS
101+
);
102+
});
103+
104+
try {
105+
return await Promise.race([fn(), timeoutRace]);
106+
} catch (error) {
107+
this._loggerService.error(`[TimerMutex] ✖ ${label}`, error);
108+
throw error;
109+
} finally {
110+
clearTimeout(timeoutId);
111+
this._loggerService.info(`[TimerMutex] ■ ${label} done`);
112+
}
113+
});
114+
115+
// Absorb rejection on the mutex chain so the next queued operation is
116+
// never blocked by the failure of a previous one. The caller receives
117+
// the real rejection via `result`.
118+
this._timerMutex = result.catch(() => {});
119+
return result;
120+
}
121+
56122
constructor(
57123
private readonly http: HttpClient,
58124
private readonly _clientCacheService: ClientCacheService,
@@ -469,7 +535,7 @@ export class TimeTrackerService {
469535
organizationTeamId: values.organizationTeamId
470536
};
471537
this._loggerService.log.info(`Toggle Start Timer Request: ${moment().format()}`, body);
472-
return firstValueFrom(this.http.post(`${API_PREFIX}/timesheet/timer/start`, { ...body }, options));
538+
return this._serialized('toggleApiStart', () => firstValueFrom(this.http.post(`${API_PREFIX}/timesheet/timer/start`, { ...body }, options)));
473539
}
474540

475541
toggleApiStop(values) {
@@ -516,26 +582,38 @@ export class TimeTrackerService {
516582
// Log request details
517583
this._loggerService.info<any>(`Toggle Stop Timer Request: ${moment().format()}`, body);
518584

519-
// Perform the API call
520-
try {
521-
return firstValueFrom(this.http.post<ITimeLog>(API_URL, body, options));
522-
} catch (error) {
523-
this._loggerService.error<any>(`Error stopping timer: ${moment().format()}`, { error, requestBody: body });
524-
throw error;
525-
}
585+
return this._serialized('toggleApiStop', () => {
586+
try {
587+
return firstValueFrom(this.http.post<ITimeLog>(API_URL, body, options));
588+
} catch (error) {
589+
this._loggerService.error<any>(`Error stopping timer: ${moment().format()}`, { error, requestBody: body });
590+
throw error;
591+
}
592+
});
526593
}
527594

528595
updateTimeLog(timeLogId: string, payload: Partial<ITimeLog>) {
529596
const TIMEOUT = 15000;
530597
const API_URL = `${API_PREFIX}/timesheet/time-log/${timeLogId}`;
598+
599+
// Guard: a null organizationId causes the server to silently move the timelog to a
600+
// null-org timesheet, making it invisible in the dashboard without any error or deletion.
601+
if (!this._store.organizationId || !this._store.tenantId) {
602+
const msg = `updateTimeLog aborted for ${timeLogId}: organizationId or tenantId is null in store`;
603+
this._loggerService.log.warn(msg);
604+
throw new Error(msg);
605+
}
606+
531607
const timeLogPayload: Partial<ITimeLog> = {
532-
startedAt: moment(payload.startedAt).utc().toDate(),
608+
...(payload.startedAt ? { startedAt: moment(payload.startedAt).utc().toDate() } : {}),
533609
stoppedAt: moment(payload.stoppedAt).utc().toDate(),
610+
...(payload.isRunning !== undefined ? { isRunning: payload.isRunning } : {}),
534611
isBillable: true,
535612
logType: TimeLogType.TRACKED,
536613
source: TimeLogSourceEnum.DESKTOP,
537614
tenantId: this._store.tenantId,
538615
organizationId: this._store.organizationId,
616+
organizationContactId: payload.organizationContactId,
539617
employeeId: this._store.user?.employee?.id,
540618
...(payload.description ? { description: payload.description } : {}),
541619
...(payload.taskId ? { taskId: payload.taskId } : {}),
@@ -545,19 +623,29 @@ export class TimeTrackerService {
545623
headers: new HttpHeaders({ timeout: TIMEOUT.toString() })
546624
};
547625
this._loggerService.log.info(`Update Time Log Request: ${timeLogId} ${moment().format()}`, timeLogPayload);
548-
return firstValueFrom(this.http.put<ITimeLog>(API_URL, timeLogPayload, options));
626+
return this._serialized(`updateTimeLog:${timeLogId}`, () => firstValueFrom(this.http.put<ITimeLog>(API_URL, timeLogPayload, options)));
549627
}
550628

551629
addTimeLog(payload: Partial<ITimeLog>) {
552630
const TIMEOUT = 15000;
553631
const API_URL = `${API_PREFIX}/timesheet/time-log`;
632+
633+
// Guard: a null organizationId causes the server to silently move the timelog to a
634+
// null-org timesheet, making it invisible in the dashboard without any error or deletion.
635+
if (!this._store.organizationId || !this._store.tenantId) {
636+
const msg = `addTimeLog aborted: organizationId or tenantId is null in store`;
637+
this._loggerService.log.warn(msg);
638+
throw new Error(msg);
639+
}
640+
554641
const timeLogPayload: Partial<ITimeLog> = {
555642
startedAt: moment(payload.startedAt).utc().toDate(),
556643
stoppedAt: moment(payload.stoppedAt).utc().toDate(),
557644
isBillable: true,
558645
logType: TimeLogType.TRACKED,
559646
source: TimeLogSourceEnum.DESKTOP,
560647
tenantId: this._store.tenantId,
648+
organizationContactId: payload.organizationContactId,
561649
organizationId: this._store.organizationId,
562650
employeeId: this._store.user?.employee?.id,
563651
...(payload.description ? { description: payload.description } : {}),
@@ -569,7 +657,7 @@ export class TimeTrackerService {
569657
headers: new HttpHeaders({ timeout: TIMEOUT.toString() })
570658
};
571659
this._loggerService.log.info(`Add Time Log Request: ${moment().format()}`, timeLogPayload);
572-
return firstValueFrom(this.http.post<ITimeLog>(API_URL, timeLogPayload, options));
660+
return this._serialized('addTimeLog', () => firstValueFrom(this.http.post<ITimeLog>(API_URL, timeLogPayload, options)));
573661
}
574662

575663
deleteTimeSlot(values) {

0 commit comments

Comments
 (0)