@ossy/timesheets 3.0.6 → 3.0.7

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.
@@ -0,0 +1,103 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { ResourcesEvents, commitResource, mutateResource } from '@ossy/resources/server'
3
+ import { TimesheetsSchema } from './schema-ids.js'
4
+ import { timesheets } from './locations.js'
5
+ import { buildPrefilledLines, monthPeriodBounds } from './build-prefilled-lines.js'
6
+ import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
7
+ import {
8
+ findTimesheetForPeriod,
9
+ getTimesheetResources,
10
+ toTimesheetDto,
11
+ } from './timesheet-resources.js'
12
+
13
+ export const metadata = { id: '@ossy/timesheets/tasks/generate' }
14
+
15
+ /**
16
+ * Generate (or load) a timesheet for a calendar month.
17
+ *
18
+ * Payload: `{ year, monthIndex, locale? }` (monthIndex 0–11) OR `{ periodStart, periodEnd, locale? }`.
19
+ * `locale` is an ISO country code or BCP 47 tag used for workday/holiday prefill.
20
+ * If a saved sheet exists for the period, returns it without regenerating.
21
+ * If a draft exists, regenerates prefilled lines (keeps id).
22
+ */
23
+ export async function run ({ payload, req, log }) {
24
+ const workspaceId = req?.workspaceId
25
+
26
+ if (!workspaceId) {
27
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
28
+ }
29
+
30
+ const { year, monthIndex, locale: payloadLocale } = payload ?? {}
31
+ let { periodStart, periodEnd } = payload ?? {}
32
+
33
+ if (periodStart == null || periodEnd == null) {
34
+ if (year == null || monthIndex == null) {
35
+ throw Object.assign(
36
+ new Error('year and monthIndex (or periodStart and periodEnd) are required'),
37
+ { status: 400 },
38
+ )
39
+ }
40
+ ;({ periodStart, periodEnd } = monthPeriodBounds(Number(year), Number(monthIndex)))
41
+ }
42
+
43
+ periodStart = Number(periodStart)
44
+ periodEnd = Number(periodEnd)
45
+
46
+ if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd < periodStart) {
47
+ throw Object.assign(new Error('invalid period'), { status: 400 })
48
+ }
49
+
50
+ const holidayLocale = resolveHolidayLocale(payloadLocale, DEFAULT_HOLIDAY_LOCALE)
51
+
52
+ log?.info(
53
+ `[timesheets/tasks/generate] period ${periodStart}–${periodEnd} for workspace ${workspaceId} (locale ${holidayLocale})`,
54
+ )
55
+
56
+ const resources = await getTimesheetResources({
57
+ schemaId: TimesheetsSchema.timesheet,
58
+ belongsTo: workspaceId,
59
+ })
60
+ const existing = findTimesheetForPeriod(resources, periodStart)
61
+
62
+ if (existing?.content?.status === 'saved') {
63
+ log?.info(`[timesheets/tasks/generate] Returning saved timesheet ${existing.id}`)
64
+ return toTimesheetDto(existing)
65
+ }
66
+
67
+ const lines = buildPrefilledLines(periodStart, periodEnd, holidayLocale)
68
+ const content = {
69
+ periodStart,
70
+ periodEnd,
71
+ status: 'draft',
72
+ holidayLocale,
73
+ lines,
74
+ }
75
+
76
+ if (existing) {
77
+ log?.info(`[timesheets/tasks/generate] Regenerating draft ${existing.id}`)
78
+ await mutateResource(
79
+ existing.id,
80
+ ResourcesEvents.Patched({
81
+ createdBy: req?.userId ?? 'system',
82
+ content,
83
+ }),
84
+ )
85
+ return toTimesheetDto({ ...existing, content })
86
+ }
87
+
88
+ const resourceId = nanoid()
89
+ const event = ResourcesEvents.Created({
90
+ resourceId,
91
+ schemaId: TimesheetsSchema.timesheet,
92
+ createdBy: req?.userId ?? 'system',
93
+ belongsTo: workspaceId,
94
+ location: timesheets,
95
+ name: `timesheet-${periodStart}.json`,
96
+ content,
97
+ })
98
+
99
+ const created = await commitResource(event)
100
+ log?.info(`[timesheets/tasks/generate] Created timesheet ${resourceId}`)
101
+
102
+ return toTimesheetDto({ id: resourceId, content, ...created })
103
+ }
@@ -0,0 +1,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/timesheets/actions/get',
3
+ access: 'workspace',
4
+ }
@@ -0,0 +1,56 @@
1
+ import { viewResource } from '@ossy/resources/server'
2
+ import { TimesheetsSchema } from './schema-ids.js'
3
+ import {
4
+ findTimesheetForPeriod,
5
+ getTimesheetResources,
6
+ toTimesheetDto,
7
+ } from './timesheet-resources.js'
8
+
9
+ export const metadata = { id: '@ossy/timesheets/tasks/get' }
10
+
11
+ /**
12
+ * Get a timesheet by `{ timesheetId }` or `{ periodStart }`.
13
+ */
14
+ export async function run ({ payload, req, log }) {
15
+ const workspaceId = req?.workspaceId
16
+
17
+ if (!workspaceId) {
18
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
19
+ }
20
+
21
+ const { timesheetId, periodStart } = payload ?? {}
22
+
23
+ if (timesheetId) {
24
+ log?.info(`[timesheets/tasks/get] Loading timesheet ${timesheetId}`)
25
+ const resource = await viewResource(timesheetId)
26
+
27
+ if (!resource || resource.type !== TimesheetsSchema.timesheet) {
28
+ return null
29
+ }
30
+
31
+ if (resource.belongsTo && resource.belongsTo !== workspaceId) {
32
+ return null
33
+ }
34
+
35
+ return toTimesheetDto(resource)
36
+ }
37
+
38
+ if (periodStart == null) {
39
+ throw Object.assign(
40
+ new Error('timesheetId or periodStart is required'),
41
+ { status: 400 },
42
+ )
43
+ }
44
+
45
+ const resources = await getTimesheetResources({
46
+ schemaId: TimesheetsSchema.timesheet,
47
+ belongsTo: workspaceId,
48
+ })
49
+ const existing = findTimesheetForPeriod(resources, Number(periodStart))
50
+
51
+ log?.info(
52
+ `[timesheets/tasks/get] period ${periodStart} → ${existing?.id ?? 'not found'}`,
53
+ )
54
+
55
+ return toTimesheetDto(existing)
56
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Map UI language / BCP 47 tags to ISO 3166-1 alpha-2 country codes
3
+ * for `date-holidays` (CalendarClient.isWorkDay).
4
+ *
5
+ * Prefer an explicit region (`sv-SE` → `SE`, `en-US` → `US`).
6
+ * Language-only tags fall back to a common country for that language.
7
+ */
8
+
9
+ /** @type {Record<string, string>} */
10
+ const LANGUAGE_TO_COUNTRY = {
11
+ sv: 'SE',
12
+ en: 'GB',
13
+ nb: 'NO',
14
+ nn: 'NO',
15
+ no: 'NO',
16
+ da: 'DK',
17
+ fi: 'FI',
18
+ de: 'DE',
19
+ fr: 'FR',
20
+ nl: 'NL',
21
+ es: 'ES',
22
+ it: 'IT',
23
+ pt: 'PT',
24
+ pl: 'PL',
25
+ }
26
+
27
+ const DEFAULT_HOLIDAY_LOCALE = 'SE'
28
+
29
+ /**
30
+ * @param {unknown} value
31
+ * @returns {string | null} Uppercase ISO country code, or null if invalid
32
+ */
33
+ export function normalizeHolidayLocale (value) {
34
+ if (typeof value !== 'string') return null
35
+ const trimmed = value.trim()
36
+ if (!/^[A-Za-z]{2}$/.test(trimmed)) return null
37
+ return trimmed.toUpperCase()
38
+ }
39
+
40
+ /**
41
+ * @param {string | null | undefined} languageOrTag UI language or BCP 47 tag
42
+ * @param {string} [fallback]
43
+ * @returns {string} ISO country code for holiday calendars
44
+ */
45
+ export function resolveHolidayLocale (languageOrTag, fallback = DEFAULT_HOLIDAY_LOCALE) {
46
+ if (typeof languageOrTag === 'string' && languageOrTag.trim()) {
47
+ try {
48
+ const loc = new Intl.Locale(languageOrTag.trim())
49
+ const region = normalizeHolidayLocale(loc.region)
50
+ if (region) return region
51
+ const fromLanguage = LANGUAGE_TO_COUNTRY[loc.language?.toLowerCase()]
52
+ if (fromLanguage) return fromLanguage
53
+ } catch {
54
+ // fall through
55
+ }
56
+
57
+ const asCountry = normalizeHolidayLocale(languageOrTag)
58
+ if (asCountry) return asCountry
59
+ }
60
+
61
+ return normalizeHolidayLocale(fallback) || DEFAULT_HOLIDAY_LOCALE
62
+ }
63
+
64
+ export { DEFAULT_HOLIDAY_LOCALE }
package/src/index.js CHANGED
@@ -1 +1,23 @@
1
1
  export { Definition } from './Definition.js'
2
+ export { TimesheetsSchema } from './schema-ids.js'
3
+ export {
4
+ location,
5
+ timesheets,
6
+ locationByType,
7
+ } from './locations.js'
8
+ export { metadata as GenerateTimesheet } from './generate.action.js'
9
+ export { metadata as SaveTimesheet } from './save.action.js'
10
+ export { metadata as ListTimesheets } from './list.action.js'
11
+ export { metadata as GetTimesheet } from './get.action.js'
12
+ export { metadata as OpenNewTimesheet } from './open-new.action.js'
13
+ export { invokeErrorMessage } from './invoke-error-message.js'
14
+ export { downloadTimesheetCsv } from './download-timesheet-csv.js'
15
+ export { downloadTimesheetImage } from './download-timesheet-image.js'
16
+ export { downloadTimesheetPdf } from './download-timesheet-pdf.js'
17
+ export { buildPrefilledLines, monthPeriodBounds, WORKDAY_HOURS } from './build-prefilled-lines.js'
18
+ export { resolveHolidayLocale, DEFAULT_HOLIDAY_LOCALE } from './holiday-locale.js'
19
+ export { Timesheet } from './Timesheet.jsx'
20
+ export { TimesheetExportCard } from './TimesheetExportCard.jsx'
21
+ export { TimesheetsFreeTool } from './TimesheetsFreeTool.jsx'
22
+ export { TIMESHEET_EXPORT_BASE_BG } from './export-capture.js'
23
+ export { TimesheetCard } from './TimesheetCard.jsx'
@@ -0,0 +1,10 @@
1
+ /** Normalize errors from `sdk.invoke` (Response, string, or Error). */
2
+ export async function invokeErrorMessage (err, fallback) {
3
+ if (typeof err === 'string') return err
4
+ if (err instanceof Response) {
5
+ const data = await err.json().catch(() => ({}))
6
+ return data?.message ?? data?.error ?? fallback
7
+ }
8
+ if (err instanceof Error) return err.message ?? fallback
9
+ return fallback
10
+ }
@@ -0,0 +1,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/timesheets/actions/list',
3
+ access: 'workspace',
4
+ }
@@ -0,0 +1,37 @@
1
+ import { TimesheetsSchema } from './schema-ids.js'
2
+ import { getTimesheetResources, toTimesheetDto } from './timesheet-resources.js'
3
+
4
+ export const metadata = { id: '@ossy/timesheets/tasks/list' }
5
+
6
+ /**
7
+ * List timesheets for the workspace. Optional `{ periodStart }` filter.
8
+ */
9
+ export async function run ({ payload, req, log }) {
10
+ const workspaceId = req?.workspaceId
11
+
12
+ if (!workspaceId) {
13
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
14
+ }
15
+
16
+ const { periodStart } = payload ?? {}
17
+
18
+ log?.info(`[timesheets/tasks/list] Listing timesheets for workspace ${workspaceId}`)
19
+
20
+ let resources = await getTimesheetResources({
21
+ schemaId: TimesheetsSchema.timesheet,
22
+ belongsTo: workspaceId,
23
+ })
24
+
25
+ if (periodStart != null) {
26
+ const ps = Number(periodStart)
27
+ resources = resources.filter((r) => r.content?.periodStart === ps)
28
+ }
29
+
30
+ const sheets = resources
31
+ .map(toTimesheetDto)
32
+ .sort((a, b) => (b.periodStart ?? 0) - (a.periodStart ?? 0))
33
+
34
+ log?.info(`[timesheets/tasks/list] Found ${sheets.length} timesheet(s)`)
35
+
36
+ return sheets
37
+ }
@@ -0,0 +1,11 @@
1
+ import { TimesheetsSchema } from './schema-ids.js'
2
+
3
+ /** Timesheets feature root. */
4
+ export const location = '/@ossy/timesheets/'
5
+
6
+ /** Canonical location for timesheet documents. */
7
+ export const timesheets = '/@ossy/timesheets/sheets/'
8
+
9
+ export const locationByType = {
10
+ [TimesheetsSchema.timesheet]: timesheets,
11
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/timesheets/actions/open-new',
3
+ access: 'workspace',
4
+ label: 'timesheets.new',
5
+ }
@@ -0,0 +1,34 @@
1
+ import { buildPrefilledLines, monthPeriodBounds } from './build-prefilled-lines.js'
2
+
3
+ /**
4
+ * Demo timesheet for the sales hero — current month, locale-aware holidays,
5
+ * with a couple of workdays nudged so it looks reviewed.
6
+ *
7
+ * @param {string | null | undefined} language UI language / BCP 47 tag
8
+ * @returns {{ periodStart: number, periodEnd: number, lines: Array<{ date: number, hours: number, isWorkday: boolean }> }}
9
+ */
10
+ export function sampleShowcaseTimesheet (language) {
11
+ const now = new Date()
12
+ const { periodStart, periodEnd } = monthPeriodBounds(now.getFullYear(), now.getMonth())
13
+ const lines = buildPrefilledLines(periodStart, periodEnd, language)
14
+
15
+ const workdayIndexes = []
16
+ for (let i = 0; i < lines.length; i++) {
17
+ if (lines[i].isWorkday) workdayIndexes.push(i)
18
+ }
19
+
20
+ // Nudge mid-month workdays so the preview looks adjusted, not blank-perfect.
21
+ const tweakTargets = [
22
+ { offset: Math.floor(workdayIndexes.length * 0.35), hours: 6 },
23
+ { offset: Math.floor(workdayIndexes.length * 0.55), hours: 4 },
24
+ ]
25
+
26
+ for (const { offset, hours } of tweakTargets) {
27
+ const idx = workdayIndexes[offset]
28
+ if (idx != null) {
29
+ lines[idx] = { ...lines[idx], hours }
30
+ }
31
+ }
32
+
33
+ return { periodStart, periodEnd, lines }
34
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/timesheets/actions/save',
3
+ access: 'workspace',
4
+ label: 'timesheets.save',
5
+ }
@@ -0,0 +1,62 @@
1
+ import { ResourcesEvents, mutateResource, viewResource } from '@ossy/resources/server'
2
+ import { TimesheetsSchema } from './schema-ids.js'
3
+ import { toTimesheetDto } from './timesheet-resources.js'
4
+
5
+ export const metadata = { id: '@ossy/timesheets/tasks/save' }
6
+
7
+ /**
8
+ * Persist lines and set status to `saved`.
9
+ * Payload: `{ timesheetId, lines }`
10
+ */
11
+ export async function run ({ payload, req, log }) {
12
+ const workspaceId = req?.workspaceId
13
+
14
+ if (!workspaceId) {
15
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
16
+ }
17
+
18
+ const { timesheetId, lines } = payload ?? {}
19
+
20
+ if (!timesheetId) {
21
+ throw Object.assign(new Error('timesheetId is required'), { status: 400 })
22
+ }
23
+
24
+ if (!Array.isArray(lines)) {
25
+ throw Object.assign(new Error('lines must be an array'), { status: 400 })
26
+ }
27
+
28
+ const resource = await viewResource(timesheetId)
29
+
30
+ if (!resource || resource.type !== TimesheetsSchema.timesheet) {
31
+ throw Object.assign(new Error('timesheet not found'), { status: 404 })
32
+ }
33
+
34
+ if (resource.belongsTo && resource.belongsTo !== workspaceId) {
35
+ throw Object.assign(new Error('timesheet not found'), { status: 404 })
36
+ }
37
+
38
+ const normalizedLines = lines.map((line) => ({
39
+ date: Number(line.date),
40
+ hours: Number(line.hours) || 0,
41
+ isWorkday: Boolean(line.isWorkday),
42
+ }))
43
+
44
+ const content = {
45
+ periodStart: resource.content?.periodStart,
46
+ periodEnd: resource.content?.periodEnd,
47
+ status: 'saved',
48
+ lines: normalizedLines,
49
+ }
50
+
51
+ log?.info(`[timesheets/tasks/save] Saving timesheet ${timesheetId}`)
52
+
53
+ await mutateResource(
54
+ timesheetId,
55
+ ResourcesEvents.Patched({
56
+ createdBy: req?.userId ?? 'system',
57
+ content,
58
+ }),
59
+ )
60
+
61
+ return toTimesheetDto({ ...resource, id: timesheetId, content })
62
+ }
@@ -0,0 +1,4 @@
1
+ /** Canonical schema ids for @ossy/timesheets (ADR 0006). */
2
+ export const TimesheetsSchema = Object.freeze({
3
+ timesheet: '@ossy/timesheets/schema/timesheet',
4
+ })
@@ -1,5 +1,73 @@
1
1
  {
2
2
  "timesheets/home.documentTitle": "Tidrapportering",
3
+ "timesheets/detail.documentTitle": "Tidrapport",
3
4
  "timesheets.home.title": "Tidrapportering",
4
- "timesheets.home.description": "Hantera tidrapporter för ditt team. Skapa nya tidrapporter, redigera befintliga och ta bort tidrapporter du inte längre behöver."
5
+ "timesheets.nav.title": "Tidrapportering",
6
+ "timesheets.home.description": "Dina månatliga tidrapporter. Skapa en, granska timmar i kalendern, spara och exportera.",
7
+ "timesheets.catalog.pitch": "Testa gratis — justera och exportera månadstimmar; skapa konto för att spara",
8
+ "timesheets.home.cover.title": "Månadsvisa tidrapporter som är förifyllda",
9
+ "timesheets.home.cover.text": "Generera en månad med timmar, justera det som skiljer sig, spara och exportera som CSV, bild eller PDF.",
10
+ "timesheets.home.cover.ctaPrimary": "Kom igång gratis",
11
+ "timesheets.home.cover.ctaSecondary": "Testa gratis",
12
+ "timesheets.freeTool.sectionTitle": "Testa gratis",
13
+ "timesheets.freeTool.sectionDescription": "Justera den här månadens timmar och exportera som CSV, bild eller PDF. Inget sparas förrän du skapar konto.",
14
+ "timesheets.freeTool.saveCta": "Skapa konto för att spara",
15
+ "timesheets.freeTool.saveHint": "Ändringar stannar i den här webbläsaren tills du laddar om. Exportera när du vill — spara kräver konto.",
16
+ "timesheets.freeTool.saveHintAuthenticated": "Exportera när du vill. Aktivera tidrapportering i workspace för att spara månader permanent.",
17
+ "timesheets.freeTool.signInLink": "Har du redan konto? Logga in",
18
+ "timesheets.home.features.prefill.title": "Förifyll månaden",
19
+ "timesheets.home.features.prefill.text": "Arbetsdagar börjar på 8 timmar. Helger och röda dagar nollas och markeras — ingen tom kalkylarksmall.",
20
+ "timesheets.home.features.review.title": "Granska i kalender",
21
+ "timesheets.home.features.review.text": "Redigera timmar dag för dag på ett månadskort. Ändra bara det som skiljer sig från standard.",
22
+ "timesheets.home.features.export.title": "Exportera när du är klar",
23
+ "timesheets.home.features.export.text": "Ladda ner CSV för kalkylark, eller PNG och PDF som matchar kalenderkortet.",
24
+ "timesheets.home.features.calendar.title": "Kalenderanpassad",
25
+ "timesheets.home.features.calendar.text": "Arbetsdagar och helgdagar räknas ut utifrån din region — helger nollas som standard.",
26
+ "timesheets.sales.overview": "Översikt",
27
+ "timesheets.sales.features": "Funktioner",
28
+ "timesheets.sales.enable": "Aktivera tidrapportering",
29
+ "timesheets.sales.enableDescription": "Aktivera tidrapportering för ditt workspace så du kan generera, spara och exportera månatlig arbetstid.",
30
+ "timesheets.sales.enableError": "Kunde inte aktivera tidrapportering. Försök igen eller kontakta support.",
31
+ "timesheets.detail.title": "Tidrapport",
32
+ "timesheets.detail.description": "Granska och justera timmar, spara eller exportera.",
33
+ "timesheets.new": "Ny",
34
+ "timesheets.createTitle": "Ny tidrapport",
35
+ "timesheets.createDescription": "Välj en månad för att generera en förifylld tidrapport.",
36
+ "timesheets.period": "Period",
37
+ "timesheets.generate": "Generera",
38
+ "timesheets.openExisting": "Öppna",
39
+ "timesheets.periodExistsHint": "Det finns redan en tidrapport för perioden — Öppna laddar den.",
40
+ "timesheets.cancel": "Avbryt",
41
+ "timesheets.save": "Spara",
42
+ "timesheets.saving": "Sparar…",
43
+ "timesheets.loading": "Laddar…",
44
+ "timesheets.export": "Exportera",
45
+ "timesheets.exporting": "Exporterar…",
46
+ "timesheets.exportCsv": "CSV",
47
+ "timesheets.exportPng": "Bild (PNG)",
48
+ "timesheets.exportPdf": "PDF",
49
+ "timesheets.hours": "Timmar",
50
+ "timesheets.empty": "Inga dagar i den här tidrapporten.",
51
+ "timesheets.listTitle": "Dina tidrapporter",
52
+ "timesheets.listEmpty": "Inga tidrapporter ännu. Klicka på Ny för att generera en för en månad.",
53
+ "timesheets.open": "Öppna",
54
+ "timesheets.backToList": "← Alla tidrapporter",
55
+ "timesheets.saved": "Tidrapport sparad.",
56
+ "timesheets.unsaved": "Osparade ändringar",
57
+ "timesheets.statusLabel": "Status: {status}",
58
+ "timesheets.status.draft": "Utkast",
59
+ "timesheets.status.saved": "Sparad",
60
+ "timesheets.totalHours": "Totalt: {hours} h",
61
+ "timesheets.weekday.mon": "Mån",
62
+ "timesheets.weekday.tue": "Tis",
63
+ "timesheets.weekday.wed": "Ons",
64
+ "timesheets.weekday.thu": "Tor",
65
+ "timesheets.weekday.fri": "Fre",
66
+ "timesheets.weekday.sat": "Lör",
67
+ "timesheets.weekday.sun": "Sön",
68
+ "timesheets.errorLoad": "Kunde inte ladda tidrapporten.",
69
+ "timesheets.errorNotFound": "Tidrapporten hittades inte.",
70
+ "timesheets.errorGenerate": "Kunde inte generera tidrapporten.",
71
+ "timesheets.errorSave": "Kunde inte spara tidrapporten.",
72
+ "timesheets.errorExport": "Kunde inte exportera tidrapporten."
5
73
  }