@ossy/timesheets 3.9.0 → 3.11.0

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 (36) hide show
  1. package/package.json +5 -3
  2. package/src/TimesheetCard.jsx +2 -0
  3. package/src/TimesheetPanel.jsx +27 -5
  4. package/src/TimesheetsFreeTool.jsx +10 -6
  5. package/src/TimesheetsProductHome.jsx +16 -9
  6. package/src/auto-generate-timesheets.js +70 -0
  7. package/src/auto-generate-timesheets.spec.js +90 -0
  8. package/src/auto-generate-timesheets.task.js +60 -0
  9. package/src/billing-period.js +123 -0
  10. package/src/billing-period.spec.js +89 -0
  11. package/src/build-prefilled-lines.js +3 -16
  12. package/src/create-and-save-timesheet.flow.js +34 -0
  13. package/src/create-timesheet-draft.js +81 -0
  14. package/src/delete-timesheet.flow.js +41 -0
  15. package/src/disable-timesheets.flow.js +58 -0
  16. package/src/enable-timesheets.flow.js +24 -0
  17. package/src/export-csv.action.js +7 -0
  18. package/src/export-pdf.action.js +7 -0
  19. package/src/export-png.action.js +7 -0
  20. package/src/export-timesheet-csv.flow.js +39 -0
  21. package/src/export-timesheet-pdf.flow.js +39 -0
  22. package/src/export-timesheet-png.flow.js +39 -0
  23. package/src/generate.task.js +8 -45
  24. package/src/index.js +14 -0
  25. package/src/open-delete.action.js +7 -0
  26. package/src/open-export.action.js +7 -0
  27. package/src/send-timesheet-reminder.task.js +76 -0
  28. package/src/timesheet-dto.js +26 -0
  29. package/src/timesheet-refs.js +16 -0
  30. package/src/timesheet-reminder.email.jsx +47 -0
  31. package/src/timesheet-reminder.js +62 -0
  32. package/src/timesheet-reminder.spec.js +75 -0
  33. package/src/timesheet-resources.js +6 -40
  34. package/src/timesheet-resources.spec.js +1 -1
  35. package/src/timesheet.schema.js +5 -0
  36. package/src/timesheets.page.jsx +6 -3
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/timesheets",
3
3
  "description": "Timesheets — generate, review, save, and export monthly work hours",
4
- "version": "3.9.0",
4
+ "version": "3.11.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -17,7 +17,9 @@
17
17
  "src": "./src"
18
18
  },
19
19
  "dependencies": {
20
- "@ossy/resources": "^3.9.0",
20
+ "@ossy/email": "^3.11.0",
21
+ "@ossy/resources": "^3.11.0",
22
+ "@ossy/schema": "^3.8.0",
21
23
  "html-to-image": "^1.11.13",
22
24
  "nanoid": "^5.1.11",
23
25
  "pdf-lib": "^1.17.1"
@@ -41,5 +43,5 @@
41
43
  "/src",
42
44
  "README.md"
43
45
  ],
44
- "gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
46
+ "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
45
47
  }
@@ -33,6 +33,8 @@ export function TimesheetCard ({ timesheet, onClick, selected = false, actions }
33
33
  selected={selected}
34
34
  onClick={onClick}
35
35
  onContextMenu={handleContextMenu}
36
+ data-timesheet-id={timesheet?.id}
37
+ data-timesheet-status={status}
36
38
  leading={(
37
39
  <Icon size="s" name="calendar" style={{ fill: 'hsl(0, 0%, 80%)' }} />
38
40
  )}
@@ -11,11 +11,16 @@ import { useTimesheetParties } from './TimesheetPartyFields.jsx'
11
11
  import { contractBillingMeta, labelForContract, labelForEmployee } from './timesheet-parties.js'
12
12
  import { metadata as GetTimesheet } from './get.action.js'
13
13
  import { metadata as SaveTimesheet } from './save.action.js'
14
+ import { metadata as OpenDelete } from './open-delete.action.js'
15
+ import { metadata as OpenExport } from './open-export.action.js'
16
+ import { metadata as ExportCsv } from './export-csv.action.js'
17
+ import { metadata as ExportPng } from './export-png.action.js'
18
+ import { metadata as ExportPdf } from './export-pdf.action.js'
14
19
 
15
20
  /**
16
21
  * Storage-style side panel for reviewing and saving a timesheet.
17
22
  */
18
- export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }) {
23
+ export function TimesheetPanel ({ timesheetId, onClose, onSaved, onRequestDelete, width = '50%' }) {
19
24
  const { t } = useLocale()
20
25
  const sdk = useSdk()
21
26
  const captureRef = useRef(null)
@@ -130,6 +135,8 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
130
135
  surface="primary"
131
136
  bordered
132
137
  roundness="xs"
138
+ data-timesheet-panel={timesheetId || undefined}
139
+ data-timesheet-panel-status={timesheet?.status || (loading ? 'loading' : undefined)}
133
140
  style={{ height: '100%', width, minHeight: 0, minWidth: 0, flexShrink: 0 }}
134
141
  >
135
142
  <View.Item>
@@ -151,6 +158,8 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
151
158
  <Dropdown
152
159
  trigger={(
153
160
  <Button
161
+ {...OpenExport}
162
+ label={undefined}
154
163
  variant="command"
155
164
  prefix="software-download"
156
165
  disabled={exportDisabled}
@@ -160,25 +169,26 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
160
169
  >
161
170
  <ContextMenu roundness="s" surface="primary">
162
171
  <ContextMenu.Item
172
+ {...ExportCsv}
163
173
  prefix="list"
164
- label="timesheets.exportCsv"
165
174
  onClick={() => runExport('csv')}
166
175
  />
167
176
  <ContextMenu.Item
177
+ {...ExportPng}
168
178
  prefix="image"
169
- label="timesheets.exportPng"
170
179
  onClick={() => runExport('png')}
171
180
  />
172
181
  <ContextMenu.Item
182
+ {...ExportPdf}
173
183
  prefix="file-document"
174
- label="timesheets.exportPdf"
175
184
  onClick={() => runExport('pdf')}
176
185
  />
177
186
  </ContextMenu>
178
187
  </Dropdown>
179
188
 
180
189
  <Button
181
- id={SaveTimesheet.id}
190
+ {...SaveTimesheet}
191
+ label={undefined}
182
192
  variant="command"
183
193
  prefix="check"
184
194
  disabled={!timesheet?.id || saving || loading}
@@ -186,6 +196,18 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
186
196
  onClick={handleSave}
187
197
  />
188
198
 
199
+ {onRequestDelete && (
200
+ <Button
201
+ {...OpenDelete}
202
+ label={undefined}
203
+ variant="command-danger"
204
+ prefix="trash-empty"
205
+ disabled={!timesheet?.id || loading}
206
+ aria-label={t('timesheets.delete')}
207
+ onClick={() => onRequestDelete(timesheet)}
208
+ />
209
+ )}
210
+
189
211
  {onClose && (
190
212
  <Button
191
213
  prefix="close"
@@ -17,6 +17,10 @@ import { downloadTimesheetPdf } from './download-timesheet-pdf.js'
17
17
  import { invokeErrorMessage } from './invoke-error-message.js'
18
18
  import { sampleShowcaseTimesheet } from './sample-showcase-timesheet.js'
19
19
  import { timesheetsAuthHref } from './timesheets-auth-return.js'
20
+ import { metadata as OpenExport } from './open-export.action.js'
21
+ import { metadata as ExportCsv } from './export-csv.action.js'
22
+ import { metadata as ExportPng } from './export-png.action.js'
23
+ import { metadata as ExportPdf } from './export-pdf.action.js'
20
24
 
21
25
  /**
22
26
  * Client-only timesheet for the sales page — prefilled, editable, exportable.
@@ -230,29 +234,29 @@ export function TimesheetsFreeTool ({
230
234
  <Dropdown
231
235
  trigger={(
232
236
  <Button
237
+ {...OpenExport}
233
238
  variant="secondary"
234
239
  prefix="software-download"
235
240
  suffix="chevron-down"
236
241
  disabled={exportDisabled}
237
- >
238
- {exporting ? t('timesheets.exporting') : t('timesheets.export')}
239
- </Button>
242
+ label={exporting ? 'timesheets.exporting' : OpenExport.label}
243
+ />
240
244
  )}
241
245
  >
242
246
  <ContextMenu roundness="s" surface="primary">
243
247
  <ContextMenu.Item
248
+ {...ExportCsv}
244
249
  prefix="list"
245
- label="timesheets.exportCsv"
246
250
  onClick={() => runExport('csv')}
247
251
  />
248
252
  <ContextMenu.Item
253
+ {...ExportPng}
249
254
  prefix="image"
250
- label="timesheets.exportPng"
251
255
  onClick={() => runExport('png')}
252
256
  />
253
257
  <ContextMenu.Item
258
+ {...ExportPdf}
254
259
  prefix="file-document"
255
- label="timesheets.exportPdf"
256
260
  onClick={() => runExport('pdf')}
257
261
  />
258
262
  </ContextMenu>
@@ -16,6 +16,10 @@ import { metadata as ListTimesheets } from './list.action.js'
16
16
  import { metadata as GenerateTimesheet } from './generate.action.js'
17
17
  import { metadata as OpenNewTimesheet } from './open-new.action.js'
18
18
  import { metadata as DeleteTimesheet } from './delete.action.js'
19
+ import { metadata as OpenDelete } from './open-delete.action.js'
20
+ import { metadata as ExportCsv } from './export-csv.action.js'
21
+ import { metadata as ExportPng } from './export-png.action.js'
22
+ import { metadata as ExportPdf } from './export-pdf.action.js'
19
23
  import { HolidayLocaleSelect } from './HolidayLocaleSelect.jsx'
20
24
  import { resolveHolidayLocale } from './holiday-locale.js'
21
25
 
@@ -212,6 +216,7 @@ export default function TimesheetsProductHome () {
212
216
  <>
213
217
  <Page
214
218
  scroll
219
+ data-timesheets-status="on"
215
220
  title="timesheets.home.title"
216
221
  near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
217
222
  description="timesheets.home.description"
@@ -232,7 +237,8 @@ export default function TimesheetsProductHome () {
232
237
  <Text as="h2" variant="small" weight="medium" text="timesheets.listTitle" />
233
238
  </View.Item>
234
239
  <Button
235
- id={OpenNewTimesheet.id}
240
+ {...OpenNewTimesheet}
241
+ label={undefined}
236
242
  variant="command"
237
243
  prefix="add"
238
244
  aria-label={t('timesheets.new')}
@@ -272,25 +278,26 @@ export default function TimesheetsProductHome () {
272
278
  />,
273
279
  <ContextMenu.Item
274
280
  key="csv"
281
+ {...ExportCsv}
275
282
  prefix="list"
276
- label="timesheets.exportCsv"
277
283
  onClick={() => requestExport(sheet, 'csv')}
278
284
  />,
279
285
  <ContextMenu.Item
280
286
  key="png"
287
+ {...ExportPng}
281
288
  prefix="image"
282
- label="timesheets.exportPng"
283
289
  onClick={() => requestExport(sheet, 'png')}
284
290
  />,
285
291
  <ContextMenu.Item
286
292
  key="pdf"
293
+ {...ExportPdf}
287
294
  prefix="file-document"
288
- label="timesheets.exportPdf"
289
295
  onClick={() => requestExport(sheet, 'pdf')}
290
296
  />,
291
297
  <ContextMenu.Separator key="separator" />,
292
298
  <ContextMenu.Item
293
299
  key="delete"
300
+ {...OpenDelete}
294
301
  variant="command-danger"
295
302
  prefix="trash-empty"
296
303
  label="timesheets.delete"
@@ -309,6 +316,7 @@ export default function TimesheetsProductHome () {
309
316
  timesheetId={selectedResourceId}
310
317
  onClose={clearSelectedResourceId}
311
318
  onSaved={refreshList}
319
+ onRequestDelete={setSheetToDelete}
312
320
  />
313
321
  </View>
314
322
  </Page>
@@ -328,7 +336,7 @@ export default function TimesheetsProductHome () {
328
336
  onClick: () => setSheetToDelete(null),
329
337
  },
330
338
  {
331
- label: 'timesheets.delete',
339
+ ...DeleteTimesheet,
332
340
  variant: 'command-danger',
333
341
  disabled: deleting,
334
342
  onClick: confirmDelete,
@@ -410,13 +418,12 @@ export default function TimesheetsProductHome () {
410
418
  onClick={closeCreateModal}
411
419
  />
412
420
  <Button
413
- id={GenerateTimesheet.id}
421
+ {...GenerateTimesheet}
422
+ label={generating ? 'timesheets.loading' : GenerateTimesheet.label}
414
423
  variant="cta"
415
424
  disabled={generating || !selected}
416
425
  onClick={handleGenerate}
417
- >
418
- {generating ? t('timesheets.loading') : t('timesheets.generate')}
419
- </Button>
426
+ />
420
427
  </View>
421
428
  </View>
422
429
  </View>
@@ -0,0 +1,70 @@
1
+ import { TimesheetRefs, TimesheetsSchema } from './schema-ids.js'
2
+ import {
3
+ findTimesheetForContractPeriod,
4
+ isContractActive,
5
+ periodForContract,
6
+ } from './billing-period.js'
7
+ import { optionalReference } from './timesheet-refs.js'
8
+
9
+ /** Company basic-info schema (consultancy) — used for holiday locale. */
10
+ export const COMPANY_BASIC_INFO_SCHEMA = '@ossy/consultancy/schema/company-basic-info'
11
+
12
+ /**
13
+ * Pick holiday locale for a workspace from company basic-info resources.
14
+ * @param {Array<{ belongsTo?: string, content?: { holidayLocale?: string } }>} companyResources
15
+ * @param {string} workspaceId
16
+ * @returns {string | undefined}
17
+ */
18
+ export function holidayLocaleForWorkspace (companyResources, workspaceId) {
19
+ const match = (companyResources || []).find((r) => r.belongsTo === workspaceId)
20
+ return match?.content?.holidayLocale
21
+ }
22
+
23
+ /**
24
+ * Decide which active contracts need a new auto-generated sheet for `asOf`.
25
+ *
26
+ * @param {object} input
27
+ * @param {Array<{ id: string, belongsTo?: string, content?: object }>} input.contracts
28
+ * @param {Array<{ id: string, belongsTo?: string, createdAt?: number, content?: object }>} input.timesheets
29
+ * @param {Date | number} [input.asOf]
30
+ * @returns {Array<{
31
+ * contractId: string,
32
+ * workspaceId: string,
33
+ * employeeId: string | null,
34
+ * periodStart: number,
35
+ * periodEnd: number,
36
+ * billingPeriod: string,
37
+ * }>}
38
+ */
39
+ export function contractsNeedingTimesheets ({ contracts, timesheets, asOf = Date.now() }) {
40
+ const needed = []
41
+
42
+ for (const contract of contracts || []) {
43
+ const workspaceId = contract.belongsTo
44
+ if (!workspaceId) continue
45
+ if (!isContractActive(contract.content, asOf)) continue
46
+
47
+ const { periodStart, periodEnd } = periodForContract(contract.content, asOf)
48
+ const existing = findTimesheetForContractPeriod(timesheets, contract.id, periodStart)
49
+ if (existing) continue
50
+
51
+ const employeeId = optionalReference(contract.content?.employeeId)?.resourceId ?? null
52
+
53
+ needed.push({
54
+ contractId: contract.id,
55
+ workspaceId,
56
+ employeeId,
57
+ periodStart,
58
+ periodEnd,
59
+ billingPeriod: contract.content?.billingPeriod === 'weekly' ? 'weekly' : 'monthly',
60
+ })
61
+ }
62
+
63
+ return needed
64
+ }
65
+
66
+ export const AUTO_GENERATE_QUERY = {
67
+ contracts: { type: TimesheetRefs.contract },
68
+ timesheets: { type: TimesheetsSchema.timesheet },
69
+ companies: { type: COMPANY_BASIC_INFO_SCHEMA },
70
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ contractsNeedingTimesheets,
4
+ holidayLocaleForWorkspace,
5
+ } from './auto-generate-timesheets.js'
6
+
7
+ describe('holidayLocaleForWorkspace', () => {
8
+ it('returns the company holidayLocale for the workspace', () => {
9
+ const companies = [
10
+ { belongsTo: 'ws-a', content: { holidayLocale: 'SE' } },
11
+ { belongsTo: 'ws-b', content: { holidayLocale: 'NO' } },
12
+ ]
13
+ expect(holidayLocaleForWorkspace(companies, 'ws-b')).toBe('NO')
14
+ expect(holidayLocaleForWorkspace(companies, 'ws-missing')).toBe(undefined)
15
+ })
16
+ })
17
+
18
+ describe('contractsNeedingTimesheets', () => {
19
+ const augustStart = new Date(2026, 7, 1).setHours(0, 0, 0, 0)
20
+ const asOf = new Date(2026, 7, 19)
21
+
22
+ it('skips inactive contracts and existing sheets', () => {
23
+ const contracts = [
24
+ {
25
+ id: 'c-active',
26
+ belongsTo: 'ws-1',
27
+ content: {
28
+ billingPeriod: 'monthly',
29
+ employeeId: 'emp-1',
30
+ startDate: new Date(2026, 0, 1).getTime(),
31
+ },
32
+ },
33
+ {
34
+ id: 'c-done',
35
+ belongsTo: 'ws-1',
36
+ content: {
37
+ billingPeriod: 'monthly',
38
+ startDate: new Date(2025, 0, 1).getTime(),
39
+ endDate: new Date(2026, 0, 1).getTime(),
40
+ },
41
+ },
42
+ {
43
+ id: 'c-has-sheet',
44
+ belongsTo: 'ws-1',
45
+ content: {
46
+ billingPeriod: 'monthly',
47
+ startDate: new Date(2026, 0, 1).getTime(),
48
+ },
49
+ },
50
+ ]
51
+
52
+ const timesheets = [
53
+ {
54
+ id: 'sheet-1',
55
+ createdAt: 1,
56
+ content: { periodStart: augustStart, contractId: 'c-has-sheet' },
57
+ },
58
+ ]
59
+
60
+ expect(contractsNeedingTimesheets({ contracts, timesheets, asOf })).toEqual([
61
+ {
62
+ contractId: 'c-active',
63
+ workspaceId: 'ws-1',
64
+ employeeId: 'emp-1',
65
+ periodStart: augustStart,
66
+ periodEnd: new Date(2026, 7, 31).setHours(0, 0, 0, 0),
67
+ billingPeriod: 'monthly',
68
+ },
69
+ ])
70
+ })
71
+
72
+ it('uses weekly period bounds when configured', () => {
73
+ const contracts = [{
74
+ id: 'c-week',
75
+ belongsTo: 'ws-2',
76
+ content: {
77
+ billingPeriod: 'weekly',
78
+ employeeId: { resourceId: 'emp-9' },
79
+ startDate: new Date(2026, 0, 1).getTime(),
80
+ },
81
+ }]
82
+
83
+ const [needed] = contractsNeedingTimesheets({ contracts, timesheets: [], asOf })
84
+ expect(needed.contractId).toBe('c-week')
85
+ expect(needed.employeeId).toBe('emp-9')
86
+ expect(needed.billingPeriod).toBe('weekly')
87
+ expect(new Date(needed.periodStart).getDay()).toBe(1)
88
+ expect(new Date(needed.periodEnd).getDay()).toBe(0)
89
+ })
90
+ })
@@ -0,0 +1,60 @@
1
+ import { ResourcesQueries } from '@ossy/resources/server'
2
+ import {
3
+ AUTO_GENERATE_QUERY,
4
+ contractsNeedingTimesheets,
5
+ holidayLocaleForWorkspace,
6
+ } from './auto-generate-timesheets.js'
7
+ import { createTimesheetDraft } from './create-timesheet-draft.js'
8
+
9
+ export const metadata = {
10
+ id: '@ossy/timesheets/tasks/auto-generate',
11
+ // Daily at 06:00 UTC — create sheets when a billing period is open
12
+ schedule: '0 6 * * *',
13
+ }
14
+
15
+ /**
16
+ * Auto-generate draft timesheets for active consultancy contracts based on
17
+ * each contract's billing period (weekly / monthly). Idempotent per
18
+ * (workspace, contractId, periodStart).
19
+ */
20
+ export async function run ({ log }) {
21
+ const now = Date.now()
22
+
23
+ const [contracts, timesheets, companies] = await Promise.all([
24
+ ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.contracts),
25
+ ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.timesheets),
26
+ ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.companies),
27
+ ])
28
+
29
+ const needed = contractsNeedingTimesheets({ contracts, timesheets, asOf: now })
30
+ log?.info(`[timesheets/tasks/auto-generate] ${needed.length} contract(s) need a sheet`)
31
+
32
+ let created = 0
33
+ let failed = 0
34
+
35
+ for (const item of needed) {
36
+ try {
37
+ const locale = holidayLocaleForWorkspace(companies, item.workspaceId)
38
+ await createTimesheetDraft({
39
+ workspaceId: item.workspaceId,
40
+ periodStart: item.periodStart,
41
+ periodEnd: item.periodEnd,
42
+ locale,
43
+ employeeId: item.employeeId,
44
+ contractId: item.contractId,
45
+ createdBy: metadata.id,
46
+ autoGenerated: true,
47
+ log,
48
+ })
49
+ created++
50
+ } catch (err) {
51
+ failed++
52
+ log?.error(
53
+ `[timesheets/tasks/auto-generate] failed for contract ${item.contractId}`,
54
+ err,
55
+ )
56
+ }
57
+ }
58
+
59
+ return { created, failed, candidates: needed.length }
60
+ }
@@ -0,0 +1,123 @@
1
+ import { optionalReference } from './timesheet-refs.js'
2
+
3
+ export const BILLING_PERIODS = Object.freeze(['weekly', 'monthly'])
4
+ export const DEFAULT_BILLING_PERIOD = 'monthly'
5
+
6
+ /**
7
+ * @param {unknown} value
8
+ * @returns {'weekly' | 'monthly'}
9
+ */
10
+ export function normalizeBillingPeriod (value) {
11
+ if (value === 'weekly' || value === 'monthly') return value
12
+ return DEFAULT_BILLING_PERIOD
13
+ }
14
+
15
+ /**
16
+ * @param {unknown} value — Unix ms, ISO string, or Date
17
+ * @returns {number | null}
18
+ */
19
+ export function toMs (value) {
20
+ if (value == null || value === '') return null
21
+ if (typeof value === 'number' && Number.isFinite(value)) return value
22
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.getTime()
23
+ if (typeof value === 'string') {
24
+ const parsed = Date.parse(value)
25
+ if (!Number.isNaN(parsed)) return parsed
26
+ }
27
+ return null
28
+ }
29
+
30
+ /**
31
+ * Calendar-month period bounds as local midnights, returned as Unix ms.
32
+ * @param {number} year
33
+ * @param {number} monthIndex 0–11
34
+ */
35
+ export function monthPeriodBounds (year, monthIndex) {
36
+ const start = new Date(year, monthIndex, 1)
37
+ const end = new Date(year, monthIndex + 1, 0)
38
+ start.setHours(0, 0, 0, 0)
39
+ end.setHours(0, 0, 0, 0)
40
+ return {
41
+ periodStart: start.getTime(),
42
+ periodEnd: end.getTime(),
43
+ }
44
+ }
45
+
46
+ /**
47
+ * ISO-week (Mon–Sun) period bounds as local midnights, returned as Unix ms.
48
+ * @param {Date | number} [asOf]
49
+ */
50
+ export function weekPeriodBounds (asOf = Date.now()) {
51
+ const ref = new Date(asOf)
52
+ ref.setHours(0, 0, 0, 0)
53
+ const day = ref.getDay() // 0 Sun … 6 Sat
54
+ const daysFromMonday = day === 0 ? 6 : day - 1
55
+ const start = new Date(ref)
56
+ start.setDate(ref.getDate() - daysFromMonday)
57
+ start.setHours(0, 0, 0, 0)
58
+ const end = new Date(start)
59
+ end.setDate(start.getDate() + 6)
60
+ end.setHours(0, 0, 0, 0)
61
+ return {
62
+ periodStart: start.getTime(),
63
+ periodEnd: end.getTime(),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Billing period that contains `asOf` for a contract's billing policy.
69
+ * @param {{ billingPeriod?: string } | null | undefined} contract
70
+ * @param {Date | number} [asOf]
71
+ */
72
+ export function periodForContract (contract, asOf = Date.now()) {
73
+ const billingPeriod = normalizeBillingPeriod(contract?.billingPeriod)
74
+ if (billingPeriod === 'weekly') return weekPeriodBounds(asOf)
75
+ const ref = new Date(asOf)
76
+ return monthPeriodBounds(ref.getFullYear(), ref.getMonth())
77
+ }
78
+
79
+ /**
80
+ * Contract is active on `asOf` when start ≤ asOf and (no end or end ≥ asOf day start).
81
+ * @param {{ startDate?: unknown, endDate?: unknown } | null | undefined} contract
82
+ * @param {Date | number} [asOf]
83
+ */
84
+ export function isContractActive (contract, asOf = Date.now()) {
85
+ if (!contract) return false
86
+ const asOfMs = toMs(asOf) ?? Date.now()
87
+ const dayStart = new Date(asOfMs)
88
+ dayStart.setHours(0, 0, 0, 0)
89
+ const dayMs = dayStart.getTime()
90
+
91
+ const startMs = toMs(contract.startDate)
92
+ if (startMs != null) {
93
+ const startDay = new Date(startMs)
94
+ startDay.setHours(0, 0, 0, 0)
95
+ if (dayMs < startDay.getTime()) return false
96
+ }
97
+
98
+ const endMs = toMs(contract.endDate)
99
+ if (endMs != null) {
100
+ const endDay = new Date(endMs)
101
+ endDay.setHours(0, 0, 0, 0)
102
+ if (dayMs > endDay.getTime()) return false
103
+ }
104
+
105
+ return true
106
+ }
107
+
108
+ /**
109
+ * Newest timesheet for a contract + periodStart pair.
110
+ * @param {Array<{ id: string, createdAt?: number, content?: object }>} resources
111
+ * @param {string} contractId
112
+ * @param {number} periodStart
113
+ */
114
+ export function findTimesheetForContractPeriod (resources, contractId, periodStart) {
115
+ if (!contractId || periodStart == null) return null
116
+ const matches = (resources || []).filter((r) => {
117
+ if (r.content?.periodStart !== periodStart) return false
118
+ const ref = optionalReference(r.content?.contractId)
119
+ return ref?.resourceId === contractId
120
+ })
121
+ if (matches.length === 0) return null
122
+ return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
123
+ }