@open-mercato/core 0.6.6-develop.6419.1.a632f417c4 → 0.6.6-develop.6423.1.f039f73ff4

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.
Files changed (22) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/progress/lib/progressServiceImpl.js +56 -24
  3. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  4. package/dist/modules/staff/api/timesheets/time-entries/route.js +3 -28
  5. package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
  6. package/dist/modules/staff/lib/timesheets/timeEntryListFilters.js +44 -0
  7. package/dist/modules/staff/lib/timesheets/timeEntryListFilters.js.map +7 -0
  8. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +1 -2
  9. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  10. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +1 -2
  11. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  12. package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js +1 -6
  13. package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js.map +2 -2
  14. package/jest.config.cjs +1 -1
  15. package/package.json +15 -15
  16. package/src/modules/progress/AGENTS.md +6 -0
  17. package/src/modules/progress/lib/progressServiceImpl.ts +85 -29
  18. package/src/modules/staff/api/timesheets/time-entries/route.ts +3 -32
  19. package/src/modules/staff/lib/timesheets/timeEntryListFilters.ts +64 -0
  20. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +1 -2
  21. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +2 -4
  22. package/src/modules/staff/widgets/injection/timer-sidebar-indicator/widget.tsx +1 -7
@@ -1,4 +1,4 @@
1
- [build:core] found 3454 entry points
1
+ [build:core] found 3455 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 186 entry points
4
4
  [build:core:generated] built successfully
@@ -2,6 +2,14 @@ import { ProgressJob } from "../data/entities.js";
2
2
  import { calculateEta, calculateProgressPercent, STALE_JOB_TIMEOUT_SECONDS } from "./progressService.js";
3
3
  import { PROGRESS_EVENTS } from "./events.js";
4
4
  import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
5
+ const DEFAULT_BROADCAST_MIN_INTERVAL_MS = 250;
6
+ function resolveBroadcastMinIntervalMs() {
7
+ const raw = process.env.OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS;
8
+ if (raw == null || raw.trim() === "") return DEFAULT_BROADCAST_MIN_INTERVAL_MS;
9
+ const parsed = Number.parseInt(raw, 10);
10
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_BROADCAST_MIN_INTERVAL_MS;
11
+ return parsed;
12
+ }
5
13
  function buildJobPayload(job) {
6
14
  return {
7
15
  jobId: job.id,
@@ -20,6 +28,44 @@ function buildJobPayload(job) {
20
28
  };
21
29
  }
22
30
  function createProgressService(em, eventBus) {
31
+ const broadcastMinIntervalMs = resolveBroadcastMinIntervalMs();
32
+ const jobUpdateThrottle = /* @__PURE__ */ new Map();
33
+ function jobScopeFilter(jobId, ctx) {
34
+ return {
35
+ id: jobId,
36
+ tenantId: ctx.tenantId,
37
+ ...ctx.organizationId ? { organizationId: ctx.organizationId } : {}
38
+ };
39
+ }
40
+ async function loadUpdatableJob(jobId, ctx) {
41
+ const cached = jobUpdateThrottle.get(jobId);
42
+ if (cached) return cached.job;
43
+ return em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx));
44
+ }
45
+ async function commitJobUpdate(job, ctx) {
46
+ const now = Date.now();
47
+ const cached = jobUpdateThrottle.get(job.id);
48
+ const shouldBroadcast = broadcastMinIntervalMs <= 0 || cached == null || now - cached.lastBroadcastAt >= broadcastMinIntervalMs || Math.abs(job.progressPercent - cached.lastBroadcastPercent) >= 1;
49
+ if (shouldBroadcast) {
50
+ await em.flush();
51
+ await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
52
+ ...buildJobPayload(job),
53
+ tenantId: ctx.tenantId,
54
+ organizationId: job.organizationId ?? null
55
+ });
56
+ jobUpdateThrottle.set(job.id, { job, lastBroadcastAt: now, lastBroadcastPercent: job.progressPercent });
57
+ } else {
58
+ jobUpdateThrottle.set(job.id, {
59
+ job,
60
+ lastBroadcastAt: cached.lastBroadcastAt,
61
+ lastBroadcastPercent: cached.lastBroadcastPercent
62
+ });
63
+ }
64
+ return job;
65
+ }
66
+ function forgetJobThrottle(jobId) {
67
+ jobUpdateThrottle.delete(jobId);
68
+ }
23
69
  return {
24
70
  async createJob(input, ctx) {
25
71
  const job = em.create(ProgressJob, {
@@ -66,12 +112,9 @@ function createProgressService(em, eventBus) {
66
112
  return job;
67
113
  },
68
114
  async updateProgress(jobId, input, ctx) {
69
- const job = await em.findOneOrFail(ProgressJob, {
70
- id: jobId,
71
- tenantId: ctx.tenantId,
72
- ...ctx.organizationId ? { organizationId: ctx.organizationId } : {}
73
- });
115
+ const job = await loadUpdatableJob(jobId, ctx);
74
116
  if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") {
117
+ forgetJobThrottle(jobId);
75
118
  return job;
76
119
  }
77
120
  if (input.processedCount !== void 0) {
@@ -94,21 +137,12 @@ function createProgressService(em, eventBus) {
94
137
  job.etaSeconds = calculateEta(job.processedCount, job.totalCount, job.startedAt);
95
138
  }
96
139
  job.heartbeatAt = /* @__PURE__ */ new Date();
97
- await em.flush();
98
- await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
99
- ...buildJobPayload(job),
100
- tenantId: ctx.tenantId,
101
- organizationId: job.organizationId ?? null
102
- });
103
- return job;
140
+ return commitJobUpdate(job, ctx);
104
141
  },
105
142
  async incrementProgress(jobId, delta, ctx) {
106
- const job = await em.findOneOrFail(ProgressJob, {
107
- id: jobId,
108
- tenantId: ctx.tenantId,
109
- ...ctx.organizationId ? { organizationId: ctx.organizationId } : {}
110
- });
143
+ const job = await loadUpdatableJob(jobId, ctx);
111
144
  if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") {
145
+ forgetJobThrottle(jobId);
112
146
  return job;
113
147
  }
114
148
  job.processedCount += delta;
@@ -119,13 +153,7 @@ function createProgressService(em, eventBus) {
119
153
  job.etaSeconds = calculateEta(job.processedCount, job.totalCount, job.startedAt);
120
154
  }
121
155
  }
122
- await em.flush();
123
- await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
124
- ...buildJobPayload(job),
125
- tenantId: ctx.tenantId,
126
- organizationId: job.organizationId ?? null
127
- });
128
- return job;
156
+ return commitJobUpdate(job, ctx);
129
157
  },
130
158
  async completeJob(jobId, input, ctx) {
131
159
  const job = await em.findOne(ProgressJob, {
@@ -151,6 +179,7 @@ function createProgressService(em, eventBus) {
151
179
  tenantId: ctx.tenantId,
152
180
  organizationId: job.organizationId ?? null
153
181
  });
182
+ forgetJobThrottle(jobId);
154
183
  return job;
155
184
  },
156
185
  async failJob(jobId, input, ctx) {
@@ -174,6 +203,7 @@ function createProgressService(em, eventBus) {
174
203
  tenantId: ctx.tenantId,
175
204
  organizationId: job.organizationId ?? null
176
205
  });
206
+ forgetJobThrottle(jobId);
177
207
  return job;
178
208
  },
179
209
  async cancelJob(jobId, ctx) {
@@ -196,6 +226,7 @@ function createProgressService(em, eventBus) {
196
226
  tenantId: ctx.tenantId,
197
227
  organizationId: job.organizationId ?? null
198
228
  });
229
+ forgetJobThrottle(jobId);
199
230
  return job;
200
231
  },
201
232
  async markCancelled(jobId, ctx) {
@@ -219,6 +250,7 @@ function createProgressService(em, eventBus) {
219
250
  tenantId: ctx.tenantId,
220
251
  organizationId: job.organizationId ?? null
221
252
  });
253
+ forgetJobThrottle(jobId);
222
254
  return job;
223
255
  },
224
256
  async isCancellationRequested(jobId, tenantId, organizationId) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/progress/lib/progressServiceImpl.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { ProgressJob } from '../data/entities'\nimport type { ProgressService } from './progressService'\nimport { calculateEta, calculateProgressPercent, STALE_JOB_TIMEOUT_SECONDS } from './progressService'\nimport { PROGRESS_EVENTS } from './events'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nfunction buildJobPayload(job: ProgressJob): Record<string, unknown> {\n return {\n jobId: job.id,\n jobType: job.jobType,\n name: job.name,\n description: job.description ?? null,\n status: job.status,\n progressPercent: job.progressPercent,\n processedCount: job.processedCount,\n totalCount: job.totalCount ?? null,\n etaSeconds: job.etaSeconds ?? null,\n cancellable: job.cancellable,\n meta: job.meta ?? null,\n startedAt: job.startedAt?.toISOString() ?? null,\n finishedAt: job.finishedAt?.toISOString() ?? null,\n }\n}\n\nexport function createProgressService(em: EntityManager, eventBus: { emit: (event: string, payload: Record<string, unknown>) => Promise<void> }): ProgressService {\n return {\n async createJob(input, ctx) {\n const job = em.create(ProgressJob, {\n jobType: input.jobType,\n name: input.name,\n description: input.description,\n totalCount: input.totalCount,\n cancellable: input.cancellable ?? false,\n meta: input.meta,\n parentJobId: input.parentJobId,\n partitionIndex: input.partitionIndex,\n partitionCount: input.partitionCount,\n startedByUserId: ctx.userId,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n status: 'pending',\n })\n\n await em.persist(job).flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_CREATED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n })\n\n return job\n },\n\n async startJob(jobId, ctx) {\n const job = await em.findOneOrFail(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (job.status === 'cancelled') {\n return job\n }\n\n job.status = 'running'\n job.startedAt = new Date()\n job.heartbeatAt = new Date()\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_STARTED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async updateProgress(jobId, input, ctx) {\n const job = await em.findOneOrFail(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {\n return job\n }\n\n if (input.processedCount !== undefined) {\n job.processedCount = input.processedCount\n }\n if (input.totalCount !== undefined) {\n job.totalCount = input.totalCount\n }\n if (input.meta !== undefined) {\n job.meta = { ...job.meta, ...input.meta }\n }\n\n if (input.progressPercent !== undefined) {\n job.progressPercent = input.progressPercent\n } else if (job.totalCount) {\n job.progressPercent = calculateProgressPercent(job.processedCount, job.totalCount)\n }\n\n if (input.etaSeconds !== undefined) {\n job.etaSeconds = input.etaSeconds\n } else if (job.startedAt && job.totalCount) {\n job.etaSeconds = calculateEta(job.processedCount, job.totalCount, job.startedAt)\n }\n\n job.heartbeatAt = new Date()\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async incrementProgress(jobId, delta, ctx) {\n const job = await em.findOneOrFail(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {\n return job\n }\n\n job.processedCount += delta\n job.heartbeatAt = new Date()\n\n if (job.totalCount) {\n job.progressPercent = calculateProgressPercent(job.processedCount, job.totalCount)\n if (job.startedAt) {\n job.etaSeconds = calculateEta(job.processedCount, job.totalCount, job.startedAt)\n }\n }\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async completeJob(jobId, input, ctx) {\n const job = await em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (!job) throw new Error(`Job ${jobId} not found`)\n if (job.status === 'cancelled') {\n return job\n }\n\n job.status = 'completed'\n job.finishedAt = new Date()\n job.progressPercent = 100\n job.etaSeconds = 0\n if (input?.resultSummary) {\n job.resultSummary = input.resultSummary\n }\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_COMPLETED, {\n ...buildJobPayload(job),\n resultSummary: job.resultSummary,\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async failJob(jobId, input, ctx) {\n const job = await em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (!job) throw new Error(`Job ${jobId} not found`)\n if (job.status === 'cancelled') {\n return job\n }\n\n job.status = 'failed'\n job.finishedAt = new Date()\n job.errorMessage = input.errorMessage\n job.errorStack = input.errorStack\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {\n ...buildJobPayload(job),\n errorMessage: job.errorMessage,\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async cancelJob(jobId, ctx) {\n const job = await em.findOneOrFail(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n cancellable: true,\n status: { $in: ['pending', 'running'] },\n })\n\n job.cancelRequestedAt = new Date()\n job.cancelledByUserId = ctx.userId\n\n if (job.status === 'pending') {\n job.status = 'cancelled'\n job.finishedAt = new Date()\n }\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async markCancelled(jobId, ctx) {\n const job = await em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (!job) throw new Error(`Job ${jobId} not found`)\n if (job.status === 'cancelled') {\n return job\n }\n\n job.cancelRequestedAt = job.cancelRequestedAt ?? new Date()\n job.cancelledByUserId = ctx.userId\n job.status = 'cancelled'\n job.finishedAt = job.finishedAt ?? new Date()\n job.etaSeconds = 0\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async isCancellationRequested(jobId, tenantId, organizationId) {\n const job = await findOneWithDecryption(em, ProgressJob, {\n id: jobId,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n })\n return job?.cancelRequestedAt != null\n },\n\n async getActiveJobs(ctx) {\n return em.find(ProgressJob, {\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n status: { $in: ['pending', 'running'] },\n parentJobId: null,\n }, {\n orderBy: { createdAt: 'DESC' },\n limit: 50,\n })\n },\n\n async getRecentlyCompletedJobs(ctx, sinceSeconds = 30) {\n const cutoff = new Date(Date.now() - sinceSeconds * 1000)\n return em.find(ProgressJob, {\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n status: { $in: ['completed', 'failed'] },\n finishedAt: { $gte: cutoff },\n parentJobId: null,\n }, {\n orderBy: { finishedAt: 'DESC' },\n limit: 10,\n })\n },\n\n async getJob(jobId, ctx) {\n return em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n },\n\n async markStaleJobsFailed(tenantId: string, timeoutSeconds = STALE_JOB_TIMEOUT_SECONDS, organizationId?: string | null) {\n const cutoff = new Date(Date.now() - timeoutSeconds * 1000)\n\n const staleJobs = await em.find(ProgressJob, {\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n status: 'running',\n $or: [\n { heartbeatAt: { $lt: cutoff } },\n {\n heartbeatAt: null,\n startedAt: { $lt: cutoff },\n },\n ],\n })\n\n for (const job of staleJobs) {\n job.status = 'failed'\n job.finishedAt = new Date()\n job.errorMessage = `Job stale: no heartbeat for ${timeoutSeconds} seconds`\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {\n ...buildJobPayload(job),\n errorMessage: job.errorMessage,\n tenantId: job.tenantId,\n stale: true,\n organizationId: job.organizationId ?? null,\n })\n }\n\n await em.flush()\n return staleJobs.length\n },\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { ProgressJob } from '../data/entities'\nimport type { ProgressService, ProgressServiceContext } from './progressService'\nimport { calculateEta, calculateProgressPercent, STALE_JOB_TIMEOUT_SECONDS } from './progressService'\nimport { PROGRESS_EVENTS } from './events'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nconst DEFAULT_BROADCAST_MIN_INTERVAL_MS = 250\n\n// Minimum elapsed time between coalesced `progress.job.updated` flush+broadcasts for a\n// single job. Bulk workers call updateProgress/incrementProgress once per record, and\n// every emit of the `clientBroadcast: true` event pays a serialized pg_notify roundtrip\n// plus a tenant-wide SSE fan-out. Setting the knob to 0 restores per-record emission.\nfunction resolveBroadcastMinIntervalMs(): number {\n const raw = process.env.OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS\n if (raw == null || raw.trim() === '') return DEFAULT_BROADCAST_MIN_INTERVAL_MS\n const parsed = Number.parseInt(raw, 10)\n if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_BROADCAST_MIN_INTERVAL_MS\n return parsed\n}\n\ntype JobUpdateThrottleEntry = {\n job: ProgressJob\n lastBroadcastAt: number\n lastBroadcastPercent: number\n}\n\nfunction buildJobPayload(job: ProgressJob): Record<string, unknown> {\n return {\n jobId: job.id,\n jobType: job.jobType,\n name: job.name,\n description: job.description ?? null,\n status: job.status,\n progressPercent: job.progressPercent,\n processedCount: job.processedCount,\n totalCount: job.totalCount ?? null,\n etaSeconds: job.etaSeconds ?? null,\n cancellable: job.cancellable,\n meta: job.meta ?? null,\n startedAt: job.startedAt?.toISOString() ?? null,\n finishedAt: job.finishedAt?.toISOString() ?? null,\n }\n}\n\nexport function createProgressService(em: EntityManager, eventBus: { emit: (event: string, payload: Record<string, unknown>) => Promise<void> }): ProgressService {\n const broadcastMinIntervalMs = resolveBroadcastMinIntervalMs()\n // Per-job coalescing state, scoped to this service instance (request/worker scope).\n // The cached managed entity doubles as the in-memory buffer: intermediate updates mutate\n // it without flushing, and a single flush+emit at the throttle boundary persists the\n // accumulated change. Domain commands fork their own EntityManager, so no other operation\n // queries this job on `em` between calls, keeping the buffered changes safe until flush.\n const jobUpdateThrottle = new Map<string, JobUpdateThrottleEntry>()\n\n function jobScopeFilter(jobId: string, ctx: ProgressServiceContext) {\n return {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n }\n }\n\n async function loadUpdatableJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob> {\n const cached = jobUpdateThrottle.get(jobId)\n if (cached) return cached.job\n return em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx))\n }\n\n async function commitJobUpdate(job: ProgressJob, ctx: ProgressServiceContext): Promise<ProgressJob> {\n const now = Date.now()\n const cached = jobUpdateThrottle.get(job.id)\n const shouldBroadcast =\n broadcastMinIntervalMs <= 0 ||\n cached == null ||\n now - cached.lastBroadcastAt >= broadcastMinIntervalMs ||\n Math.abs(job.progressPercent - cached.lastBroadcastPercent) >= 1\n\n if (shouldBroadcast) {\n await em.flush()\n await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n jobUpdateThrottle.set(job.id, { job, lastBroadcastAt: now, lastBroadcastPercent: job.progressPercent })\n } else {\n jobUpdateThrottle.set(job.id, {\n job,\n lastBroadcastAt: cached.lastBroadcastAt,\n lastBroadcastPercent: cached.lastBroadcastPercent,\n })\n }\n\n return job\n }\n\n function forgetJobThrottle(jobId: string) {\n jobUpdateThrottle.delete(jobId)\n }\n\n return {\n async createJob(input, ctx) {\n const job = em.create(ProgressJob, {\n jobType: input.jobType,\n name: input.name,\n description: input.description,\n totalCount: input.totalCount,\n cancellable: input.cancellable ?? false,\n meta: input.meta,\n parentJobId: input.parentJobId,\n partitionIndex: input.partitionIndex,\n partitionCount: input.partitionCount,\n startedByUserId: ctx.userId,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n status: 'pending',\n })\n\n await em.persist(job).flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_CREATED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n })\n\n return job\n },\n\n async startJob(jobId, ctx) {\n const job = await em.findOneOrFail(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (job.status === 'cancelled') {\n return job\n }\n\n job.status = 'running'\n job.startedAt = new Date()\n job.heartbeatAt = new Date()\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_STARTED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n return job\n },\n\n async updateProgress(jobId, input, ctx) {\n const job = await loadUpdatableJob(jobId, ctx)\n if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {\n forgetJobThrottle(jobId)\n return job\n }\n\n if (input.processedCount !== undefined) {\n job.processedCount = input.processedCount\n }\n if (input.totalCount !== undefined) {\n job.totalCount = input.totalCount\n }\n if (input.meta !== undefined) {\n job.meta = { ...job.meta, ...input.meta }\n }\n\n if (input.progressPercent !== undefined) {\n job.progressPercent = input.progressPercent\n } else if (job.totalCount) {\n job.progressPercent = calculateProgressPercent(job.processedCount, job.totalCount)\n }\n\n if (input.etaSeconds !== undefined) {\n job.etaSeconds = input.etaSeconds\n } else if (job.startedAt && job.totalCount) {\n job.etaSeconds = calculateEta(job.processedCount, job.totalCount, job.startedAt)\n }\n\n job.heartbeatAt = new Date()\n\n return commitJobUpdate(job, ctx)\n },\n\n async incrementProgress(jobId, delta, ctx) {\n const job = await loadUpdatableJob(jobId, ctx)\n if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {\n forgetJobThrottle(jobId)\n return job\n }\n\n job.processedCount += delta\n job.heartbeatAt = new Date()\n\n if (job.totalCount) {\n job.progressPercent = calculateProgressPercent(job.processedCount, job.totalCount)\n if (job.startedAt) {\n job.etaSeconds = calculateEta(job.processedCount, job.totalCount, job.startedAt)\n }\n }\n\n return commitJobUpdate(job, ctx)\n },\n\n async completeJob(jobId, input, ctx) {\n const job = await em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (!job) throw new Error(`Job ${jobId} not found`)\n if (job.status === 'cancelled') {\n return job\n }\n\n job.status = 'completed'\n job.finishedAt = new Date()\n job.progressPercent = 100\n job.etaSeconds = 0\n if (input?.resultSummary) {\n job.resultSummary = input.resultSummary\n }\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_COMPLETED, {\n ...buildJobPayload(job),\n resultSummary: job.resultSummary,\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n forgetJobThrottle(jobId)\n return job\n },\n\n async failJob(jobId, input, ctx) {\n const job = await em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (!job) throw new Error(`Job ${jobId} not found`)\n if (job.status === 'cancelled') {\n return job\n }\n\n job.status = 'failed'\n job.finishedAt = new Date()\n job.errorMessage = input.errorMessage\n job.errorStack = input.errorStack\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {\n ...buildJobPayload(job),\n errorMessage: job.errorMessage,\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n forgetJobThrottle(jobId)\n return job\n },\n\n async cancelJob(jobId, ctx) {\n const job = await em.findOneOrFail(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n cancellable: true,\n status: { $in: ['pending', 'running'] },\n })\n\n job.cancelRequestedAt = new Date()\n job.cancelledByUserId = ctx.userId\n\n if (job.status === 'pending') {\n job.status = 'cancelled'\n job.finishedAt = new Date()\n }\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n forgetJobThrottle(jobId)\n return job\n },\n\n async markCancelled(jobId, ctx) {\n const job = await em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n if (!job) throw new Error(`Job ${jobId} not found`)\n if (job.status === 'cancelled') {\n return job\n }\n\n job.cancelRequestedAt = job.cancelRequestedAt ?? new Date()\n job.cancelledByUserId = ctx.userId\n job.status = 'cancelled'\n job.finishedAt = job.finishedAt ?? new Date()\n job.etaSeconds = 0\n\n await em.flush()\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {\n ...buildJobPayload(job),\n tenantId: ctx.tenantId,\n organizationId: job.organizationId ?? null,\n })\n\n forgetJobThrottle(jobId)\n return job\n },\n\n async isCancellationRequested(jobId, tenantId, organizationId) {\n const job = await findOneWithDecryption(em, ProgressJob, {\n id: jobId,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n })\n return job?.cancelRequestedAt != null\n },\n\n async getActiveJobs(ctx) {\n return em.find(ProgressJob, {\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n status: { $in: ['pending', 'running'] },\n parentJobId: null,\n }, {\n orderBy: { createdAt: 'DESC' },\n limit: 50,\n })\n },\n\n async getRecentlyCompletedJobs(ctx, sinceSeconds = 30) {\n const cutoff = new Date(Date.now() - sinceSeconds * 1000)\n return em.find(ProgressJob, {\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n status: { $in: ['completed', 'failed'] },\n finishedAt: { $gte: cutoff },\n parentJobId: null,\n }, {\n orderBy: { finishedAt: 'DESC' },\n limit: 10,\n })\n },\n\n async getJob(jobId, ctx) {\n return em.findOne(ProgressJob, {\n id: jobId,\n tenantId: ctx.tenantId,\n ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),\n })\n },\n\n async markStaleJobsFailed(tenantId: string, timeoutSeconds = STALE_JOB_TIMEOUT_SECONDS, organizationId?: string | null) {\n const cutoff = new Date(Date.now() - timeoutSeconds * 1000)\n\n const staleJobs = await em.find(ProgressJob, {\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n status: 'running',\n $or: [\n { heartbeatAt: { $lt: cutoff } },\n {\n heartbeatAt: null,\n startedAt: { $lt: cutoff },\n },\n ],\n })\n\n for (const job of staleJobs) {\n job.status = 'failed'\n job.finishedAt = new Date()\n job.errorMessage = `Job stale: no heartbeat for ${timeoutSeconds} seconds`\n\n await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {\n ...buildJobPayload(job),\n errorMessage: job.errorMessage,\n tenantId: job.tenantId,\n stale: true,\n organizationId: job.organizationId ?? null,\n })\n }\n\n await em.flush()\n return staleJobs.length\n },\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,6 +4,7 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
4
4
  import { resolveCrudRecordId, parseScopedCommandInput } from "@open-mercato/shared/lib/api/scoped";
5
5
  import { StaffTimeEntry } from "../../../data/entities.js";
6
6
  import { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from "../../../data/validators.js";
7
+ import { buildTimeEntryListFilters, isParseableDateFilter } from "../../../lib/timesheets/timeEntryListFilters.js";
7
8
  import { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from "../../openapi.js";
8
9
  const F = {
9
10
  id: "id",
@@ -32,12 +33,6 @@ const routeMetadata = {
32
33
  };
33
34
  const metadata = routeMetadata;
34
35
  const rawBodySchema = z.object({}).passthrough();
35
- const isParseableDateFilter = (value) => {
36
- const trimmed = value.trim();
37
- if (trimmed.length === 0) return true;
38
- if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(trimmed)) return false;
39
- return !Number.isNaN(new Date(trimmed).getTime());
40
- };
41
36
  const dateFilterSchema = z.string().refine(isParseableDateFilter, { message: "Invalid date" }).optional();
42
37
  const listSchema = z.object({
43
38
  page: z.coerce.number().min(1).default(1),
@@ -47,6 +42,7 @@ const listSchema = z.object({
47
42
  to: dateFilterSchema,
48
43
  projectId: z.string().uuid().optional(),
49
44
  ids: z.string().optional(),
45
+ running: z.string().optional(),
50
46
  sortField: z.string().optional(),
51
47
  sortDir: z.enum(["asc", "desc"]).optional()
52
48
  }).passthrough();
@@ -87,28 +83,7 @@ const crud = makeCrudRoute({
87
83
  updatedAt: F.updated_at,
88
84
  durationMinutes: F.duration_minutes
89
85
  },
90
- buildFilters: async (query) => {
91
- const filters = {};
92
- if (typeof query.ids === "string" && query.ids.trim().length > 0) {
93
- const ids = query.ids.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
94
- if (ids.length > 0) {
95
- filters[F.id] = { $in: ids };
96
- }
97
- }
98
- if (typeof query.staffMemberId === "string" && query.staffMemberId.length > 0) {
99
- filters[F.staff_member_id] = query.staffMemberId;
100
- }
101
- if (typeof query.from === "string" && query.from.length > 0 && isParseableDateFilter(query.from)) {
102
- filters[F.date] = { ...filters[F.date] ?? {}, $gte: query.from };
103
- }
104
- if (typeof query.to === "string" && query.to.length > 0 && isParseableDateFilter(query.to)) {
105
- filters[F.date] = { ...filters[F.date] ?? {}, $lte: query.to };
106
- }
107
- if (typeof query.projectId === "string" && query.projectId.length > 0) {
108
- filters[F.time_project_id] = query.projectId;
109
- }
110
- return filters;
111
- }
86
+ buildFilters: async (query) => buildTimeEntryListFilters(query)
112
87
  },
113
88
  actions: {
114
89
  create: {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/staff/api/timesheets/time-entries/route.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { StaffTimeEntry } from '../../../data/entities'\nimport { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from '../../../data/validators'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n staff_member_id: 'staff_member_id',\n date: 'date',\n duration_minutes: 'duration_minutes',\n started_at: 'started_at',\n ended_at: 'ended_at',\n notes: 'notes',\n time_project_id: 'time_project_id',\n customer_id: 'customer_id',\n deal_id: 'deal_id',\n order_id: 'order_id',\n source: 'source',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst isParseableDateFilter = (value: string): boolean => {\n const trimmed = value.trim()\n if (trimmed.length === 0) return true\n if (!/^\\d{4}-\\d{2}-\\d{2}([T ].*)?$/.test(trimmed)) return false\n return !Number.isNaN(new Date(trimmed).getTime())\n}\n\nconst dateFilterSchema = z\n .string()\n .refine(isParseableDateFilter, { message: 'Invalid date' })\n .optional()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n staffMemberId: z.string().uuid().optional(),\n from: dateFilterSchema,\n to: dateFilterSchema,\n projectId: z.string().uuid().optional(),\n ids: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeEntry,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n indexer: { entityType: 'staff:staff_time_entry' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_entry',\n fields: [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.staff_member_id,\n F.date,\n F.duration_minutes,\n F.started_at,\n F.ended_at,\n F.notes,\n F.time_project_id,\n F.customer_id,\n F.deal_id,\n F.order_id,\n F.source,\n F.created_at,\n F.updated_at,\n ],\n sortFieldMap: {\n date: F.date,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n durationMinutes: F.duration_minutes,\n },\n buildFilters: async (query) => {\n const filters: Record<string, unknown> = {}\n if (typeof query.ids === 'string' && query.ids.trim().length > 0) {\n const ids = query.ids\n .split(',')\n .map((value) => value.trim())\n .filter((value) => value.length > 0)\n if (ids.length > 0) {\n filters[F.id] = { $in: ids }\n }\n }\n if (typeof query.staffMemberId === 'string' && query.staffMemberId.length > 0) {\n filters[F.staff_member_id] = query.staffMemberId\n }\n if (typeof query.from === 'string' && query.from.length > 0 && isParseableDateFilter(query.from)) {\n filters[F.date] = { ...((filters[F.date] as Record<string, unknown>) ?? {}), $gte: query.from }\n }\n if (typeof query.to === 'string' && query.to.length > 0 && isParseableDateFilter(query.to)) {\n filters[F.date] = { ...((filters[F.date] as Record<string, unknown>) ?? {}), $lte: query.to }\n }\n if (typeof query.projectId === 'string' && query.projectId.length > 0) {\n filters[F.time_project_id] = query.projectId\n }\n return filters\n },\n },\n actions: {\n create: {\n commandId: 'staff.timesheets.time_entries.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeEntryId ?? null }),\n status: 201,\n },\n update: {\n commandId: 'staff.timesheets.time_entries.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'staff.timesheets.time_entries.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nconst timeEntryListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n staff_member_id: z.string().uuid().nullable().optional(),\n date: z.string().nullable().optional(),\n duration_minutes: z.number().nullable().optional(),\n started_at: z.string().nullable().optional(),\n ended_at: z.string().nullable().optional(),\n notes: z.string().nullable().optional(),\n time_project_id: z.string().uuid().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n deal_id: z.string().uuid().nullable().optional(),\n order_id: z.string().uuid().nullable().optional(),\n source: z.string().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n})\n\nexport const openApi = createStaffCrudOpenApi({\n resourceName: 'TimeEntry',\n pluralName: 'TimeEntries',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema),\n create: {\n schema: staffTimeEntryCreateSchema,\n description: 'Creates a time entry for a staff member.',\n },\n update: {\n schema: staffTimeEntryUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates a time entry by id.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Deletes a time entry by id.',\n },\n})\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { StaffTimeEntry } from '../../../data/entities'\nimport { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from '../../../data/validators'\nimport { buildTimeEntryListFilters, isParseableDateFilter } from '../../../lib/timesheets/timeEntryListFilters'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n staff_member_id: 'staff_member_id',\n date: 'date',\n duration_minutes: 'duration_minutes',\n started_at: 'started_at',\n ended_at: 'ended_at',\n notes: 'notes',\n time_project_id: 'time_project_id',\n customer_id: 'customer_id',\n deal_id: 'deal_id',\n order_id: 'order_id',\n source: 'source',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst dateFilterSchema = z\n .string()\n .refine(isParseableDateFilter, { message: 'Invalid date' })\n .optional()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n staffMemberId: z.string().uuid().optional(),\n from: dateFilterSchema,\n to: dateFilterSchema,\n projectId: z.string().uuid().optional(),\n ids: z.string().optional(),\n running: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeEntry,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n indexer: { entityType: 'staff:staff_time_entry' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_entry',\n fields: [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.staff_member_id,\n F.date,\n F.duration_minutes,\n F.started_at,\n F.ended_at,\n F.notes,\n F.time_project_id,\n F.customer_id,\n F.deal_id,\n F.order_id,\n F.source,\n F.created_at,\n F.updated_at,\n ],\n sortFieldMap: {\n date: F.date,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n durationMinutes: F.duration_minutes,\n },\n buildFilters: async (query) => buildTimeEntryListFilters(query),\n },\n actions: {\n create: {\n commandId: 'staff.timesheets.time_entries.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeEntryId ?? null }),\n status: 201,\n },\n update: {\n commandId: 'staff.timesheets.time_entries.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'staff.timesheets.time_entries.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nconst timeEntryListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n staff_member_id: z.string().uuid().nullable().optional(),\n date: z.string().nullable().optional(),\n duration_minutes: z.number().nullable().optional(),\n started_at: z.string().nullable().optional(),\n ended_at: z.string().nullable().optional(),\n notes: z.string().nullable().optional(),\n time_project_id: z.string().uuid().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n deal_id: z.string().uuid().nullable().optional(),\n order_id: z.string().uuid().nullable().optional(),\n source: z.string().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n})\n\nexport const openApi = createStaffCrudOpenApi({\n resourceName: 'TimeEntry',\n pluralName: 'TimeEntries',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema),\n create: {\n schema: staffTimeEntryCreateSchema,\n description: 'Creates a time entry for a staff member.',\n },\n update: {\n schema: staffTimeEntryUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates a time entry by id.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Deletes a time entry by id.',\n },\n})\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,44 @@
1
+ import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
2
+ const ID_COLUMN = "id";
3
+ const STAFF_MEMBER_COLUMN = "staff_member_id";
4
+ const DATE_COLUMN = "date";
5
+ const STARTED_AT_COLUMN = "started_at";
6
+ const ENDED_AT_COLUMN = "ended_at";
7
+ const PROJECT_COLUMN = "time_project_id";
8
+ function isParseableDateFilter(value) {
9
+ const trimmed = value.trim();
10
+ if (trimmed.length === 0) return true;
11
+ if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(trimmed)) return false;
12
+ return !Number.isNaN(new Date(trimmed).getTime());
13
+ }
14
+ function buildTimeEntryListFilters(query) {
15
+ const filters = {};
16
+ if (typeof query.ids === "string" && query.ids.trim().length > 0) {
17
+ const ids = query.ids.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
18
+ if (ids.length > 0) {
19
+ filters[ID_COLUMN] = { $in: ids };
20
+ }
21
+ }
22
+ if (typeof query.staffMemberId === "string" && query.staffMemberId.length > 0) {
23
+ filters[STAFF_MEMBER_COLUMN] = query.staffMemberId;
24
+ }
25
+ if (typeof query.from === "string" && query.from.length > 0 && isParseableDateFilter(query.from)) {
26
+ filters[DATE_COLUMN] = { ...filters[DATE_COLUMN] ?? {}, $gte: query.from };
27
+ }
28
+ if (typeof query.to === "string" && query.to.length > 0 && isParseableDateFilter(query.to)) {
29
+ filters[DATE_COLUMN] = { ...filters[DATE_COLUMN] ?? {}, $lte: query.to };
30
+ }
31
+ if (typeof query.projectId === "string" && query.projectId.length > 0) {
32
+ filters[PROJECT_COLUMN] = query.projectId;
33
+ }
34
+ if (parseBooleanToken(query.running ?? null) === true) {
35
+ filters[STARTED_AT_COLUMN] = { $ne: null };
36
+ filters[ENDED_AT_COLUMN] = null;
37
+ }
38
+ return filters;
39
+ }
40
+ export {
41
+ buildTimeEntryListFilters,
42
+ isParseableDateFilter
43
+ };
44
+ //# sourceMappingURL=timeEntryListFilters.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/staff/lib/timesheets/timeEntryListFilters.ts"],
4
+ "sourcesContent": ["import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\n\nexport type TimeEntryListFilterQuery = {\n ids?: string\n staffMemberId?: string\n from?: string\n to?: string\n projectId?: string\n running?: string\n}\n\nconst ID_COLUMN = 'id'\nconst STAFF_MEMBER_COLUMN = 'staff_member_id'\nconst DATE_COLUMN = 'date'\nconst STARTED_AT_COLUMN = 'started_at'\nconst ENDED_AT_COLUMN = 'ended_at'\nconst PROJECT_COLUMN = 'time_project_id'\n\nexport function isParseableDateFilter(value: string): boolean {\n const trimmed = value.trim()\n if (trimmed.length === 0) return true\n if (!/^\\d{4}-\\d{2}-\\d{2}([T ].*)?$/.test(trimmed)) return false\n return !Number.isNaN(new Date(trimmed).getTime())\n}\n\n/**\n * Builds the query-engine filter map for the time-entries list route.\n *\n * The `running` flag matches the currently-open timer regardless of its date\n * (`started_at IS NOT NULL AND ended_at IS NULL`). A timer started before\n * midnight is still running the next day, so a `from=today&to=today` lookup\n * would miss it and leave the entry invisible/uncontrollable (issue #3717).\n * This mirrors the single-active-timer check in the `start_timer` command so\n * the UI can always surface and stop an orphaned overnight timer.\n */\nexport function buildTimeEntryListFilters(query: TimeEntryListFilterQuery): Record<string, unknown> {\n const filters: Record<string, unknown> = {}\n if (typeof query.ids === 'string' && query.ids.trim().length > 0) {\n const ids = query.ids\n .split(',')\n .map((value) => value.trim())\n .filter((value) => value.length > 0)\n if (ids.length > 0) {\n filters[ID_COLUMN] = { $in: ids }\n }\n }\n if (typeof query.staffMemberId === 'string' && query.staffMemberId.length > 0) {\n filters[STAFF_MEMBER_COLUMN] = query.staffMemberId\n }\n if (typeof query.from === 'string' && query.from.length > 0 && isParseableDateFilter(query.from)) {\n filters[DATE_COLUMN] = { ...((filters[DATE_COLUMN] as Record<string, unknown>) ?? {}), $gte: query.from }\n }\n if (typeof query.to === 'string' && query.to.length > 0 && isParseableDateFilter(query.to)) {\n filters[DATE_COLUMN] = { ...((filters[DATE_COLUMN] as Record<string, unknown>) ?? {}), $lte: query.to }\n }\n if (typeof query.projectId === 'string' && query.projectId.length > 0) {\n filters[PROJECT_COLUMN] = query.projectId\n }\n if (parseBooleanToken(query.running ?? null) === true) {\n filters[STARTED_AT_COLUMN] = { $ne: null }\n filters[ENDED_AT_COLUMN] = null\n }\n return filters\n}\n"],
5
+ "mappings": "AAAA,SAAS,yBAAyB;AAWlC,MAAM,YAAY;AAClB,MAAM,sBAAsB;AAC5B,MAAM,cAAc;AACpB,MAAM,oBAAoB;AAC1B,MAAM,kBAAkB;AACxB,MAAM,iBAAiB;AAEhB,SAAS,sBAAsB,OAAwB;AAC5D,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,MAAI,CAAC,+BAA+B,KAAK,OAAO,EAAG,QAAO;AAC1D,SAAO,CAAC,OAAO,MAAM,IAAI,KAAK,OAAO,EAAE,QAAQ,CAAC;AAClD;AAYO,SAAS,0BAA0B,OAA0D;AAClG,QAAM,UAAmC,CAAC;AAC1C,MAAI,OAAO,MAAM,QAAQ,YAAY,MAAM,IAAI,KAAK,EAAE,SAAS,GAAG;AAChE,UAAM,MAAM,MAAM,IACf,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AACrC,QAAI,IAAI,SAAS,GAAG;AAClB,cAAQ,SAAS,IAAI,EAAE,KAAK,IAAI;AAAA,IAClC;AAAA,EACF;AACA,MAAI,OAAO,MAAM,kBAAkB,YAAY,MAAM,cAAc,SAAS,GAAG;AAC7E,YAAQ,mBAAmB,IAAI,MAAM;AAAA,EACvC;AACA,MAAI,OAAO,MAAM,SAAS,YAAY,MAAM,KAAK,SAAS,KAAK,sBAAsB,MAAM,IAAI,GAAG;AAChG,YAAQ,WAAW,IAAI,EAAE,GAAK,QAAQ,WAAW,KAAiC,CAAC,GAAI,MAAM,MAAM,KAAK;AAAA,EAC1G;AACA,MAAI,OAAO,MAAM,OAAO,YAAY,MAAM,GAAG,SAAS,KAAK,sBAAsB,MAAM,EAAE,GAAG;AAC1F,YAAQ,WAAW,IAAI,EAAE,GAAK,QAAQ,WAAW,KAAiC,CAAC,GAAI,MAAM,MAAM,GAAG;AAAA,EACxG;AACA,MAAI,OAAO,MAAM,cAAc,YAAY,MAAM,UAAU,SAAS,GAAG;AACrE,YAAQ,cAAc,IAAI,MAAM;AAAA,EAClC;AACA,MAAI,kBAAkB,MAAM,WAAW,IAAI,MAAM,MAAM;AACrD,YAAQ,iBAAiB,IAAI,EAAE,KAAK,KAAK;AACzC,YAAQ,eAAe,IAAI;AAAA,EAC7B;AACA,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -66,10 +66,9 @@ function TimerBar({ projects, staffMemberId, onTimerStopped }) {
66
66
  }, []);
67
67
  useEffect(() => {
68
68
  if (!staffMemberId) return;
69
- const today = getToday();
70
69
  const checkActiveTimer = async () => {
71
70
  const response = await apiCall(
72
- `/api/staff/timesheets/time-entries?staffMemberId=${staffMemberId}&from=${today}&to=${today}&pageSize=50`
71
+ `/api/staff/timesheets/time-entries?staffMemberId=${staffMemberId}&running=true&pageSize=50`
73
72
  );
74
73
  if (!response.ok) return;
75
74
  const data = response.result;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/staff/lib/timesheets-ui/TimerBar.tsx"],
4
- "sourcesContent": ["'use client'\n\nimport { useState, useEffect, useRef, useCallback } from 'react'\nimport { Play, Square } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { ProjectColorDot } from './ProjectColorDot'\nimport { startTimerEntry } from './startTimer'\nimport { resolveTimerActionError } from './timerErrors'\n\ntype ProjectOption = {\n id: string\n name: string\n code: string | null\n color?: string | null\n}\n\ntype TimerBarProps = {\n projects: ProjectOption[]\n staffMemberId: string | null\n onTimerStopped: () => void\n}\n\nconst TIMER_MUTATION_CONTEXT_ID = 'staff-timesheets-timer-bar'\n\ntype TimerMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n staffMemberId: string | null\n action: 'timer-create' | 'timer-start' | 'timer-stop'\n retryLastMutation: () => Promise<boolean>\n}\n\nfunction formatElapsed(seconds: number): string {\n const h = Math.floor(seconds / 3600)\n const m = Math.floor((seconds % 3600) / 60)\n const s = seconds % 60\n return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`\n}\n\nfunction getToday(): string {\n const now = new Date()\n const year = now.getFullYear()\n const month = String(now.getMonth() + 1).padStart(2, '0')\n const day = String(now.getDate()).padStart(2, '0')\n return `${year}-${month}-${day}`\n}\n\nexport function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarProps) {\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<TimerMutationContext>({\n contextId: TIMER_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const [activeEntryId, setActiveEntryId] = useState<string | null>(null)\n const [activeProjectId, setActiveProjectId] = useState<string | null>(null)\n const [elapsedSeconds, setElapsedSeconds] = useState(0)\n const [description, setDescription] = useState('')\n const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)\n const [isStarting, setIsStarting] = useState(false)\n const [isStopping, setIsStopping] = useState(false)\n const [showProjectDropdown, setShowProjectDropdown] = useState(false)\n const [projectFilter, setProjectFilter] = useState('')\n\n const dropdownRef = useRef<HTMLDivElement>(null)\n const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n\n const isRunning = activeEntryId !== null\n\n const activeProject = projects.find((p) => p.id === activeProjectId)\n const selectedProject = projects.find((p) => p.id === selectedProjectId)\n\n const filteredProjects = projects.filter((p) =>\n p.name.toLowerCase().includes(projectFilter.toLowerCase()),\n )\n\n const startElapsedCounter = useCallback((startedAt: string) => {\n const startTime = new Date(startedAt).getTime()\n const calcElapsed = () => Math.max(0, Math.floor((Date.now() - startTime) / 1000))\n setElapsedSeconds(calcElapsed())\n\n if (intervalRef.current) clearInterval(intervalRef.current)\n intervalRef.current = setInterval(() => {\n setElapsedSeconds(calcElapsed())\n }, 1000)\n }, [])\n\n const stopElapsedCounter = useCallback(() => {\n if (intervalRef.current) {\n clearInterval(intervalRef.current)\n intervalRef.current = null\n }\n setElapsedSeconds(0)\n }, [])\n\n useEffect(() => {\n if (!staffMemberId) return\n\n const today = getToday()\n const checkActiveTimer = async () => {\n const response = await apiCall(\n `/api/staff/timesheets/time-entries?staffMemberId=${staffMemberId}&from=${today}&to=${today}&pageSize=50`,\n )\n if (!response.ok) return\n\n const data = response.result as Record<string, unknown> | null\n const items = (data?.items ?? data?.data ?? []) as Array<Record<string, unknown>>\n\n const activeEntry = items.find(\n (entry: Record<string, unknown>) =>\n entry.started_at && !entry.ended_at,\n )\n\n if (activeEntry) {\n setActiveEntryId(String(activeEntry.id ?? ''))\n setActiveProjectId(String(activeEntry.time_project_id ?? ''))\n setDescription(String(activeEntry.notes ?? ''))\n startElapsedCounter(String(activeEntry.started_at ?? ''))\n }\n }\n\n checkActiveTimer()\n }, [staffMemberId, startElapsedCounter])\n\n useEffect(() => {\n return () => {\n if (intervalRef.current) clearInterval(intervalRef.current)\n }\n }, [])\n\n useEffect(() => {\n function handleClickOutside(event: MouseEvent) {\n if (\n dropdownRef.current &&\n !dropdownRef.current.contains(event.target as Node)\n ) {\n setShowProjectDropdown(false)\n setProjectFilter('')\n }\n }\n\n if (showProjectDropdown) {\n document.addEventListener('mousedown', handleClickOutside)\n }\n return () => document.removeEventListener('mousedown', handleClickOutside)\n }, [showProjectDropdown])\n\n const handleStart = async () => {\n if (!selectedProjectId || !staffMemberId) return\n\n setIsStarting(true)\n try {\n const today = getToday()\n const startPayload = {\n staffMemberId,\n timeProjectId: selectedProjectId,\n date: today,\n notes: description || null,\n }\n const { id: entryId } = await runMutation({\n operation: () => startTimerEntry(startPayload),\n context: {\n formId: TIMER_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: staffMemberId,\n staffMemberId,\n action: 'timer-start',\n retryLastMutation,\n },\n mutationPayload: startPayload,\n })\n\n setActiveEntryId(entryId)\n setActiveProjectId(selectedProjectId)\n startElapsedCounter(new Date().toISOString())\n } catch (err) {\n flash(\n resolveTimerActionError(err, t('staff.timesheets.my.timer.startError', 'Failed to start timer')),\n 'error',\n )\n } finally {\n setIsStarting(false)\n }\n }\n\n const handleStop = async () => {\n if (!activeEntryId) return\n\n setIsStopping(true)\n try {\n const stopPayload = {\n id: activeEntryId,\n action: 'timer-stop',\n staffMemberId,\n }\n await runMutation({\n operation: () =>\n apiCallOrThrow(\n `/api/staff/timesheets/time-entries/${activeEntryId}/timer-stop`,\n { method: 'POST' },\n ),\n context: {\n formId: TIMER_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: activeEntryId,\n staffMemberId,\n action: 'timer-stop',\n retryLastMutation,\n },\n mutationPayload: stopPayload,\n })\n\n setActiveEntryId(null)\n setActiveProjectId(null)\n setDescription('')\n stopElapsedCounter()\n onTimerStopped()\n } catch (err) {\n flash(\n resolveTimerActionError(err, t('staff.timesheets.my.timer.stopError', 'Failed to stop timer')),\n 'error',\n )\n } finally {\n setIsStopping(false)\n }\n }\n\n return (\n <div className=\"flex items-center gap-3 rounded-lg border bg-card p-3 mb-4\">\n <input\n type=\"text\"\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n readOnly={isRunning}\n placeholder={t(\n 'staff.timesheets.my.timer.placeholder',\n 'What are you working on?',\n )}\n className=\"flex-1 bg-transparent border-0 outline-none text-sm placeholder:text-muted-foreground\"\n />\n\n <div className=\"relative\" ref={dropdownRef}>\n {isRunning ? (\n activeProject ? (\n <span className=\"inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded border bg-muted\">\n <ProjectColorDot colorKey={activeProject.color} projectName={activeProject.name} size=\"xs\" />\n {activeProject.name}\n </span>\n ) : null\n ) : (\n <>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => {\n setShowProjectDropdown(!showProjectDropdown)\n setProjectFilter('')\n }}\n className=\"text-xs font-medium\"\n >\n {selectedProject\n ? (<><ProjectColorDot colorKey={selectedProject.color} projectName={selectedProject.name} size=\"xs\" /><span className=\"ml-1\">{selectedProject.name}</span></>)\n : t('staff.timesheets.my.timer.selectProject', 'Project')}\n </Button>\n\n {showProjectDropdown && (\n <div className=\"absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-md border bg-popover p-1 shadow-md\">\n <input\n type=\"text\"\n value={projectFilter}\n onChange={(e) => setProjectFilter(e.target.value)}\n placeholder={t(\n 'staff.timesheets.my.timer.searchProject',\n 'Search projects...',\n )}\n className=\"w-full bg-transparent border-b px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground mb-1\"\n autoFocus\n />\n <div className=\"max-h-[200px] overflow-y-auto\">\n {filteredProjects.length === 0 ? (\n <div className=\"px-2 py-1.5 text-xs text-muted-foreground\">\n {t(\n 'staff.timesheets.my.timer.noProjects',\n 'No projects found',\n )}\n </div>\n ) : (\n filteredProjects.map((project) => (\n <Button\n key={project.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start text-xs h-auto py-1.5\"\n onClick={() => {\n setSelectedProjectId(project.id)\n setShowProjectDropdown(false)\n setProjectFilter('')\n }}\n >\n <ProjectColorDot colorKey={project.color} projectName={project.name} size=\"xs\" />\n <span className=\"ml-1\">{project.name}</span>\n {project.code ? (\n <span className=\"ml-1 text-muted-foreground\">\n ({project.code})\n </span>\n ) : null}\n </Button>\n ))\n )}\n </div>\n </div>\n )}\n </>\n )}\n </div>\n\n <span className=\"font-mono text-sm tabular-nums min-w-[64px] text-right\">\n {formatElapsed(elapsedSeconds)}\n </span>\n\n {isRunning ? (\n <IconButton\n type=\"button\"\n variant=\"destructive\"\n size=\"default\"\n onClick={handleStop}\n disabled={isStopping}\n aria-label={t('staff.timesheets.my.timer.stop', 'Stop timer')}\n >\n <Square className=\"size-4\" />\n </IconButton>\n ) : (\n <IconButton\n type=\"button\"\n variant=\"primary\"\n size=\"default\"\n onClick={handleStart}\n disabled={isStarting || !selectedProjectId}\n aria-label={t('staff.timesheets.my.timer.start', 'Start timer')}\n >\n <Play className=\"size-4\" />\n </IconButton>\n )}\n </div>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["'use client'\n\nimport { useState, useEffect, useRef, useCallback } from 'react'\nimport { Play, Square } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { ProjectColorDot } from './ProjectColorDot'\nimport { startTimerEntry } from './startTimer'\nimport { resolveTimerActionError } from './timerErrors'\n\ntype ProjectOption = {\n id: string\n name: string\n code: string | null\n color?: string | null\n}\n\ntype TimerBarProps = {\n projects: ProjectOption[]\n staffMemberId: string | null\n onTimerStopped: () => void\n}\n\nconst TIMER_MUTATION_CONTEXT_ID = 'staff-timesheets-timer-bar'\n\ntype TimerMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n staffMemberId: string | null\n action: 'timer-create' | 'timer-start' | 'timer-stop'\n retryLastMutation: () => Promise<boolean>\n}\n\nfunction formatElapsed(seconds: number): string {\n const h = Math.floor(seconds / 3600)\n const m = Math.floor((seconds % 3600) / 60)\n const s = seconds % 60\n return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`\n}\n\nfunction getToday(): string {\n const now = new Date()\n const year = now.getFullYear()\n const month = String(now.getMonth() + 1).padStart(2, '0')\n const day = String(now.getDate()).padStart(2, '0')\n return `${year}-${month}-${day}`\n}\n\nexport function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarProps) {\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<TimerMutationContext>({\n contextId: TIMER_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const [activeEntryId, setActiveEntryId] = useState<string | null>(null)\n const [activeProjectId, setActiveProjectId] = useState<string | null>(null)\n const [elapsedSeconds, setElapsedSeconds] = useState(0)\n const [description, setDescription] = useState('')\n const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)\n const [isStarting, setIsStarting] = useState(false)\n const [isStopping, setIsStopping] = useState(false)\n const [showProjectDropdown, setShowProjectDropdown] = useState(false)\n const [projectFilter, setProjectFilter] = useState('')\n\n const dropdownRef = useRef<HTMLDivElement>(null)\n const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n\n const isRunning = activeEntryId !== null\n\n const activeProject = projects.find((p) => p.id === activeProjectId)\n const selectedProject = projects.find((p) => p.id === selectedProjectId)\n\n const filteredProjects = projects.filter((p) =>\n p.name.toLowerCase().includes(projectFilter.toLowerCase()),\n )\n\n const startElapsedCounter = useCallback((startedAt: string) => {\n const startTime = new Date(startedAt).getTime()\n const calcElapsed = () => Math.max(0, Math.floor((Date.now() - startTime) / 1000))\n setElapsedSeconds(calcElapsed())\n\n if (intervalRef.current) clearInterval(intervalRef.current)\n intervalRef.current = setInterval(() => {\n setElapsedSeconds(calcElapsed())\n }, 1000)\n }, [])\n\n const stopElapsedCounter = useCallback(() => {\n if (intervalRef.current) {\n clearInterval(intervalRef.current)\n intervalRef.current = null\n }\n setElapsedSeconds(0)\n }, [])\n\n useEffect(() => {\n if (!staffMemberId) return\n\n const checkActiveTimer = async () => {\n const response = await apiCall(\n `/api/staff/timesheets/time-entries?staffMemberId=${staffMemberId}&running=true&pageSize=50`,\n )\n if (!response.ok) return\n\n const data = response.result as Record<string, unknown> | null\n const items = (data?.items ?? data?.data ?? []) as Array<Record<string, unknown>>\n\n const activeEntry = items.find(\n (entry: Record<string, unknown>) =>\n entry.started_at && !entry.ended_at,\n )\n\n if (activeEntry) {\n setActiveEntryId(String(activeEntry.id ?? ''))\n setActiveProjectId(String(activeEntry.time_project_id ?? ''))\n setDescription(String(activeEntry.notes ?? ''))\n startElapsedCounter(String(activeEntry.started_at ?? ''))\n }\n }\n\n checkActiveTimer()\n }, [staffMemberId, startElapsedCounter])\n\n useEffect(() => {\n return () => {\n if (intervalRef.current) clearInterval(intervalRef.current)\n }\n }, [])\n\n useEffect(() => {\n function handleClickOutside(event: MouseEvent) {\n if (\n dropdownRef.current &&\n !dropdownRef.current.contains(event.target as Node)\n ) {\n setShowProjectDropdown(false)\n setProjectFilter('')\n }\n }\n\n if (showProjectDropdown) {\n document.addEventListener('mousedown', handleClickOutside)\n }\n return () => document.removeEventListener('mousedown', handleClickOutside)\n }, [showProjectDropdown])\n\n const handleStart = async () => {\n if (!selectedProjectId || !staffMemberId) return\n\n setIsStarting(true)\n try {\n const today = getToday()\n const startPayload = {\n staffMemberId,\n timeProjectId: selectedProjectId,\n date: today,\n notes: description || null,\n }\n const { id: entryId } = await runMutation({\n operation: () => startTimerEntry(startPayload),\n context: {\n formId: TIMER_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: staffMemberId,\n staffMemberId,\n action: 'timer-start',\n retryLastMutation,\n },\n mutationPayload: startPayload,\n })\n\n setActiveEntryId(entryId)\n setActiveProjectId(selectedProjectId)\n startElapsedCounter(new Date().toISOString())\n } catch (err) {\n flash(\n resolveTimerActionError(err, t('staff.timesheets.my.timer.startError', 'Failed to start timer')),\n 'error',\n )\n } finally {\n setIsStarting(false)\n }\n }\n\n const handleStop = async () => {\n if (!activeEntryId) return\n\n setIsStopping(true)\n try {\n const stopPayload = {\n id: activeEntryId,\n action: 'timer-stop',\n staffMemberId,\n }\n await runMutation({\n operation: () =>\n apiCallOrThrow(\n `/api/staff/timesheets/time-entries/${activeEntryId}/timer-stop`,\n { method: 'POST' },\n ),\n context: {\n formId: TIMER_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: activeEntryId,\n staffMemberId,\n action: 'timer-stop',\n retryLastMutation,\n },\n mutationPayload: stopPayload,\n })\n\n setActiveEntryId(null)\n setActiveProjectId(null)\n setDescription('')\n stopElapsedCounter()\n onTimerStopped()\n } catch (err) {\n flash(\n resolveTimerActionError(err, t('staff.timesheets.my.timer.stopError', 'Failed to stop timer')),\n 'error',\n )\n } finally {\n setIsStopping(false)\n }\n }\n\n return (\n <div className=\"flex items-center gap-3 rounded-lg border bg-card p-3 mb-4\">\n <input\n type=\"text\"\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n readOnly={isRunning}\n placeholder={t(\n 'staff.timesheets.my.timer.placeholder',\n 'What are you working on?',\n )}\n className=\"flex-1 bg-transparent border-0 outline-none text-sm placeholder:text-muted-foreground\"\n />\n\n <div className=\"relative\" ref={dropdownRef}>\n {isRunning ? (\n activeProject ? (\n <span className=\"inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded border bg-muted\">\n <ProjectColorDot colorKey={activeProject.color} projectName={activeProject.name} size=\"xs\" />\n {activeProject.name}\n </span>\n ) : null\n ) : (\n <>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => {\n setShowProjectDropdown(!showProjectDropdown)\n setProjectFilter('')\n }}\n className=\"text-xs font-medium\"\n >\n {selectedProject\n ? (<><ProjectColorDot colorKey={selectedProject.color} projectName={selectedProject.name} size=\"xs\" /><span className=\"ml-1\">{selectedProject.name}</span></>)\n : t('staff.timesheets.my.timer.selectProject', 'Project')}\n </Button>\n\n {showProjectDropdown && (\n <div className=\"absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-md border bg-popover p-1 shadow-md\">\n <input\n type=\"text\"\n value={projectFilter}\n onChange={(e) => setProjectFilter(e.target.value)}\n placeholder={t(\n 'staff.timesheets.my.timer.searchProject',\n 'Search projects...',\n )}\n className=\"w-full bg-transparent border-b px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground mb-1\"\n autoFocus\n />\n <div className=\"max-h-[200px] overflow-y-auto\">\n {filteredProjects.length === 0 ? (\n <div className=\"px-2 py-1.5 text-xs text-muted-foreground\">\n {t(\n 'staff.timesheets.my.timer.noProjects',\n 'No projects found',\n )}\n </div>\n ) : (\n filteredProjects.map((project) => (\n <Button\n key={project.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start text-xs h-auto py-1.5\"\n onClick={() => {\n setSelectedProjectId(project.id)\n setShowProjectDropdown(false)\n setProjectFilter('')\n }}\n >\n <ProjectColorDot colorKey={project.color} projectName={project.name} size=\"xs\" />\n <span className=\"ml-1\">{project.name}</span>\n {project.code ? (\n <span className=\"ml-1 text-muted-foreground\">\n ({project.code})\n </span>\n ) : null}\n </Button>\n ))\n )}\n </div>\n </div>\n )}\n </>\n )}\n </div>\n\n <span className=\"font-mono text-sm tabular-nums min-w-[64px] text-right\">\n {formatElapsed(elapsedSeconds)}\n </span>\n\n {isRunning ? (\n <IconButton\n type=\"button\"\n variant=\"destructive\"\n size=\"default\"\n onClick={handleStop}\n disabled={isStopping}\n aria-label={t('staff.timesheets.my.timer.stop', 'Stop timer')}\n >\n <Square className=\"size-4\" />\n </IconButton>\n ) : (\n <IconButton\n type=\"button\"\n variant=\"primary\"\n size=\"default\"\n onClick={handleStart}\n disabled={isStarting || !selectedProjectId}\n aria-label={t('staff.timesheets.my.timer.start', 'Start timer')}\n >\n <Play className=\"size-4\" />\n </IconButton>\n )}\n </div>\n )\n}\n"],
5
+ "mappings": ";AA0OM,SAiCa,UAjCb,KAeM,YAfN;AAxON,SAAS,UAAU,WAAW,QAAQ,mBAAmB;AACzD,SAAS,MAAM,cAAc;AAC7B,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,YAAY;AACrB,SAAS,SAAS,sBAAsB;AACxC,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,uBAAuB;AAChC,SAAS,uBAAuB;AAChC,SAAS,+BAA+B;AAexC,MAAM,4BAA4B;AAWlC,SAAS,cAAc,SAAyB;AAC9C,QAAM,IAAI,KAAK,MAAM,UAAU,IAAI;AACnC,QAAM,IAAI,KAAK,MAAO,UAAU,OAAQ,EAAE;AAC1C,QAAM,IAAI,UAAU;AACpB,SAAO,GAAG,CAAC,IAAI,OAAO,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC,IAAI,OAAO,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC;AACzE;AAEA,SAAS,WAAmB;AAC1B,QAAM,MAAM,oBAAI,KAAK;AACrB,QAAM,OAAO,IAAI,YAAY;AAC7B,QAAM,QAAQ,OAAO,IAAI,SAAS,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG;AACxD,QAAM,MAAM,OAAO,IAAI,QAAQ,CAAC,EAAE,SAAS,GAAG,GAAG;AACjD,SAAO,GAAG,IAAI,IAAI,KAAK,IAAI,GAAG;AAChC;AAEO,SAAS,SAAS,EAAE,UAAU,eAAe,eAAe,GAAkB;AACnF,QAAM,IAAI,KAAK;AACf,QAAM,EAAE,aAAa,kBAAkB,IAAI,mBAAyC;AAAA,IAClF,WAAW;AAAA,IACX,gBAAgB,EAAE,8BAA8B,4BAA4B;AAAA,EAC9E,CAAC;AAED,QAAM,CAAC,eAAe,gBAAgB,IAAI,SAAwB,IAAI;AACtE,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,SAAwB,IAAI;AAC1E,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,SAAS,CAAC;AACtD,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,EAAE;AACjD,QAAM,CAAC,mBAAmB,oBAAoB,IAAI,SAAwB,IAAI;AAC9E,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,KAAK;AAClD,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,KAAK;AAClD,QAAM,CAAC,qBAAqB,sBAAsB,IAAI,SAAS,KAAK;AACpE,QAAM,CAAC,eAAe,gBAAgB,IAAI,SAAS,EAAE;AAErD,QAAM,cAAc,OAAuB,IAAI;AAC/C,QAAM,cAAc,OAA8C,IAAI;AAEtE,QAAM,YAAY,kBAAkB;AAEpC,QAAM,gBAAgB,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,eAAe;AACnE,QAAM,kBAAkB,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,iBAAiB;AAEvE,QAAM,mBAAmB,SAAS;AAAA,IAAO,CAAC,MACxC,EAAE,KAAK,YAAY,EAAE,SAAS,cAAc,YAAY,CAAC;AAAA,EAC3D;AAEA,QAAM,sBAAsB,YAAY,CAAC,cAAsB;AAC7D,UAAM,YAAY,IAAI,KAAK,SAAS,EAAE,QAAQ;AAC9C,UAAM,cAAc,MAAM,KAAK,IAAI,GAAG,KAAK,OAAO,KAAK,IAAI,IAAI,aAAa,GAAI,CAAC;AACjF,sBAAkB,YAAY,CAAC;AAE/B,QAAI,YAAY,QAAS,eAAc,YAAY,OAAO;AAC1D,gBAAY,UAAU,YAAY,MAAM;AACtC,wBAAkB,YAAY,CAAC;AAAA,IACjC,GAAG,GAAI;AAAA,EACT,GAAG,CAAC,CAAC;AAEL,QAAM,qBAAqB,YAAY,MAAM;AAC3C,QAAI,YAAY,SAAS;AACvB,oBAAc,YAAY,OAAO;AACjC,kBAAY,UAAU;AAAA,IACxB;AACA,sBAAkB,CAAC;AAAA,EACrB,GAAG,CAAC,CAAC;AAEL,YAAU,MAAM;AACd,QAAI,CAAC,cAAe;AAEpB,UAAM,mBAAmB,YAAY;AACnC,YAAM,WAAW,MAAM;AAAA,QACrB,oDAAoD,aAAa;AAAA,MACnE;AACA,UAAI,CAAC,SAAS,GAAI;AAElB,YAAM,OAAO,SAAS;AACtB,YAAM,QAAS,MAAM,SAAS,MAAM,QAAQ,CAAC;AAE7C,YAAM,cAAc,MAAM;AAAA,QACxB,CAAC,UACC,MAAM,cAAc,CAAC,MAAM;AAAA,MAC/B;AAEA,UAAI,aAAa;AACf,yBAAiB,OAAO,YAAY,MAAM,EAAE,CAAC;AAC7C,2BAAmB,OAAO,YAAY,mBAAmB,EAAE,CAAC;AAC5D,uBAAe,OAAO,YAAY,SAAS,EAAE,CAAC;AAC9C,4BAAoB,OAAO,YAAY,cAAc,EAAE,CAAC;AAAA,MAC1D;AAAA,IACF;AAEA,qBAAiB;AAAA,EACnB,GAAG,CAAC,eAAe,mBAAmB,CAAC;AAEvC,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,YAAY,QAAS,eAAc,YAAY,OAAO;AAAA,IAC5D;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,YAAU,MAAM;AACd,aAAS,mBAAmB,OAAmB;AAC7C,UACE,YAAY,WACZ,CAAC,YAAY,QAAQ,SAAS,MAAM,MAAc,GAClD;AACA,+BAAuB,KAAK;AAC5B,yBAAiB,EAAE;AAAA,MACrB;AAAA,IACF;AAEA,QAAI,qBAAqB;AACvB,eAAS,iBAAiB,aAAa,kBAAkB;AAAA,IAC3D;AACA,WAAO,MAAM,SAAS,oBAAoB,aAAa,kBAAkB;AAAA,EAC3E,GAAG,CAAC,mBAAmB,CAAC;AAExB,QAAM,cAAc,YAAY;AAC9B,QAAI,CAAC,qBAAqB,CAAC,cAAe;AAE1C,kBAAc,IAAI;AAClB,QAAI;AACF,YAAM,QAAQ,SAAS;AACvB,YAAM,eAAe;AAAA,QACnB;AAAA,QACA,eAAe;AAAA,QACf,MAAM;AAAA,QACN,OAAO,eAAe;AAAA,MACxB;AACA,YAAM,EAAE,IAAI,QAAQ,IAAI,MAAM,YAAY;AAAA,QACxC,WAAW,MAAM,gBAAgB,YAAY;AAAA,QAC7C,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,cAAc;AAAA,UACd,YAAY;AAAA,UACZ;AAAA,UACA,QAAQ;AAAA,UACR;AAAA,QACF;AAAA,QACA,iBAAiB;AAAA,MACnB,CAAC;AAED,uBAAiB,OAAO;AACxB,yBAAmB,iBAAiB;AACpC,2BAAoB,oBAAI,KAAK,GAAE,YAAY,CAAC;AAAA,IAC9C,SAAS,KAAK;AACZ;AAAA,QACE,wBAAwB,KAAK,EAAE,wCAAwC,uBAAuB,CAAC;AAAA,QAC/F;AAAA,MACF;AAAA,IACF,UAAE;AACA,oBAAc,KAAK;AAAA,IACrB;AAAA,EACF;AAEA,QAAM,aAAa,YAAY;AAC7B,QAAI,CAAC,cAAe;AAEpB,kBAAc,IAAI;AAClB,QAAI;AACF,YAAM,cAAc;AAAA,QAClB,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR;AAAA,MACF;AACA,YAAM,YAAY;AAAA,QAChB,WAAW,MACT;AAAA,UACE,sCAAsC,aAAa;AAAA,UACnD,EAAE,QAAQ,OAAO;AAAA,QACnB;AAAA,QACF,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,cAAc;AAAA,UACd,YAAY;AAAA,UACZ;AAAA,UACA,QAAQ;AAAA,UACR;AAAA,QACF;AAAA,QACA,iBAAiB;AAAA,MACnB,CAAC;AAED,uBAAiB,IAAI;AACrB,yBAAmB,IAAI;AACvB,qBAAe,EAAE;AACjB,yBAAmB;AACnB,qBAAe;AAAA,IACjB,SAAS,KAAK;AACZ;AAAA,QACE,wBAAwB,KAAK,EAAE,uCAAuC,sBAAsB,CAAC;AAAA,QAC7F;AAAA,MACF;AAAA,IACF,UAAE;AACA,oBAAc,KAAK;AAAA,IACrB;AAAA,EACF;AAEA,SACE,qBAAC,SAAI,WAAU,8DACb;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAO;AAAA,QACP,UAAU,CAAC,MAAM,eAAe,EAAE,OAAO,KAAK;AAAA,QAC9C,UAAU;AAAA,QACV,aAAa;AAAA,UACX;AAAA,UACA;AAAA,QACF;AAAA,QACA,WAAU;AAAA;AAAA,IACZ;AAAA,IAEA,oBAAC,SAAI,WAAU,YAAW,KAAK,aAC5B,sBACC,gBACE,qBAAC,UAAK,WAAU,0FACd;AAAA,0BAAC,mBAAgB,UAAU,cAAc,OAAO,aAAa,cAAc,MAAM,MAAK,MAAK;AAAA,MAC1F,cAAc;AAAA,OACjB,IACE,OAEJ,iCACE;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAQ;AAAA,UACR,MAAK;AAAA,UACL,SAAS,MAAM;AACb,mCAAuB,CAAC,mBAAmB;AAC3C,6BAAiB,EAAE;AAAA,UACrB;AAAA,UACA,WAAU;AAAA,UAET,4BACI,iCAAE;AAAA,gCAAC,mBAAgB,UAAU,gBAAgB,OAAO,aAAa,gBAAgB,MAAM,MAAK,MAAK;AAAA,YAAE,oBAAC,UAAK,WAAU,QAAQ,0BAAgB,MAAK;AAAA,aAAO,IACxJ,EAAE,2CAA2C,SAAS;AAAA;AAAA,MAC5D;AAAA,MAEC,uBACC,qBAAC,SAAI,WAAU,gGACb;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,OAAO;AAAA,YACP,UAAU,CAAC,MAAM,iBAAiB,EAAE,OAAO,KAAK;AAAA,YAChD,aAAa;AAAA,cACX;AAAA,cACA;AAAA,YACF;AAAA,YACA,WAAU;AAAA,YACV,WAAS;AAAA;AAAA,QACX;AAAA,QACA,oBAAC,SAAI,WAAU,iCACZ,2BAAiB,WAAW,IAC3B,oBAAC,SAAI,WAAU,6CACZ;AAAA,UACC;AAAA,UACA;AAAA,QACF,GACF,IAEA,iBAAiB,IAAI,CAAC,YACpB;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,WAAU;AAAA,YACV,SAAS,MAAM;AACb,mCAAqB,QAAQ,EAAE;AAC/B,qCAAuB,KAAK;AAC5B,+BAAiB,EAAE;AAAA,YACrB;AAAA,YAEA;AAAA,kCAAC,mBAAgB,UAAU,QAAQ,OAAO,aAAa,QAAQ,MAAM,MAAK,MAAK;AAAA,cAC/E,oBAAC,UAAK,WAAU,QAAQ,kBAAQ,MAAK;AAAA,cACpC,QAAQ,OACP,qBAAC,UAAK,WAAU,8BAA6B;AAAA;AAAA,gBACzC,QAAQ;AAAA,gBAAK;AAAA,iBACjB,IACE;AAAA;AAAA;AAAA,UAjBC,QAAQ;AAAA,QAkBf,CACD,GAEL;AAAA,SACF;AAAA,OAEJ,GAEJ;AAAA,IAEA,oBAAC,UAAK,WAAU,0DACb,wBAAc,cAAc,GAC/B;AAAA,IAEC,YACC;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS;AAAA,QACT,UAAU;AAAA,QACV,cAAY,EAAE,kCAAkC,YAAY;AAAA,QAE5D,8BAAC,UAAO,WAAU,UAAS;AAAA;AAAA,IAC7B,IAEA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS;AAAA,QACT,UAAU,cAAc,CAAC;AAAA,QACzB,cAAY,EAAE,mCAAmC,aAAa;AAAA,QAE9D,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,IAC3B;AAAA,KAEJ;AAEJ;",
6
6
  "names": []
7
7
  }
@@ -61,7 +61,6 @@ const TimeReportingWidget = ({
61
61
  const projectIds = assignmentItems.map((item) => String(item.time_project_id ?? item.timeProjectId ?? "")).filter((id) => id.length > 0);
62
62
  const memberId = selfRes.member?.id ?? null;
63
63
  setStaffMemberId(memberId);
64
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
65
64
  const [projectsRes, entriesRes] = await Promise.all([
66
65
  projectIds.length > 0 ? readApiResultOrThrow(
67
66
  `/api/staff/timesheets/time-projects?ids=${projectIds.join(",")}&pageSize=100`,
@@ -69,7 +68,7 @@ const TimeReportingWidget = ({
69
68
  { errorMessage: "", fallback: { items: [] } }
70
69
  ) : Promise.resolve({ items: [] }),
71
70
  memberId ? readApiResultOrThrow(
72
- `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&from=${today}&to=${today}&pageSize=100`,
71
+ `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=100`,
73
72
  void 0,
74
73
  { errorMessage: "", fallback: { items: [] } }
75
74
  ) : Promise.resolve({ items: [] })
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'\nimport { resolveTimerActionError } from '../../../lib/timesheets-ui/timerErrors'\nimport { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'\n\ntype ProjectOption = { id: string; name: string; code: string | null }\n\ntype TimerState = {\n entryId: string | null\n running: boolean\n startedAt: string | null\n projectId: string | null\n}\n\nconst TIMER_WIDGET_MUTATION_CONTEXT_ID = 'staff-timesheets-time-reporting-widget'\n\ntype TimerWidgetMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n staffMemberId: string\n action: 'timer-create' | 'timer-start' | 'timer-stop'\n retryLastMutation: () => Promise<boolean>\n}\n\nfunction formatElapsed(startedAt: string): string {\n const elapsed = Math.max(0, Date.now() - new Date(startedAt).getTime())\n const totalSeconds = Math.floor(elapsed / 1000)\n const hours = Math.floor(totalSeconds / 3600)\n const minutes = Math.floor((totalSeconds % 3600) / 60)\n const seconds = totalSeconds % 60\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`\n}\n\nconst TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingSettings>> = ({\n mode,\n settings = DEFAULT_SETTINGS,\n onSettingsChange,\n refreshToken,\n onRefreshStateChange,\n}) => {\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<TimerWidgetMutationContext>({\n contextId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])\n\n const [projects, setProjects] = React.useState<ProjectOption[]>([])\n const [selectedProjectId, setSelectedProjectId] = React.useState<string | null>(hydrated.lastProjectId)\n const [notes, setNotes] = React.useState('')\n const [timer, setTimer] = React.useState<TimerState>({ entryId: null, running: false, startedAt: null, projectId: null })\n const [elapsed, setElapsed] = React.useState('00:00:00')\n const [staffMemberId, setStaffMemberId] = React.useState<string | null>(null)\n const [loading, setLoading] = React.useState(true)\n const [actionLoading, setActionLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n\n const loadState = React.useCallback(async () => {\n onRefreshStateChange?.(true)\n setLoading(true)\n setError(null)\n try {\n // Assignments and the current user's staff member are independent \u2014 fetch them together.\n const [assignmentsRes, selfRes] = await Promise.all([\n readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n '/api/staff/timesheets/my-projects?pageSize=100',\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n ),\n readApiResultOrThrow<{ member?: { id: string } | null }>(\n '/api/staff/team-members/self',\n undefined,\n { errorMessage: '', fallback: { member: null } },\n ),\n ])\n\n const assignmentItems = Array.isArray(assignmentsRes.items) ? assignmentsRes.items : []\n const projectIds = assignmentItems\n .map((item) => String(item.time_project_id ?? item.timeProjectId ?? ''))\n .filter((id) => id.length > 0)\n\n const memberId = selfRes.member?.id ?? null\n setStaffMemberId(memberId)\n\n const today = new Date().toISOString().slice(0, 10)\n\n // Project details depend on the assignment ids and active entries depend on the staff\n // member id, but the two requests are independent of each other \u2014 fetch them together.\n const [projectsRes, entriesRes] = await Promise.all([\n projectIds.length > 0\n ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-projects?ids=${projectIds.join(',')}&pageSize=100`,\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),\n memberId\n ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&from=${today}&to=${today}&pageSize=100`,\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),\n ])\n\n const projectItems = Array.isArray(projectsRes.items) ? projectsRes.items : []\n setProjects(projectItems.map((item) => ({\n id: String(item.id ?? ''),\n name: String(item.name ?? ''),\n code: typeof item.code === 'string' ? item.code : null,\n })))\n\n if (memberId) {\n const entries = Array.isArray(entriesRes.items) ? entriesRes.items : []\n const running = entries.find((entry) => {\n const startedAt = entry.started_at ?? entry.startedAt\n const endedAt = entry.ended_at ?? entry.endedAt\n return startedAt != null && endedAt == null\n })\n if (running) {\n setTimer({\n entryId: String(running.id ?? ''),\n running: true,\n startedAt: String(running.started_at ?? running.startedAt ?? ''),\n projectId: String(running.time_project_id ?? running.timeProjectId ?? ''),\n })\n } else {\n setTimer({ entryId: null, running: false, startedAt: null, projectId: null })\n }\n }\n } catch (err) {\n console.error('staff.timesheets.timeReporting.load', err)\n setError(t('staff.timesheets.widgets.timeReporting.error', 'Failed to load timer state'))\n } finally {\n setLoading(false)\n onRefreshStateChange?.(false)\n }\n }, [onRefreshStateChange, t])\n\n React.useEffect(() => {\n void loadState()\n }, [loadState, refreshToken])\n\n // Tick elapsed time\n React.useEffect(() => {\n if (!timer.running || !timer.startedAt) return\n const tick = () => setElapsed(formatElapsed(timer.startedAt!))\n tick()\n const interval = setInterval(tick, 1000)\n return () => clearInterval(interval)\n }, [timer.running, timer.startedAt])\n\n const handleStart = React.useCallback(async () => {\n if (!selectedProjectId || !staffMemberId) return\n if (timer.running) return\n setActionLoading(true)\n try {\n const today = new Date().toISOString().slice(0, 10)\n // Single atomic create+start request (issue #3311): a partial failure can\n // no longer leave an orphaned, unstarted timer entry, and a rejected start\n // now surfaces here instead of being silently ignored. The call is routed\n // through the mutation guard (issue #3308) so global injection modules can\n // run onBeforeSave/onAfterSave and surface conflicts consistently.\n const startPayload = {\n staffMemberId,\n timeProjectId: selectedProjectId,\n date: today,\n notes: notes.trim() || null,\n }\n await runMutation({\n operation: () => startTimerEntry(startPayload),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: staffMemberId,\n staffMemberId,\n action: 'timer-start',\n retryLastMutation,\n },\n mutationPayload: startPayload,\n })\n\n onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })\n await loadState()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.start', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer')))\n } finally {\n setActionLoading(false)\n }\n }, [selectedProjectId, staffMemberId, timer.running, notes, hydrated, onSettingsChange, loadState, runMutation, retryLastMutation, t])\n\n const handleStop = React.useCallback(async () => {\n if (!timer.entryId || !staffMemberId) return\n setActionLoading(true)\n try {\n const stopPayload = {\n id: timer.entryId,\n action: 'timer-stop',\n staffMemberId,\n }\n await runMutation({\n operation: () =>\n apiCall(`/api/staff/timesheets/time-entries/${timer.entryId}/timer-stop`, { method: 'POST' }),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: timer.entryId,\n staffMemberId,\n action: 'timer-stop',\n retryLastMutation,\n },\n mutationPayload: stopPayload,\n })\n await loadState()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.stop', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer')))\n } finally {\n setActionLoading(false)\n }\n }, [timer.entryId, staffMemberId, loadState, runMutation, retryLastMutation, t])\n\n if (mode === 'settings') {\n return (\n <div className=\"space-y-2 text-sm\">\n <p className=\"text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.settings.description', 'No additional settings. Select a project and start tracking from the widget.')}\n </p>\n </div>\n )\n }\n\n if (loading) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">{t('staff.timesheets.widgets.timeReporting.loading', 'Loading...')}</p>\n </div>\n )\n }\n\n if (error) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p role=\"alert\" className=\"text-sm text-destructive\">{error}</p>\n </div>\n )\n }\n\n if (projects.length === 0) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.noProjects', 'No projects assigned.')}\n </p>\n </div>\n )\n }\n\n // Timer is running\n if (timer.running) {\n const runningProject = projects.find((p) => p.id === timer.projectId)\n return (\n <div className=\"space-y-3\">\n <div className=\"text-sm text-muted-foreground\">\n {runningProject?.name ?? t('staff.timesheets.widgets.timeReporting.unknownProject', 'Unknown project')}\n </div>\n <div className=\"text-center\">\n <p className=\"font-mono text-3xl font-bold tabular-nums\">{elapsed}</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.running', 'Timer running')}\n </p>\n </div>\n <Button\n type=\"button\"\n variant=\"destructive\"\n className=\"w-full\"\n onClick={handleStop}\n disabled={actionLoading}\n >\n {t('staff.timesheets.widgets.timeReporting.stop', 'Stop Timer')}\n </Button>\n </div>\n )\n }\n\n // Timer not running \u2014 show start form\n return (\n <div className=\"space-y-3\">\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-project\">\n {t('staff.timesheets.widgets.timeReporting.project', 'Project')}\n </label>\n <select\n id=\"timer-project\"\n className=\"w-full rounded-md border bg-background px-3 py-2 text-sm\"\n value={selectedProjectId ?? ''}\n onChange={(e) => setSelectedProjectId(e.target.value || null)}\n >\n <option value=\"\">{t('staff.timesheets.widgets.timeReporting.selectProject', 'Select project...')}</option>\n {projects.map((project) => (\n <option key={project.id} value={project.id}>\n {project.name}{project.code ? ` (${project.code})` : ''}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-notes\">\n {t('staff.timesheets.widgets.timeReporting.taskNote', 'Task / Note')}\n </label>\n <Input\n id=\"timer-notes\"\n value={notes}\n onChange={(e) => setNotes(e.target.value)}\n placeholder={t('staff.timesheets.widgets.timeReporting.notesPlaceholder', 'What are you working on?')}\n />\n </div>\n <div className=\"text-center\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.notRunning', 'Not running')}\n </p>\n </div>\n <Button\n type=\"button\"\n className=\"w-full\"\n onClick={handleStart}\n disabled={actionLoading || !selectedProjectId}\n >\n {t('staff.timesheets.widgets.timeReporting.start', 'Start Timer')}\n </Button>\n </div>\n )\n}\n\nexport default TimeReportingWidget\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'\nimport { resolveTimerActionError } from '../../../lib/timesheets-ui/timerErrors'\nimport { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'\n\ntype ProjectOption = { id: string; name: string; code: string | null }\n\ntype TimerState = {\n entryId: string | null\n running: boolean\n startedAt: string | null\n projectId: string | null\n}\n\nconst TIMER_WIDGET_MUTATION_CONTEXT_ID = 'staff-timesheets-time-reporting-widget'\n\ntype TimerWidgetMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n staffMemberId: string\n action: 'timer-create' | 'timer-start' | 'timer-stop'\n retryLastMutation: () => Promise<boolean>\n}\n\nfunction formatElapsed(startedAt: string): string {\n const elapsed = Math.max(0, Date.now() - new Date(startedAt).getTime())\n const totalSeconds = Math.floor(elapsed / 1000)\n const hours = Math.floor(totalSeconds / 3600)\n const minutes = Math.floor((totalSeconds % 3600) / 60)\n const seconds = totalSeconds % 60\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`\n}\n\nconst TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingSettings>> = ({\n mode,\n settings = DEFAULT_SETTINGS,\n onSettingsChange,\n refreshToken,\n onRefreshStateChange,\n}) => {\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<TimerWidgetMutationContext>({\n contextId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])\n\n const [projects, setProjects] = React.useState<ProjectOption[]>([])\n const [selectedProjectId, setSelectedProjectId] = React.useState<string | null>(hydrated.lastProjectId)\n const [notes, setNotes] = React.useState('')\n const [timer, setTimer] = React.useState<TimerState>({ entryId: null, running: false, startedAt: null, projectId: null })\n const [elapsed, setElapsed] = React.useState('00:00:00')\n const [staffMemberId, setStaffMemberId] = React.useState<string | null>(null)\n const [loading, setLoading] = React.useState(true)\n const [actionLoading, setActionLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n\n const loadState = React.useCallback(async () => {\n onRefreshStateChange?.(true)\n setLoading(true)\n setError(null)\n try {\n // Assignments and the current user's staff member are independent \u2014 fetch them together.\n const [assignmentsRes, selfRes] = await Promise.all([\n readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n '/api/staff/timesheets/my-projects?pageSize=100',\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n ),\n readApiResultOrThrow<{ member?: { id: string } | null }>(\n '/api/staff/team-members/self',\n undefined,\n { errorMessage: '', fallback: { member: null } },\n ),\n ])\n\n const assignmentItems = Array.isArray(assignmentsRes.items) ? assignmentsRes.items : []\n const projectIds = assignmentItems\n .map((item) => String(item.time_project_id ?? item.timeProjectId ?? ''))\n .filter((id) => id.length > 0)\n\n const memberId = selfRes.member?.id ?? null\n setStaffMemberId(memberId)\n\n // Project details depend on the assignment ids and the active timer depends on the staff\n // member id, but the two requests are independent of each other \u2014 fetch them together.\n const [projectsRes, entriesRes] = await Promise.all([\n projectIds.length > 0\n ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-projects?ids=${projectIds.join(',')}&pageSize=100`,\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),\n memberId\n ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=100`,\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),\n ])\n\n const projectItems = Array.isArray(projectsRes.items) ? projectsRes.items : []\n setProjects(projectItems.map((item) => ({\n id: String(item.id ?? ''),\n name: String(item.name ?? ''),\n code: typeof item.code === 'string' ? item.code : null,\n })))\n\n if (memberId) {\n const entries = Array.isArray(entriesRes.items) ? entriesRes.items : []\n const running = entries.find((entry) => {\n const startedAt = entry.started_at ?? entry.startedAt\n const endedAt = entry.ended_at ?? entry.endedAt\n return startedAt != null && endedAt == null\n })\n if (running) {\n setTimer({\n entryId: String(running.id ?? ''),\n running: true,\n startedAt: String(running.started_at ?? running.startedAt ?? ''),\n projectId: String(running.time_project_id ?? running.timeProjectId ?? ''),\n })\n } else {\n setTimer({ entryId: null, running: false, startedAt: null, projectId: null })\n }\n }\n } catch (err) {\n console.error('staff.timesheets.timeReporting.load', err)\n setError(t('staff.timesheets.widgets.timeReporting.error', 'Failed to load timer state'))\n } finally {\n setLoading(false)\n onRefreshStateChange?.(false)\n }\n }, [onRefreshStateChange, t])\n\n React.useEffect(() => {\n void loadState()\n }, [loadState, refreshToken])\n\n // Tick elapsed time\n React.useEffect(() => {\n if (!timer.running || !timer.startedAt) return\n const tick = () => setElapsed(formatElapsed(timer.startedAt!))\n tick()\n const interval = setInterval(tick, 1000)\n return () => clearInterval(interval)\n }, [timer.running, timer.startedAt])\n\n const handleStart = React.useCallback(async () => {\n if (!selectedProjectId || !staffMemberId) return\n if (timer.running) return\n setActionLoading(true)\n try {\n const today = new Date().toISOString().slice(0, 10)\n // Single atomic create+start request (issue #3311): a partial failure can\n // no longer leave an orphaned, unstarted timer entry, and a rejected start\n // now surfaces here instead of being silently ignored. The call is routed\n // through the mutation guard (issue #3308) so global injection modules can\n // run onBeforeSave/onAfterSave and surface conflicts consistently.\n const startPayload = {\n staffMemberId,\n timeProjectId: selectedProjectId,\n date: today,\n notes: notes.trim() || null,\n }\n await runMutation({\n operation: () => startTimerEntry(startPayload),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: staffMemberId,\n staffMemberId,\n action: 'timer-start',\n retryLastMutation,\n },\n mutationPayload: startPayload,\n })\n\n onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })\n await loadState()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.start', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer')))\n } finally {\n setActionLoading(false)\n }\n }, [selectedProjectId, staffMemberId, timer.running, notes, hydrated, onSettingsChange, loadState, runMutation, retryLastMutation, t])\n\n const handleStop = React.useCallback(async () => {\n if (!timer.entryId || !staffMemberId) return\n setActionLoading(true)\n try {\n const stopPayload = {\n id: timer.entryId,\n action: 'timer-stop',\n staffMemberId,\n }\n await runMutation({\n operation: () =>\n apiCall(`/api/staff/timesheets/time-entries/${timer.entryId}/timer-stop`, { method: 'POST' }),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: timer.entryId,\n staffMemberId,\n action: 'timer-stop',\n retryLastMutation,\n },\n mutationPayload: stopPayload,\n })\n await loadState()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.stop', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer')))\n } finally {\n setActionLoading(false)\n }\n }, [timer.entryId, staffMemberId, loadState, runMutation, retryLastMutation, t])\n\n if (mode === 'settings') {\n return (\n <div className=\"space-y-2 text-sm\">\n <p className=\"text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.settings.description', 'No additional settings. Select a project and start tracking from the widget.')}\n </p>\n </div>\n )\n }\n\n if (loading) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">{t('staff.timesheets.widgets.timeReporting.loading', 'Loading...')}</p>\n </div>\n )\n }\n\n if (error) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p role=\"alert\" className=\"text-sm text-destructive\">{error}</p>\n </div>\n )\n }\n\n if (projects.length === 0) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.noProjects', 'No projects assigned.')}\n </p>\n </div>\n )\n }\n\n // Timer is running\n if (timer.running) {\n const runningProject = projects.find((p) => p.id === timer.projectId)\n return (\n <div className=\"space-y-3\">\n <div className=\"text-sm text-muted-foreground\">\n {runningProject?.name ?? t('staff.timesheets.widgets.timeReporting.unknownProject', 'Unknown project')}\n </div>\n <div className=\"text-center\">\n <p className=\"font-mono text-3xl font-bold tabular-nums\">{elapsed}</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.running', 'Timer running')}\n </p>\n </div>\n <Button\n type=\"button\"\n variant=\"destructive\"\n className=\"w-full\"\n onClick={handleStop}\n disabled={actionLoading}\n >\n {t('staff.timesheets.widgets.timeReporting.stop', 'Stop Timer')}\n </Button>\n </div>\n )\n }\n\n // Timer not running \u2014 show start form\n return (\n <div className=\"space-y-3\">\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-project\">\n {t('staff.timesheets.widgets.timeReporting.project', 'Project')}\n </label>\n <select\n id=\"timer-project\"\n className=\"w-full rounded-md border bg-background px-3 py-2 text-sm\"\n value={selectedProjectId ?? ''}\n onChange={(e) => setSelectedProjectId(e.target.value || null)}\n >\n <option value=\"\">{t('staff.timesheets.widgets.timeReporting.selectProject', 'Select project...')}</option>\n {projects.map((project) => (\n <option key={project.id} value={project.id}>\n {project.name}{project.code ? ` (${project.code})` : ''}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-notes\">\n {t('staff.timesheets.widgets.timeReporting.taskNote', 'Task / Note')}\n </label>\n <Input\n id=\"timer-notes\"\n value={notes}\n onChange={(e) => setNotes(e.target.value)}\n placeholder={t('staff.timesheets.widgets.timeReporting.notesPlaceholder', 'What are you working on?')}\n />\n </div>\n <div className=\"text-center\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.notRunning', 'Not running')}\n </p>\n </div>\n <Button\n type=\"button\"\n className=\"w-full\"\n onClick={handleStart}\n disabled={actionLoading || !selectedProjectId}\n >\n {t('staff.timesheets.widgets.timeReporting.start', 'Start Timer')}\n </Button>\n </div>\n )\n}\n\nexport default TimeReportingWidget\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -28,10 +28,6 @@ function formatElapsed(seconds) {
28
28
  const s = seconds % 60;
29
29
  return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
30
30
  }
31
- function getToday() {
32
- const now = /* @__PURE__ */ new Date();
33
- return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
34
- }
35
31
  function TimerSidebarIndicator() {
36
32
  const t = useT();
37
33
  const [timer, setTimer] = React.useState(loadFromSession);
@@ -49,9 +45,8 @@ function TimerSidebarIndicator() {
49
45
  updateTimer(null);
50
46
  return;
51
47
  }
52
- const today = getToday();
53
48
  const res = await apiCall(
54
- `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&from=${today}&to=${today}&pageSize=50`
49
+ `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=50`
55
50
  );
56
51
  const items = res.result?.items ?? [];
57
52
  const active = items.find((e) => e.started_at && !e.ended_at);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/staff/widgets/injection/timer-sidebar-indicator/widget.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport { ProjectColorDot } from '../../../lib/timesheets-ui/ProjectColorDot'\n\ntype TimerState = {\n entryId: string\n projectName: string\n projectColor: string | null\n startedAt: string\n}\n\nconst STORAGE_KEY = 'om:timesheets:activeTimer'\n\nfunction saveToSession(state: TimerState | null) {\n try {\n if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n else sessionStorage.removeItem(STORAGE_KEY)\n } catch { /* private browsing */ }\n}\n\nfunction loadFromSession(): TimerState | null {\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY)\n if (!raw) return null\n const parsed = JSON.parse(raw)\n if (parsed?.startedAt && parsed?.entryId) return parsed as TimerState\n } catch { /* corrupt or private browsing */ }\n return null\n}\n\nfunction formatElapsed(seconds: number): string {\n const h = Math.floor(seconds / 3600)\n const m = Math.floor((seconds % 3600) / 60)\n const s = seconds % 60\n return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`\n}\n\nfunction getToday(): string {\n const now = new Date()\n return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`\n}\n\nfunction TimerSidebarIndicator() {\n const t = useT()\n // Initialise from sessionStorage so the indicator doesn't flash away on navigation\n const [timer, setTimer] = React.useState<TimerState | null>(loadFromSession)\n const [elapsed, setElapsed] = React.useState(0)\n const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null)\n\n // Wrap setTimer to also persist to sessionStorage\n const updateTimer = React.useCallback((next: TimerState | null) => {\n setTimer(next)\n saveToSession(next)\n }, [])\n\n const pollTimer = React.useCallback(async () => {\n try {\n const selfRes = await apiCall<{ member?: { id: string } | null }>('/api/staff/team-members/self')\n const memberId = selfRes.result?.member?.id\n if (!memberId) { updateTimer(null); return }\n\n const today = getToday()\n const res = await apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&from=${today}&to=${today}&pageSize=50`,\n )\n const items = (res.result?.items ?? []) as Array<Record<string, unknown>>\n const active = items.find((e) => e.started_at && !e.ended_at)\n\n if (!active) {\n updateTimer(null)\n return\n }\n\n const projectId = String(active.time_project_id ?? '')\n let projectName = ''\n let projectColor: string | null = null\n if (projectId) {\n const projRes = await apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,\n )\n const proj = (projRes.result?.items ?? [])[0]\n if (proj) {\n projectName = String(proj.name ?? '')\n projectColor = typeof proj.color === 'string' ? proj.color : null\n }\n }\n\n updateTimer({\n entryId: String(active.id ?? ''),\n projectName,\n projectColor,\n startedAt: String(active.started_at ?? ''),\n })\n } catch {\n // Silent \u2014 don't crash the sidebar\n }\n }, [updateTimer])\n\n // Poll for active timer on mount + every 30s\n React.useEffect(() => {\n void pollTimer()\n const poll = setInterval(() => { void pollTimer() }, 30000)\n return () => clearInterval(poll)\n }, [pollTimer])\n\n // Tick the elapsed counter locally every second\n React.useEffect(() => {\n if (!timer) {\n if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }\n setElapsed(0)\n return\n }\n\n const startTime = new Date(timer.startedAt).getTime()\n const calcElapsed = () => Math.max(0, Math.floor((Date.now() - startTime) / 1000))\n setElapsed(calcElapsed())\n\n intervalRef.current = setInterval(() => setElapsed(calcElapsed()), 1000)\n return () => {\n if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }\n }\n }, [timer])\n\n if (!timer) return null\n\n return (\n <a\n href=\"/backend/staff/timesheets\"\n className=\"flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted transition-colors cursor-pointer\"\n title={t('staff.timesheets.sidebar.timerRunning', 'Timer running \u2014 click to view')}\n >\n <span className=\"relative flex h-2.5 w-2.5 shrink-0\">\n <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75\" />\n <span className=\"relative inline-flex h-2.5 w-2.5 rounded-full bg-red-500\" />\n </span>\n {timer.projectName ? (\n <span className=\"inline-flex items-center gap-1 truncate\">\n <ProjectColorDot colorKey={timer.projectColor} projectName={timer.projectName} size=\"xs\" />\n <span className=\"truncate\">{timer.projectName}</span>\n </span>\n ) : null}\n <span className=\"ml-auto font-mono tabular-nums shrink-0\">{formatElapsed(elapsed)}</span>\n </a>\n )\n}\n\nconst widget: InjectionWidgetModule = {\n metadata: {\n id: 'staff.injection.timer-sidebar-indicator',\n title: 'Active timer indicator',\n description: 'Shows a pulsing indicator in the sidebar when a timesheet timer is running.',\n features: ['staff.timesheets.manage_own'],\n },\n Widget: TimerSidebarIndicator,\n}\n\nexport default widget\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport { ProjectColorDot } from '../../../lib/timesheets-ui/ProjectColorDot'\n\ntype TimerState = {\n entryId: string\n projectName: string\n projectColor: string | null\n startedAt: string\n}\n\nconst STORAGE_KEY = 'om:timesheets:activeTimer'\n\nfunction saveToSession(state: TimerState | null) {\n try {\n if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n else sessionStorage.removeItem(STORAGE_KEY)\n } catch { /* private browsing */ }\n}\n\nfunction loadFromSession(): TimerState | null {\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY)\n if (!raw) return null\n const parsed = JSON.parse(raw)\n if (parsed?.startedAt && parsed?.entryId) return parsed as TimerState\n } catch { /* corrupt or private browsing */ }\n return null\n}\n\nfunction formatElapsed(seconds: number): string {\n const h = Math.floor(seconds / 3600)\n const m = Math.floor((seconds % 3600) / 60)\n const s = seconds % 60\n return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`\n}\n\nfunction TimerSidebarIndicator() {\n const t = useT()\n // Initialise from sessionStorage so the indicator doesn't flash away on navigation\n const [timer, setTimer] = React.useState<TimerState | null>(loadFromSession)\n const [elapsed, setElapsed] = React.useState(0)\n const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null)\n\n // Wrap setTimer to also persist to sessionStorage\n const updateTimer = React.useCallback((next: TimerState | null) => {\n setTimer(next)\n saveToSession(next)\n }, [])\n\n const pollTimer = React.useCallback(async () => {\n try {\n const selfRes = await apiCall<{ member?: { id: string } | null }>('/api/staff/team-members/self')\n const memberId = selfRes.result?.member?.id\n if (!memberId) { updateTimer(null); return }\n\n const res = await apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=50`,\n )\n const items = (res.result?.items ?? []) as Array<Record<string, unknown>>\n const active = items.find((e) => e.started_at && !e.ended_at)\n\n if (!active) {\n updateTimer(null)\n return\n }\n\n const projectId = String(active.time_project_id ?? '')\n let projectName = ''\n let projectColor: string | null = null\n if (projectId) {\n const projRes = await apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,\n )\n const proj = (projRes.result?.items ?? [])[0]\n if (proj) {\n projectName = String(proj.name ?? '')\n projectColor = typeof proj.color === 'string' ? proj.color : null\n }\n }\n\n updateTimer({\n entryId: String(active.id ?? ''),\n projectName,\n projectColor,\n startedAt: String(active.started_at ?? ''),\n })\n } catch {\n // Silent \u2014 don't crash the sidebar\n }\n }, [updateTimer])\n\n // Poll for active timer on mount + every 30s\n React.useEffect(() => {\n void pollTimer()\n const poll = setInterval(() => { void pollTimer() }, 30000)\n return () => clearInterval(poll)\n }, [pollTimer])\n\n // Tick the elapsed counter locally every second\n React.useEffect(() => {\n if (!timer) {\n if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }\n setElapsed(0)\n return\n }\n\n const startTime = new Date(timer.startedAt).getTime()\n const calcElapsed = () => Math.max(0, Math.floor((Date.now() - startTime) / 1000))\n setElapsed(calcElapsed())\n\n intervalRef.current = setInterval(() => setElapsed(calcElapsed()), 1000)\n return () => {\n if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }\n }\n }, [timer])\n\n if (!timer) return null\n\n return (\n <a\n href=\"/backend/staff/timesheets\"\n className=\"flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted transition-colors cursor-pointer\"\n title={t('staff.timesheets.sidebar.timerRunning', 'Timer running \u2014 click to view')}\n >\n <span className=\"relative flex h-2.5 w-2.5 shrink-0\">\n <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75\" />\n <span className=\"relative inline-flex h-2.5 w-2.5 rounded-full bg-red-500\" />\n </span>\n {timer.projectName ? (\n <span className=\"inline-flex items-center gap-1 truncate\">\n <ProjectColorDot colorKey={timer.projectColor} projectName={timer.projectName} size=\"xs\" />\n <span className=\"truncate\">{timer.projectName}</span>\n </span>\n ) : null}\n <span className=\"ml-auto font-mono tabular-nums shrink-0\">{formatElapsed(elapsed)}</span>\n </a>\n )\n}\n\nconst widget: InjectionWidgetModule = {\n metadata: {\n id: 'staff.injection.timer-sidebar-indicator',\n title: 'Active timer indicator',\n description: 'Shows a pulsing indicator in the sidebar when a timesheet timer is running.',\n features: ['staff.timesheets.manage_own'],\n },\n Widget: TimerSidebarIndicator,\n}\n\nexport default widget\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/jest.config.cjs CHANGED
@@ -40,7 +40,7 @@ module.exports = {
40
40
  },
41
41
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
42
42
  transformIgnorePatterns: [
43
- 'node_modules/(?!(@mikro-orm|kysely)/)',
43
+ 'node_modules/(?!(@mikro-orm|kysely|ai|@ai-sdk|ai-sdk-ollama|@workflow|@standard-schema)/)',
44
44
  ],
45
45
  testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
46
46
  passWithNoTests: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.6419.1.a632f417c4",
3
+ "version": "0.6.6-develop.6423.1.f039f73ff4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -228,41 +228,41 @@
228
228
  }
229
229
  },
230
230
  "dependencies": {
231
- "@mikro-orm/core": "^7.1.4",
232
- "@mikro-orm/decorators": "^7.1.4",
233
- "@mikro-orm/postgresql": "^7.1.4",
231
+ "@mikro-orm/core": "^7.1.5",
232
+ "@mikro-orm/decorators": "^7.1.5",
233
+ "@mikro-orm/postgresql": "^7.1.5",
234
234
  "@types/html-to-text": "^9.0.4",
235
235
  "@types/leaflet": "^1.9.12",
236
236
  "@types/leaflet.markercluster": "^1.5.5",
237
237
  "@types/sanitize-html": "^2.13.0",
238
238
  "@types/semver": "^7.5.8",
239
- "@xyflow/react": "^12.11.0",
240
- "ai": "^6.0.208",
239
+ "@xyflow/react": "^12.11.1",
240
+ "ai": "^7.0.4",
241
241
  "date-fns": "4.4.0",
242
242
  "date-fns-tz": "^3.2.0",
243
243
  "html-to-text": "^10.0.0",
244
244
  "leaflet": "^1.9.4",
245
245
  "leaflet.markercluster": "^1.5.3",
246
246
  "mammoth": "^1.9.0",
247
- "pdfjs-dist": "^6.0.227",
248
- "resend": "^6.14.0",
247
+ "pdfjs-dist": "^6.1.200",
248
+ "resend": "^6.16.0",
249
249
  "sanitize-html": "^2.17.5",
250
250
  "semver": "^7.8.5",
251
- "svix": "^1.96.0",
251
+ "svix": "^1.96.1",
252
252
  "ts-pattern": "^5.0.0",
253
253
  "zod": "^4.4.3"
254
254
  },
255
255
  "peerDependencies": {
256
- "@open-mercato/ai-assistant": "0.6.6-develop.6419.1.a632f417c4",
257
- "@open-mercato/shared": "0.6.6-develop.6419.1.a632f417c4",
258
- "@open-mercato/ui": "0.6.6-develop.6419.1.a632f417c4",
256
+ "@open-mercato/ai-assistant": "0.6.6-develop.6423.1.f039f73ff4",
257
+ "@open-mercato/shared": "0.6.6-develop.6423.1.f039f73ff4",
258
+ "@open-mercato/ui": "0.6.6-develop.6423.1.f039f73ff4",
259
259
  "react": "^19.0.0",
260
260
  "react-dom": "^19.0.0"
261
261
  },
262
262
  "devDependencies": {
263
- "@open-mercato/ai-assistant": "0.6.6-develop.6419.1.a632f417c4",
264
- "@open-mercato/shared": "0.6.6-develop.6419.1.a632f417c4",
265
- "@open-mercato/ui": "0.6.6-develop.6419.1.a632f417c4",
263
+ "@open-mercato/ai-assistant": "0.6.6-develop.6423.1.f039f73ff4",
264
+ "@open-mercato/shared": "0.6.6-develop.6423.1.f039f73ff4",
265
+ "@open-mercato/ui": "0.6.6-develop.6423.1.f039f73ff4",
266
266
  "@testing-library/dom": "^10.4.1",
267
267
  "@testing-library/jest-dom": "^6.9.1",
268
268
  "@testing-library/react": "^16.3.1",
@@ -82,6 +82,12 @@ Use stable, grep-friendly ids:
82
82
  | DataTable progress result handling | `packages/ui/src/backend/DataTable.tsx` |
83
83
  | Progress UI merge/polling | `packages/ui/src/backend/progress/useProgressPoll.ts` and `useProgressSse.ts` |
84
84
 
85
+ ## Environment
86
+
87
+ | Variable | Effect | Default |
88
+ |----------|--------|---------|
89
+ | `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS` | Coalesces intermediate `progress.job.updated` flush+broadcasts per job: the service flushes and emits only when this many ms elapsed since the last broadcast **or** `progressPercent` advanced by ≥1; sub-threshold updates buffer in memory. Keeps bulk workers from firing one serialized `pg_notify` roundtrip + tenant-wide SSE fan-out per record. Terminal events (`created`/`started`/`completed`/`failed`/`cancelled`) are never throttled, so `ProgressTopBar` still converges and the 60s `STALE_JOB_TIMEOUT_SECONDS` heartbeat window is never exceeded. Set to `0` to restore per-record emission (tests/debugging). | `250` |
90
+
85
91
  ## Cross-References
86
92
 
87
93
  - **Queue worker rules**: `packages/queue/AGENTS.md`
@@ -1,10 +1,30 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { ProgressJob } from '../data/entities'
3
- import type { ProgressService } from './progressService'
3
+ import type { ProgressService, ProgressServiceContext } from './progressService'
4
4
  import { calculateEta, calculateProgressPercent, STALE_JOB_TIMEOUT_SECONDS } from './progressService'
5
5
  import { PROGRESS_EVENTS } from './events'
6
6
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
7
 
8
+ const DEFAULT_BROADCAST_MIN_INTERVAL_MS = 250
9
+
10
+ // Minimum elapsed time between coalesced `progress.job.updated` flush+broadcasts for a
11
+ // single job. Bulk workers call updateProgress/incrementProgress once per record, and
12
+ // every emit of the `clientBroadcast: true` event pays a serialized pg_notify roundtrip
13
+ // plus a tenant-wide SSE fan-out. Setting the knob to 0 restores per-record emission.
14
+ function resolveBroadcastMinIntervalMs(): number {
15
+ const raw = process.env.OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS
16
+ if (raw == null || raw.trim() === '') return DEFAULT_BROADCAST_MIN_INTERVAL_MS
17
+ const parsed = Number.parseInt(raw, 10)
18
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_BROADCAST_MIN_INTERVAL_MS
19
+ return parsed
20
+ }
21
+
22
+ type JobUpdateThrottleEntry = {
23
+ job: ProgressJob
24
+ lastBroadcastAt: number
25
+ lastBroadcastPercent: number
26
+ }
27
+
8
28
  function buildJobPayload(job: ProgressJob): Record<string, unknown> {
9
29
  return {
10
30
  jobId: job.id,
@@ -24,6 +44,60 @@ function buildJobPayload(job: ProgressJob): Record<string, unknown> {
24
44
  }
25
45
 
26
46
  export function createProgressService(em: EntityManager, eventBus: { emit: (event: string, payload: Record<string, unknown>) => Promise<void> }): ProgressService {
47
+ const broadcastMinIntervalMs = resolveBroadcastMinIntervalMs()
48
+ // Per-job coalescing state, scoped to this service instance (request/worker scope).
49
+ // The cached managed entity doubles as the in-memory buffer: intermediate updates mutate
50
+ // it without flushing, and a single flush+emit at the throttle boundary persists the
51
+ // accumulated change. Domain commands fork their own EntityManager, so no other operation
52
+ // queries this job on `em` between calls, keeping the buffered changes safe until flush.
53
+ const jobUpdateThrottle = new Map<string, JobUpdateThrottleEntry>()
54
+
55
+ function jobScopeFilter(jobId: string, ctx: ProgressServiceContext) {
56
+ return {
57
+ id: jobId,
58
+ tenantId: ctx.tenantId,
59
+ ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
60
+ }
61
+ }
62
+
63
+ async function loadUpdatableJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob> {
64
+ const cached = jobUpdateThrottle.get(jobId)
65
+ if (cached) return cached.job
66
+ return em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx))
67
+ }
68
+
69
+ async function commitJobUpdate(job: ProgressJob, ctx: ProgressServiceContext): Promise<ProgressJob> {
70
+ const now = Date.now()
71
+ const cached = jobUpdateThrottle.get(job.id)
72
+ const shouldBroadcast =
73
+ broadcastMinIntervalMs <= 0 ||
74
+ cached == null ||
75
+ now - cached.lastBroadcastAt >= broadcastMinIntervalMs ||
76
+ Math.abs(job.progressPercent - cached.lastBroadcastPercent) >= 1
77
+
78
+ if (shouldBroadcast) {
79
+ await em.flush()
80
+ await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
81
+ ...buildJobPayload(job),
82
+ tenantId: ctx.tenantId,
83
+ organizationId: job.organizationId ?? null,
84
+ })
85
+ jobUpdateThrottle.set(job.id, { job, lastBroadcastAt: now, lastBroadcastPercent: job.progressPercent })
86
+ } else {
87
+ jobUpdateThrottle.set(job.id, {
88
+ job,
89
+ lastBroadcastAt: cached.lastBroadcastAt,
90
+ lastBroadcastPercent: cached.lastBroadcastPercent,
91
+ })
92
+ }
93
+
94
+ return job
95
+ }
96
+
97
+ function forgetJobThrottle(jobId: string) {
98
+ jobUpdateThrottle.delete(jobId)
99
+ }
100
+
27
101
  return {
28
102
  async createJob(input, ctx) {
29
103
  const job = em.create(ProgressJob, {
@@ -79,12 +153,9 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
79
153
  },
80
154
 
81
155
  async updateProgress(jobId, input, ctx) {
82
- const job = await em.findOneOrFail(ProgressJob, {
83
- id: jobId,
84
- tenantId: ctx.tenantId,
85
- ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
86
- })
156
+ const job = await loadUpdatableJob(jobId, ctx)
87
157
  if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
158
+ forgetJobThrottle(jobId)
88
159
  return job
89
160
  }
90
161
 
@@ -112,24 +183,13 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
112
183
 
113
184
  job.heartbeatAt = new Date()
114
185
 
115
- await em.flush()
116
-
117
- await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
118
- ...buildJobPayload(job),
119
- tenantId: ctx.tenantId,
120
- organizationId: job.organizationId ?? null,
121
- })
122
-
123
- return job
186
+ return commitJobUpdate(job, ctx)
124
187
  },
125
188
 
126
189
  async incrementProgress(jobId, delta, ctx) {
127
- const job = await em.findOneOrFail(ProgressJob, {
128
- id: jobId,
129
- tenantId: ctx.tenantId,
130
- ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
131
- })
190
+ const job = await loadUpdatableJob(jobId, ctx)
132
191
  if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
192
+ forgetJobThrottle(jobId)
133
193
  return job
134
194
  }
135
195
 
@@ -143,15 +203,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
143
203
  }
144
204
  }
145
205
 
146
- await em.flush()
147
-
148
- await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
149
- ...buildJobPayload(job),
150
- tenantId: ctx.tenantId,
151
- organizationId: job.organizationId ?? null,
152
- })
153
-
154
- return job
206
+ return commitJobUpdate(job, ctx)
155
207
  },
156
208
 
157
209
  async completeJob(jobId, input, ctx) {
@@ -182,6 +234,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
182
234
  organizationId: job.organizationId ?? null,
183
235
  })
184
236
 
237
+ forgetJobThrottle(jobId)
185
238
  return job
186
239
  },
187
240
 
@@ -210,6 +263,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
210
263
  organizationId: job.organizationId ?? null,
211
264
  })
212
265
 
266
+ forgetJobThrottle(jobId)
213
267
  return job
214
268
  },
215
269
 
@@ -238,6 +292,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
238
292
  organizationId: job.organizationId ?? null,
239
293
  })
240
294
 
295
+ forgetJobThrottle(jobId)
241
296
  return job
242
297
  },
243
298
 
@@ -266,6 +321,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
266
321
  organizationId: job.organizationId ?? null,
267
322
  })
268
323
 
324
+ forgetJobThrottle(jobId)
269
325
  return job
270
326
  },
271
327
 
@@ -4,6 +4,7 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
4
  import { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
5
5
  import { StaffTimeEntry } from '../../../data/entities'
6
6
  import { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from '../../../data/validators'
7
+ import { buildTimeEntryListFilters, isParseableDateFilter } from '../../../lib/timesheets/timeEntryListFilters'
7
8
  import { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'
8
9
 
9
10
  const F = {
@@ -37,13 +38,6 @@ export const metadata = routeMetadata
37
38
 
38
39
  const rawBodySchema = z.object({}).passthrough()
39
40
 
40
- const isParseableDateFilter = (value: string): boolean => {
41
- const trimmed = value.trim()
42
- if (trimmed.length === 0) return true
43
- if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(trimmed)) return false
44
- return !Number.isNaN(new Date(trimmed).getTime())
45
- }
46
-
47
41
  const dateFilterSchema = z
48
42
  .string()
49
43
  .refine(isParseableDateFilter, { message: 'Invalid date' })
@@ -58,6 +52,7 @@ const listSchema = z
58
52
  to: dateFilterSchema,
59
53
  projectId: z.string().uuid().optional(),
60
54
  ids: z.string().optional(),
55
+ running: z.string().optional(),
61
56
  sortField: z.string().optional(),
62
57
  sortDir: z.enum(['asc', 'desc']).optional(),
63
58
  })
@@ -100,31 +95,7 @@ const crud = makeCrudRoute({
100
95
  updatedAt: F.updated_at,
101
96
  durationMinutes: F.duration_minutes,
102
97
  },
103
- buildFilters: async (query) => {
104
- const filters: Record<string, unknown> = {}
105
- if (typeof query.ids === 'string' && query.ids.trim().length > 0) {
106
- const ids = query.ids
107
- .split(',')
108
- .map((value) => value.trim())
109
- .filter((value) => value.length > 0)
110
- if (ids.length > 0) {
111
- filters[F.id] = { $in: ids }
112
- }
113
- }
114
- if (typeof query.staffMemberId === 'string' && query.staffMemberId.length > 0) {
115
- filters[F.staff_member_id] = query.staffMemberId
116
- }
117
- if (typeof query.from === 'string' && query.from.length > 0 && isParseableDateFilter(query.from)) {
118
- filters[F.date] = { ...((filters[F.date] as Record<string, unknown>) ?? {}), $gte: query.from }
119
- }
120
- if (typeof query.to === 'string' && query.to.length > 0 && isParseableDateFilter(query.to)) {
121
- filters[F.date] = { ...((filters[F.date] as Record<string, unknown>) ?? {}), $lte: query.to }
122
- }
123
- if (typeof query.projectId === 'string' && query.projectId.length > 0) {
124
- filters[F.time_project_id] = query.projectId
125
- }
126
- return filters
127
- },
98
+ buildFilters: async (query) => buildTimeEntryListFilters(query),
128
99
  },
129
100
  actions: {
130
101
  create: {
@@ -0,0 +1,64 @@
1
+ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
2
+
3
+ export type TimeEntryListFilterQuery = {
4
+ ids?: string
5
+ staffMemberId?: string
6
+ from?: string
7
+ to?: string
8
+ projectId?: string
9
+ running?: string
10
+ }
11
+
12
+ const ID_COLUMN = 'id'
13
+ const STAFF_MEMBER_COLUMN = 'staff_member_id'
14
+ const DATE_COLUMN = 'date'
15
+ const STARTED_AT_COLUMN = 'started_at'
16
+ const ENDED_AT_COLUMN = 'ended_at'
17
+ const PROJECT_COLUMN = 'time_project_id'
18
+
19
+ export function isParseableDateFilter(value: string): boolean {
20
+ const trimmed = value.trim()
21
+ if (trimmed.length === 0) return true
22
+ if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(trimmed)) return false
23
+ return !Number.isNaN(new Date(trimmed).getTime())
24
+ }
25
+
26
+ /**
27
+ * Builds the query-engine filter map for the time-entries list route.
28
+ *
29
+ * The `running` flag matches the currently-open timer regardless of its date
30
+ * (`started_at IS NOT NULL AND ended_at IS NULL`). A timer started before
31
+ * midnight is still running the next day, so a `from=today&to=today` lookup
32
+ * would miss it and leave the entry invisible/uncontrollable (issue #3717).
33
+ * This mirrors the single-active-timer check in the `start_timer` command so
34
+ * the UI can always surface and stop an orphaned overnight timer.
35
+ */
36
+ export function buildTimeEntryListFilters(query: TimeEntryListFilterQuery): Record<string, unknown> {
37
+ const filters: Record<string, unknown> = {}
38
+ if (typeof query.ids === 'string' && query.ids.trim().length > 0) {
39
+ const ids = query.ids
40
+ .split(',')
41
+ .map((value) => value.trim())
42
+ .filter((value) => value.length > 0)
43
+ if (ids.length > 0) {
44
+ filters[ID_COLUMN] = { $in: ids }
45
+ }
46
+ }
47
+ if (typeof query.staffMemberId === 'string' && query.staffMemberId.length > 0) {
48
+ filters[STAFF_MEMBER_COLUMN] = query.staffMemberId
49
+ }
50
+ if (typeof query.from === 'string' && query.from.length > 0 && isParseableDateFilter(query.from)) {
51
+ filters[DATE_COLUMN] = { ...((filters[DATE_COLUMN] as Record<string, unknown>) ?? {}), $gte: query.from }
52
+ }
53
+ if (typeof query.to === 'string' && query.to.length > 0 && isParseableDateFilter(query.to)) {
54
+ filters[DATE_COLUMN] = { ...((filters[DATE_COLUMN] as Record<string, unknown>) ?? {}), $lte: query.to }
55
+ }
56
+ if (typeof query.projectId === 'string' && query.projectId.length > 0) {
57
+ filters[PROJECT_COLUMN] = query.projectId
58
+ }
59
+ if (parseBooleanToken(query.running ?? null) === true) {
60
+ filters[STARTED_AT_COLUMN] = { $ne: null }
61
+ filters[ENDED_AT_COLUMN] = null
62
+ }
63
+ return filters
64
+ }
@@ -102,10 +102,9 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
102
102
  useEffect(() => {
103
103
  if (!staffMemberId) return
104
104
 
105
- const today = getToday()
106
105
  const checkActiveTimer = async () => {
107
106
  const response = await apiCall(
108
- `/api/staff/timesheets/time-entries?staffMemberId=${staffMemberId}&from=${today}&to=${today}&pageSize=50`,
107
+ `/api/staff/timesheets/time-entries?staffMemberId=${staffMemberId}&running=true&pageSize=50`,
109
108
  )
110
109
  if (!response.ok) return
111
110
 
@@ -91,9 +91,7 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
91
91
  const memberId = selfRes.member?.id ?? null
92
92
  setStaffMemberId(memberId)
93
93
 
94
- const today = new Date().toISOString().slice(0, 10)
95
-
96
- // Project details depend on the assignment ids and active entries depend on the staff
94
+ // Project details depend on the assignment ids and the active timer depends on the staff
97
95
  // member id, but the two requests are independent of each other — fetch them together.
98
96
  const [projectsRes, entriesRes] = await Promise.all([
99
97
  projectIds.length > 0
@@ -105,7 +103,7 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
105
103
  : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),
106
104
  memberId
107
105
  ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
108
- `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&from=${today}&to=${today}&pageSize=100`,
106
+ `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=100`,
109
107
  undefined,
110
108
  { errorMessage: '', fallback: { items: [] } },
111
109
  )
@@ -39,11 +39,6 @@ function formatElapsed(seconds: number): string {
39
39
  return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
40
40
  }
41
41
 
42
- function getToday(): string {
43
- const now = new Date()
44
- return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
45
- }
46
-
47
42
  function TimerSidebarIndicator() {
48
43
  const t = useT()
49
44
  // Initialise from sessionStorage so the indicator doesn't flash away on navigation
@@ -63,9 +58,8 @@ function TimerSidebarIndicator() {
63
58
  const memberId = selfRes.result?.member?.id
64
59
  if (!memberId) { updateTimer(null); return }
65
60
 
66
- const today = getToday()
67
61
  const res = await apiCall<{ items?: Array<Record<string, unknown>> }>(
68
- `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&from=${today}&to=${today}&pageSize=50`,
62
+ `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=50`,
69
63
  )
70
64
  const items = (res.result?.items ?? []) as Array<Record<string, unknown>>
71
65
  const active = items.find((e) => e.started_at && !e.ended_at)