@timesheet/plugin-google-calendar 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.handleSyncBatch = void 0;
4
4
  const integration_sdk_1 = require("@timesheet/integration-sdk");
5
5
  const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'google-calendar';
7
+ const PROJECT_ENTITY = 'project';
6
8
  exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
9
  context.logger.info('Processing sync batch', {
8
10
  sinceVersion: input.sinceVersion,
@@ -10,6 +12,19 @@ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, con
10
12
  changeCount: input.changes.length,
11
13
  hasMore: input.hasMore
12
14
  });
15
+ // Pre-load all mappings once for the entire batch
16
+ const [projectMappings, taskMappings] = await Promise.all([
17
+ context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY }),
18
+ context.mappings.list({ system: SYSTEM, entity: 'task' })
19
+ ]);
20
+ const projectMappingByLocalId = new Map();
21
+ for (const mapping of projectMappings) {
22
+ projectMappingByLocalId.set(mapping.localId, mapping);
23
+ }
24
+ const taskMappingByLocalId = new Map();
25
+ for (const mapping of taskMappings) {
26
+ taskMappingByLocalId.set(mapping.localId, mapping);
27
+ }
13
28
  let syncedCount = 0;
14
29
  const errors = [];
15
30
  for (const change of input.changes) {
@@ -22,7 +37,7 @@ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, con
22
37
  event,
23
38
  taskId: change.entityId,
24
39
  item: change.item
25
- }, context);
40
+ }, context, { projectMappingByLocalId, taskMappingByLocalId });
26
41
  if (result.status === 'synced' || result.status === 'deleted') {
27
42
  syncedCount++;
28
43
  }
@@ -37,7 +52,7 @@ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, con
37
52
  }
38
53
  }
39
54
  return {
40
- system: 'google-calendar',
55
+ system: SYSTEM,
41
56
  status: errors.length > 0 ? 'partial' : 'completed',
42
57
  syncedCount,
43
58
  details: {
@@ -7,6 +7,7 @@ interface GoogleCalendarClientOptions {
7
7
  export declare class GoogleCalendarClient {
8
8
  private readonly getAccessToken;
9
9
  private readonly refreshAccessToken;
10
+ private cachedToken;
10
11
  constructor(options: GoogleCalendarClientOptions);
11
12
  testConnection(): Promise<boolean>;
12
13
  listCalendars(): Promise<ExternalEntity[]>;
@@ -3,8 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GoogleCalendarClient = void 0;
4
4
  class GoogleCalendarClient {
5
5
  constructor(options) {
6
- this.getAccessToken = options.getAccessToken;
7
- this.refreshAccessToken = options.refreshAccessToken;
6
+ this.cachedToken = null;
7
+ this.getAccessToken = async () => {
8
+ if (this.cachedToken)
9
+ return this.cachedToken;
10
+ this.cachedToken = await options.getAccessToken();
11
+ return this.cachedToken;
12
+ };
13
+ this.refreshAccessToken = async () => {
14
+ this.cachedToken = null;
15
+ this.cachedToken = await options.refreshAccessToken();
16
+ return this.cachedToken;
17
+ };
8
18
  }
9
19
  async testConnection() {
10
20
  const calendars = await this.listCalendars();
@@ -1,4 +1,4 @@
1
- import { IntegrationContext } from '@timesheet/integration-sdk';
1
+ import { IntegrationContext, MappingRecord } from '@timesheet/integration-sdk';
2
2
  import { GoogleCalendarConfig, GoogleCalendarSyncInput } from './types';
3
3
  export interface GoogleCalendarSyncResult {
4
4
  system: string;
@@ -6,7 +6,11 @@ export interface GoogleCalendarSyncResult {
6
6
  syncedCount: number;
7
7
  details?: Record<string, unknown>;
8
8
  }
9
- export declare function syncTaskToGoogleCalendar(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
9
+ export interface SyncBatchCaches {
10
+ projectMappingByLocalId?: Map<string, MappingRecord>;
11
+ taskMappingByLocalId?: Map<string, MappingRecord>;
12
+ }
13
+ export declare function syncTaskToGoogleCalendar(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>, caches?: SyncBatchCaches): Promise<GoogleCalendarSyncResult>;
10
14
  export declare function runGoogleCalendarFullSync(context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
11
15
  export declare function handleGoogleWebhook(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
12
16
  export declare function ensureWatchChannels(context: IntegrationContext<GoogleCalendarConfig>): Promise<void>;
@@ -8,7 +8,9 @@ const googleCalendarClient_1 = require("./googleCalendarClient");
8
8
  const SYSTEM = 'google-calendar';
9
9
  const PROJECT_ENTITY = 'project';
10
10
  const TASK_ENTITY = 'task';
11
- async function syncTaskToGoogleCalendar(input, context) {
11
+ // Shared client instance for batch execution — avoids re-fetching the access token per change.
12
+ let sharedClient = null;
13
+ async function syncTaskToGoogleCalendar(input, context, caches) {
12
14
  const syncDirection = context.config?.syncDirection ?? 'bidirectional';
13
15
  if (syncDirection === 'google-to-timesheet' || syncDirection === 'external-to-timesheet') {
14
16
  return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'sync-direction-mismatch' } };
@@ -21,15 +23,22 @@ async function syncTaskToGoogleCalendar(input, context) {
21
23
  if (!task) {
22
24
  return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'task-not-found' } };
23
25
  }
24
- const projectId = task.project?.id;
26
+ const projectId = task.project?.id ?? input.item?.projectId;
25
27
  if (!projectId) {
26
28
  return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'missing-project' } };
27
29
  }
28
- const projectMapping = await context.mappings.get({
29
- system: SYSTEM,
30
- entity: PROJECT_ENTITY,
31
- localId: projectId
32
- });
30
+ // Use pre-loaded project mapping from cache if available
31
+ let projectMapping;
32
+ if (caches?.projectMappingByLocalId) {
33
+ projectMapping = caches.projectMappingByLocalId.get(projectId) ?? null;
34
+ }
35
+ else {
36
+ projectMapping = await context.mappings.get({
37
+ system: SYSTEM,
38
+ entity: PROJECT_ENTITY,
39
+ localId: projectId
40
+ });
41
+ }
33
42
  const externalCalendarId = projectMapping?.externalId;
34
43
  if (!externalCalendarId) {
35
44
  return {
@@ -39,12 +48,19 @@ async function syncTaskToGoogleCalendar(input, context) {
39
48
  details: { reason: 'missing-project-mapping', projectId }
40
49
  };
41
50
  }
42
- const client = createClient(context);
43
- const taskMapping = await context.mappings.get({
44
- system: SYSTEM,
45
- entity: TASK_ENTITY,
46
- localId: task.id
47
- });
51
+ const client = getOrCreateClient(context);
52
+ // Use pre-loaded task mapping from cache if available
53
+ let taskMapping;
54
+ if (caches?.taskMappingByLocalId) {
55
+ taskMapping = caches.taskMappingByLocalId.get(task.id) ?? null;
56
+ }
57
+ else {
58
+ taskMapping = await context.mappings.get({
59
+ system: SYSTEM,
60
+ entity: TASK_ENTITY,
61
+ localId: task.id
62
+ });
63
+ }
48
64
  if (task.deleted) {
49
65
  if (taskMapping?.externalId) {
50
66
  const mappedCalendarId = getMappedCalendarId(taskMapping) ?? externalCalendarId;
@@ -88,9 +104,7 @@ async function syncTaskToGoogleCalendar(input, context) {
88
104
  details: { reason: 'missing-external-id' }
89
105
  };
90
106
  }
91
- await context.mappings.upsert({
92
- system: SYSTEM,
93
- entity: TASK_ENTITY,
107
+ const upsertedMapping = {
94
108
  localId: task.id,
95
109
  externalId: externalEvent.id,
96
110
  externalLabel: externalEvent.summary ?? task.description ?? task.id,
@@ -100,7 +114,16 @@ async function syncTaskToGoogleCalendar(input, context) {
100
114
  updated: externalEvent.updated ?? ''
101
115
  },
102
116
  syncStatus: 'SYNCED'
117
+ };
118
+ await context.mappings.upsert({
119
+ system: SYSTEM,
120
+ entity: TASK_ENTITY,
121
+ ...upsertedMapping
103
122
  });
123
+ // Update in-memory cache so subsequent changes in the same batch see this mapping
124
+ if (caches?.taskMappingByLocalId) {
125
+ caches.taskMappingByLocalId.set(task.id, upsertedMapping);
126
+ }
104
127
  return {
105
128
  system: SYSTEM,
106
129
  status: 'synced',
@@ -318,10 +341,10 @@ function toTaskDateRange(event) {
318
341
  let endRaw = event.end?.dateTime;
319
342
  // Handle all-day events (date field instead of dateTime)
320
343
  if (!startRaw && event.start?.date) {
321
- startRaw = `${event.start.date}T00:00:00Z`;
344
+ startRaw = `${event.start.date}T00:00:00+00:00`;
322
345
  }
323
346
  if (!endRaw && event.end?.date) {
324
- endRaw = `${event.end.date}T00:00:00Z`;
347
+ endRaw = `${event.end.date}T00:00:00+00:00`;
325
348
  }
326
349
  if (!startRaw || !endRaw) {
327
350
  return null;
@@ -332,8 +355,8 @@ function toTaskDateRange(event) {
332
355
  return null;
333
356
  }
334
357
  return {
335
- startDateTime: start.toISOString(),
336
- endDateTime: end.toISOString()
358
+ startDateTime: toTimesheetDateTime(start),
359
+ endDateTime: toTimesheetDateTime(end)
337
360
  };
338
361
  }
339
362
  function buildGoogleEventPayload(task) {
@@ -392,14 +415,21 @@ function createClient(context) {
392
415
  refreshAccessToken: () => context.credentials.refreshToken('google')
393
416
  });
394
417
  }
418
+ function getOrCreateClient(context) {
419
+ if (!sharedClient) {
420
+ sharedClient = createClient(context);
421
+ }
422
+ return sharedClient;
423
+ }
395
424
  async function loadTask(taskId, input, context) {
425
+ // Prefer inline item data from sync change — avoids an API round-trip
426
+ if (input?.item && typeof input.item === 'object' && input.item.id) {
427
+ return input.item;
428
+ }
396
429
  try {
397
430
  return await context.data.getTask(taskId);
398
431
  }
399
432
  catch {
400
- if (input?.item && typeof input.item === 'object') {
401
- return input.item;
402
- }
403
433
  return null;
404
434
  }
405
435
  }
@@ -493,6 +523,10 @@ async function ensureWatchChannels(context) {
493
523
  }
494
524
  }
495
525
  }
526
+ /** Format a Date to the offset format the Timesheet backend expects: yyyy-MM-dd'T'HH:mm:ss+00:00 */
527
+ function toTimesheetDateTime(date) {
528
+ return date.toISOString().replace(/\.\d{3}Z$/, '+00:00');
529
+ }
496
530
  function readMetadataString(metadata, key) {
497
531
  const value = metadata[key];
498
532
  if (typeof value === 'string' && value.trim().length > 0) {
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "google-calendar-sync",
3
3
  "name": "Google Calendar Sync",
4
- "version": "1.2.1",
4
+ "version": "1.3.1",
5
5
  "description": "Synchronize Timesheet tasks with Google Calendar",
6
6
  "longDescription": "Bidirectional synchronization between Timesheet tasks and Google Calendar events.",
7
7
  "icon": "google-calendar",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timesheet/plugin-google-calendar",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [