@ossy/timesheets 3.0.6 → 3.0.8

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,229 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import { Text, View, Button, Page, useLocale, Tags, Alert, Select, List, Overlay } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { useSdk } from '@ossy/sdk-react'
5
+ import { Definition } from './Definition.js'
6
+ import { TimesheetCard } from './TimesheetCard.jsx'
7
+ import { monthPeriodBounds } from './build-prefilled-lines.js'
8
+ import { invokeErrorMessage } from './invoke-error-message.js'
9
+ import { metadata as ListTimesheets } from './list.action.js'
10
+ import { metadata as GenerateTimesheet } from './generate.action.js'
11
+ import { metadata as OpenNewTimesheet } from './open-new.action.js'
12
+
13
+ function buildMonthOptions (language, yearsBack = 1, yearsForward = 0) {
14
+ let locale = 'en-GB'
15
+ try {
16
+ locale = new Intl.Locale(language || 'en').toString()
17
+ } catch {
18
+ locale = language === 'sv' ? 'sv-SE' : 'en-GB'
19
+ }
20
+ const now = new Date()
21
+ const options = []
22
+
23
+ for (let y = now.getFullYear() - yearsBack; y <= now.getFullYear() + yearsForward; y++) {
24
+ for (let m = 0; m < 12; m++) {
25
+ const label = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
26
+ .format(new Date(y, m, 1))
27
+ options.push({
28
+ value: `${y}-${m}`,
29
+ label: label.charAt(0).toUpperCase() + label.slice(1),
30
+ year: y,
31
+ monthIndex: m,
32
+ })
33
+ }
34
+ }
35
+
36
+ return options
37
+ }
38
+
39
+ export default function TimesheetsProductHome () {
40
+ const { t, language } = useLocale()
41
+ const sdk = useSdk()
42
+ const router = useRouter()
43
+
44
+ const monthOptions = useMemo(() => buildMonthOptions(language), [language])
45
+ const [periodKey, setPeriodKey] = useState(() => {
46
+ const n = new Date()
47
+ return `${n.getFullYear()}-${n.getMonth()}`
48
+ })
49
+ const [creating, setCreating] = useState(false)
50
+ const [sheets, setSheets] = useState([])
51
+ const [loading, setLoading] = useState(true)
52
+ const [generating, setGenerating] = useState(false)
53
+ const [error, setError] = useState(null)
54
+ const [createError, setCreateError] = useState(null)
55
+
56
+ const selected = useMemo(
57
+ () => monthOptions.find((o) => o.value === periodKey) ?? monthOptions[0],
58
+ [monthOptions, periodKey],
59
+ )
60
+
61
+ const existingPeriodStarts = useMemo(
62
+ () => new Set(sheets.map((s) => s.periodStart)),
63
+ [sheets],
64
+ )
65
+
66
+ const statusTags = (Definition.status ?? [])
67
+ .map((s) => ({ beta: 'Beta', 'coming-soon': 'Coming Soon' }[s]))
68
+ .filter(Boolean)
69
+
70
+ const loadList = useCallback(async () => {
71
+ setLoading(true)
72
+ setError(null)
73
+ try {
74
+ const data = await sdk.invoke(ListTimesheets, {})
75
+ setSheets(Array.isArray(data) ? data : [])
76
+ } catch (err) {
77
+ setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
78
+ setSheets([])
79
+ } finally {
80
+ setLoading(false)
81
+ }
82
+ }, [sdk, t])
83
+
84
+ useEffect(() => {
85
+ loadList()
86
+ }, [loadList])
87
+
88
+ const openDetail = (timesheetId) => {
89
+ const href = router.getHref({
90
+ id: 'timesheets/detail',
91
+ params: { timesheetId },
92
+ })
93
+ if (href) window.location.href = href
94
+ }
95
+
96
+ const closeCreateModal = () => {
97
+ if (generating) return
98
+ setCreating(false)
99
+ setCreateError(null)
100
+ }
101
+
102
+ const openCreateModal = () => {
103
+ setCreating(true)
104
+ setCreateError(null)
105
+ const n = new Date()
106
+ setPeriodKey(`${n.getFullYear()}-${n.getMonth()}`)
107
+ }
108
+
109
+ const handleGenerate = async () => {
110
+ if (!selected) return
111
+ setGenerating(true)
112
+ setCreateError(null)
113
+ try {
114
+ const data = await sdk.invoke(GenerateTimesheet, {
115
+ year: selected.year,
116
+ monthIndex: selected.monthIndex,
117
+ locale: language,
118
+ })
119
+ if (data?.id) {
120
+ openDetail(data.id)
121
+ return
122
+ }
123
+ await loadList()
124
+ setCreating(false)
125
+ } catch (err) {
126
+ setCreateError(await invokeErrorMessage(err, t('timesheets.errorGenerate')))
127
+ } finally {
128
+ setGenerating(false)
129
+ }
130
+ }
131
+
132
+ const periodAlreadyExists = selected
133
+ && existingPeriodStarts.has(monthPeriodBounds(selected.year, selected.monthIndex).periodStart)
134
+
135
+ return (
136
+ <Page
137
+ title="timesheets.home.title"
138
+ near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
139
+ description="timesheets.home.description"
140
+ far={(
141
+ <Button
142
+ id={OpenNewTimesheet.id}
143
+ variant="cta"
144
+ prefix="add"
145
+ label="timesheets.new"
146
+ onClick={openCreateModal}
147
+ />
148
+ )}
149
+ >
150
+ <View gap="m">
151
+ {error && <Alert variant="danger">{error}</Alert>}
152
+
153
+ <View gap="s">
154
+ <Text variant="heading-secondary" as="h2" text="timesheets.listTitle" />
155
+ {loading ? (
156
+ <Text color="secondary" text="timesheets.loading" />
157
+ ) : sheets.length === 0 ? (
158
+ <Text color="secondary" text="timesheets.listEmpty" />
159
+ ) : (
160
+ <View gap="s">
161
+ <List>
162
+ {sheets.map((sheet) => (
163
+ <TimesheetCard
164
+ key={sheet.id}
165
+ timesheet={sheet}
166
+ onClick={() => openDetail(sheet.id)}
167
+ />
168
+ ))}
169
+ </List>
170
+ </View>
171
+ )}
172
+ </View>
173
+ </View>
174
+
175
+ <Overlay isVisible={creating} onClose={closeCreateModal}>
176
+ <View layout="off-center-m" style={{ height: '100%' }}>
177
+ <View data-region="content" style={{ width: 'min(28rem, calc(100vw - 2rem))' }}>
178
+ <View surface="primary" roundness="m" inset="l" gap="m">
179
+ <View gap="xs">
180
+ <Text variant="heading-tertiary" as="h3" text="timesheets.createTitle" />
181
+ <Text color="secondary" text="timesheets.createDescription" />
182
+ </View>
183
+
184
+ {createError && <Alert variant="danger">{createError}</Alert>}
185
+
186
+ <View gap="xs">
187
+ <Text variant="small" weight="medium" text="timesheets.period" />
188
+ <Select
189
+ value={periodKey}
190
+ onChange={(e) => setPeriodKey(e.target.value)}
191
+ disabled={generating}
192
+ >
193
+ {monthOptions.map((o) => (
194
+ <option key={o.value} value={o.value}>{o.label}</option>
195
+ ))}
196
+ </Select>
197
+ </View>
198
+
199
+ {periodAlreadyExists && (
200
+ <Text variant="small" color="secondary" text="timesheets.periodExistsHint" />
201
+ )}
202
+
203
+ <View layout="row" gap="s" justifyContent="flex-end" style={{ flexWrap: 'wrap' }}>
204
+ <Button
205
+ variant="neutral"
206
+ disabled={generating}
207
+ label="timesheets.cancel"
208
+ onClick={closeCreateModal}
209
+ />
210
+ <Button
211
+ id={GenerateTimesheet.id}
212
+ variant="cta"
213
+ disabled={generating || !selected}
214
+ onClick={handleGenerate}
215
+ >
216
+ {generating
217
+ ? t('timesheets.loading')
218
+ : periodAlreadyExists
219
+ ? t('timesheets.openExisting')
220
+ : t('timesheets.generate')}
221
+ </Button>
222
+ </View>
223
+ </View>
224
+ </View>
225
+ </View>
226
+ </Overlay>
227
+ </Page>
228
+ )
229
+ }
@@ -0,0 +1,50 @@
1
+ import React, { useState, useCallback } from 'react'
2
+ import { EnableService } from '@ossy/workspaces'
3
+ import { View, useLocale } from '@ossy/design-system'
4
+ import SalesSection from './SalesSection.jsx'
5
+ import { TimesheetsFreeTool } from './TimesheetsFreeTool.jsx'
6
+ import { useTimesheetsHomeContent } from './timesheets-home-content.js'
7
+ import { TIMESHEETS_SERVICE } from './timesheets-auth-return.js'
8
+
9
+ /**
10
+ * Public sales surface. Anonymous → sign up; signed-in but not entitled → enable.
11
+ */
12
+ export default function TimesheetsSalesPage ({ isAuthenticated, onEnable }) {
13
+ const { t } = useLocale()
14
+ const [enableError, setEnableError] = useState(null)
15
+ const { cover, features } = useTimesheetsHomeContent()
16
+
17
+ const handleEnable = useCallback(() => {
18
+ if (typeof onEnable !== 'function') return Promise.resolve()
19
+ setEnableError(null)
20
+ return onEnable().catch(() => {
21
+ setEnableError(t('timesheets.sales.enableError'))
22
+ })
23
+ }, [onEnable, t])
24
+
25
+ const enableAction = {
26
+ ...EnableService,
27
+ 'data-service': TIMESHEETS_SERVICE,
28
+ variant: 'cta',
29
+ label: 'timesheets.sales.enable',
30
+ onClick: handleEnable,
31
+ }
32
+
33
+ return (
34
+ <View data-timesheets-status="off">
35
+ <SalesSection
36
+ features={features}
37
+ tool={(
38
+ <TimesheetsFreeTool
39
+ title={cover.title}
40
+ text={cover.text}
41
+ isAuthenticated={!!isAuthenticated}
42
+ onEnable={handleEnable}
43
+ enableAction={enableAction}
44
+ enableError={enableError}
45
+ />
46
+ )}
47
+ />
48
+ </View>
49
+ )
50
+ }
@@ -0,0 +1,60 @@
1
+ import { CalendarClient } from '@ossy/calendar'
2
+ import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
3
+
4
+ /** Prefill hours for a workday (SPEC). */
5
+ export const WORKDAY_HOURS = 8
6
+
7
+ /**
8
+ * Calendar-month period bounds as local midnights, returned as Unix ms.
9
+ * @param {number} year
10
+ * @param {number} monthIndex 0–11
11
+ */
12
+ export function monthPeriodBounds (year, monthIndex) {
13
+ const start = new Date(year, monthIndex, 1)
14
+ const end = new Date(year, monthIndex + 1, 0)
15
+ start.setHours(0, 0, 0, 0)
16
+ end.setHours(0, 0, 0, 0)
17
+ return {
18
+ periodStart: start.getTime(),
19
+ periodEnd: end.getTime(),
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Build prefilled timesheet lines for an inclusive period.
25
+ * Workdays → 8h; non-workdays → 0h. `isWorkday` is snapshotted.
26
+ *
27
+ * @param {number} periodStartMs
28
+ * @param {number} periodEndMs
29
+ * @param {string} [locale] ISO country code or BCP 47 tag (resolved via holiday-locale)
30
+ */
31
+ export function buildPrefilledLines (periodStartMs, periodEndMs, locale) {
32
+ const holidayLocale = resolveHolidayLocale(locale, DEFAULT_HOLIDAY_LOCALE)
33
+ const days = CalendarClient.getAllDaysInDateSpan(
34
+ new Date(periodStartMs),
35
+ new Date(periodEndMs),
36
+ )
37
+
38
+ return days.map((day) => {
39
+ const date = new Date(day)
40
+ date.setHours(0, 0, 0, 0)
41
+ const isWorkday = CalendarClient.isWorkDay(date, holidayLocale)
42
+ return {
43
+ date: date.getTime(),
44
+ hours: isWorkday ? WORKDAY_HOURS : 0,
45
+ isWorkday,
46
+ }
47
+ })
48
+ }
49
+
50
+ /**
51
+ * @param {number} ms
52
+ * @returns {string} YYYY-MM-DD in local time
53
+ */
54
+ export function formatDateKey (ms) {
55
+ const d = new Date(ms)
56
+ const y = d.getFullYear()
57
+ const m = String(d.getMonth() + 1).padStart(2, '0')
58
+ const day = String(d.getDate()).padStart(2, '0')
59
+ return `${y}-${m}-${day}`
60
+ }
@@ -0,0 +1,35 @@
1
+ import { formatDateKey } from './build-prefilled-lines.js'
2
+
3
+ /**
4
+ * Build a CSV string for a timesheet and trigger a browser download.
5
+ * @param {{ periodStart?: number, periodEnd?: number, status?: string, lines?: Array<{ date: number, hours: number }> }} timesheet
6
+ * @param {string} [filename]
7
+ */
8
+ export function downloadTimesheetCsv (timesheet, filename) {
9
+ const lines = timesheet?.lines ?? []
10
+ const headerRows = []
11
+
12
+ if (timesheet?.periodStart != null && timesheet?.periodEnd != null) {
13
+ headerRows.push(`# period,${formatDateKey(timesheet.periodStart)},${formatDateKey(timesheet.periodEnd)}`)
14
+ headerRows.push(`# status,${timesheet.status ?? ''}`)
15
+ }
16
+
17
+ const rows = [
18
+ ...headerRows,
19
+ 'date,hours',
20
+ ...lines.map((line) =>
21
+ [formatDateKey(line.date), line.hours ?? 0].join(','),
22
+ ),
23
+ ]
24
+
25
+ const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' })
26
+ const url = URL.createObjectURL(blob)
27
+ const a = document.createElement('a')
28
+ a.href = url
29
+ a.download = filename
30
+ ?? `timesheet-${formatDateKey(timesheet?.periodStart ?? Date.now())}.csv`
31
+ document.body.appendChild(a)
32
+ a.click()
33
+ a.remove()
34
+ URL.revokeObjectURL(url)
35
+ }
@@ -0,0 +1,15 @@
1
+ import { captureNodeAsPng, timesheetExportFilename, triggerDownload } from './export-capture.js'
2
+
3
+ /**
4
+ * Capture the timesheet card DOM and download as PNG.
5
+ * @param {HTMLElement} node
6
+ * @param {{ periodStart?: number }} [timesheet]
7
+ * @param {string} [filename]
8
+ */
9
+ export async function downloadTimesheetImage (node, timesheet = {}, filename) {
10
+ const dataUrl = await captureNodeAsPng(node)
11
+ triggerDownload(
12
+ dataUrl,
13
+ filename ?? timesheetExportFilename(timesheet.periodStart, 'png'),
14
+ )
15
+ }
@@ -0,0 +1,49 @@
1
+ import { PDFDocument } from 'pdf-lib'
2
+ import { captureNodeAsPng, timesheetExportFilename, triggerDownload } from './export-capture.js'
3
+
4
+ /**
5
+ * Capture the timesheet card DOM and download as a single-page PDF.
6
+ * @param {HTMLElement} node
7
+ * @param {{ periodStart?: number }} [timesheet]
8
+ * @param {string} [filename]
9
+ */
10
+ export async function downloadTimesheetPdf (node, timesheet = {}, filename) {
11
+ const dataUrl = await captureNodeAsPng(node)
12
+ const pngBytes = dataUrlToUint8Array(dataUrl)
13
+
14
+ const pdf = await PDFDocument.create()
15
+ const image = await pdf.embedPng(pngBytes)
16
+
17
+ const pageWidth = 595.28 // A4 pt
18
+ const pageHeight = 841.89
19
+ const margin = 36
20
+ const maxWidth = pageWidth - margin * 2
21
+ const maxHeight = pageHeight - margin * 2
22
+ const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1)
23
+ const drawWidth = image.width * scale
24
+ const drawHeight = image.height * scale
25
+
26
+ const page = pdf.addPage([pageWidth, pageHeight])
27
+ page.drawImage(image, {
28
+ x: (pageWidth - drawWidth) / 2,
29
+ y: pageHeight - margin - drawHeight,
30
+ width: drawWidth,
31
+ height: drawHeight,
32
+ })
33
+
34
+ const pdfBytes = await pdf.save()
35
+ const blob = new Blob([pdfBytes], { type: 'application/pdf' })
36
+ triggerDownload(
37
+ blob,
38
+ filename ?? timesheetExportFilename(timesheet.periodStart, 'pdf'),
39
+ )
40
+ }
41
+
42
+ function dataUrlToUint8Array (dataUrl) {
43
+ const base64 = dataUrl.split(',')[1]
44
+ if (!base64) throw new Error('Invalid image data')
45
+ const binary = atob(base64)
46
+ const bytes = new Uint8Array(binary.length)
47
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
48
+ return bytes
49
+ }
@@ -1,5 +1,73 @@
1
1
  {
2
2
  "timesheets/home.documentTitle": "Timesheets",
3
+ "timesheets/detail.documentTitle": "Timesheet",
3
4
  "timesheets.home.title": "Timesheets",
4
- "timesheets.home.description": "Manage timesheets for your team. You can create new timesheets, edit existing ones, and delete timesheets that you no longer need."
5
+ "timesheets.nav.title": "Timesheets",
6
+ "timesheets.home.description": "Your monthly timesheets. Create one, review hours on the calendar, save, and export.",
7
+ "timesheets.catalog.pitch": "Try free — edit and export monthly hours; sign up to save",
8
+ "timesheets.home.cover.title": "Monthly timesheets that are pre filled",
9
+ "timesheets.home.cover.text": "Generate a month of hours, adjust what differs, then save and export as CSV, image, or PDF.",
10
+ "timesheets.home.cover.ctaPrimary": "Get started free",
11
+ "timesheets.home.cover.ctaSecondary": "Try it free",
12
+ "timesheets.freeTool.sectionTitle": "Try it free",
13
+ "timesheets.freeTool.sectionDescription": "Edit this month’s hours and export as CSV, image, or PDF. Nothing is saved until you sign up.",
14
+ "timesheets.freeTool.saveCta": "Sign up to save",
15
+ "timesheets.freeTool.saveHint": "Edits stay in this browser until you refresh. Export anytime — save needs an account.",
16
+ "timesheets.freeTool.saveHintAuthenticated": "Export anytime. Enable timesheets in your workspace to save months permanently.",
17
+ "timesheets.freeTool.signInLink": "Already have an account? Sign in",
18
+ "timesheets.home.features.prefill.title": "Prefill the month",
19
+ "timesheets.home.features.prefill.text": "Workdays start at 8 hours. Weekends and holidays are zeroed and marked — no blank spreadsheet.",
20
+ "timesheets.home.features.review.title": "Review on a calendar",
21
+ "timesheets.home.features.review.text": "Edit hours day by day on a month card. Change only what differs from the default.",
22
+ "timesheets.home.features.export.title": "Export when ready",
23
+ "timesheets.home.features.export.text": "Download CSV for spreadsheets, or PNG and PDF that match the calendar card.",
24
+ "timesheets.home.features.calendar.title": "Calendar-aware",
25
+ "timesheets.home.features.calendar.text": "Workdays and public holidays are detected for your locale — weekends stay clear by default.",
26
+ "timesheets.sales.overview": "Overview",
27
+ "timesheets.sales.features": "Features",
28
+ "timesheets.sales.enable": "Enable timesheets",
29
+ "timesheets.sales.enableDescription": "Activate timesheets for your workspace to generate, save, and export monthly work hours.",
30
+ "timesheets.sales.enableError": "Could not enable timesheets. Try again or contact support.",
31
+ "timesheets.detail.title": "Timesheet",
32
+ "timesheets.detail.description": "Review and adjust hours, then save or export.",
33
+ "timesheets.new": "New",
34
+ "timesheets.createTitle": "New timesheet",
35
+ "timesheets.createDescription": "Choose a month to generate a prefilled timesheet.",
36
+ "timesheets.period": "Period",
37
+ "timesheets.generate": "Generate",
38
+ "timesheets.openExisting": "Open",
39
+ "timesheets.periodExistsHint": "A timesheet already exists for this period — Open loads it.",
40
+ "timesheets.cancel": "Cancel",
41
+ "timesheets.save": "Save",
42
+ "timesheets.saving": "Saving…",
43
+ "timesheets.loading": "Loading…",
44
+ "timesheets.export": "Export",
45
+ "timesheets.exporting": "Exporting…",
46
+ "timesheets.exportCsv": "CSV",
47
+ "timesheets.exportPng": "Image (PNG)",
48
+ "timesheets.exportPdf": "PDF",
49
+ "timesheets.hours": "Hours",
50
+ "timesheets.empty": "No days in this timesheet.",
51
+ "timesheets.listTitle": "Your timesheets",
52
+ "timesheets.listEmpty": "No timesheets yet. Click New to generate one for a month.",
53
+ "timesheets.open": "Open",
54
+ "timesheets.backToList": "← All timesheets",
55
+ "timesheets.saved": "Timesheet saved.",
56
+ "timesheets.unsaved": "Unsaved changes",
57
+ "timesheets.statusLabel": "Status: {status}",
58
+ "timesheets.status.draft": "Draft",
59
+ "timesheets.status.saved": "Saved",
60
+ "timesheets.totalHours": "Total: {hours} h",
61
+ "timesheets.weekday.mon": "Mon",
62
+ "timesheets.weekday.tue": "Tue",
63
+ "timesheets.weekday.wed": "Wed",
64
+ "timesheets.weekday.thu": "Thu",
65
+ "timesheets.weekday.fri": "Fri",
66
+ "timesheets.weekday.sat": "Sat",
67
+ "timesheets.weekday.sun": "Sun",
68
+ "timesheets.errorLoad": "Could not load timesheet.",
69
+ "timesheets.errorNotFound": "Timesheet not found.",
70
+ "timesheets.errorGenerate": "Could not generate timesheet.",
71
+ "timesheets.errorSave": "Could not save timesheet.",
72
+ "timesheets.errorExport": "Could not export timesheet."
5
73
  }
@@ -0,0 +1,106 @@
1
+ import { formatDateKey } from './build-prefilled-lines.js'
2
+
3
+ /** Export canvas — plain white so PNG/PDF match a clean screenshot, not a themed card. */
4
+ export const TIMESHEET_EXPORT_BASE_BG = '#ffffff'
5
+
6
+ /** Matches Cloud Light / theme token stack. */
7
+ export const TIMESHEET_EXPORT_FONT_FAMILY = 'Roboto, Helvetica, Arial, sans-serif'
8
+
9
+ const ROBOTO_STYLESHEET_HREF =
10
+ 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap'
11
+ const ROBOTO_LINK_ID = 'ossy-timesheet-export-fonts'
12
+
13
+ /**
14
+ * @param {number | undefined} periodStart
15
+ * @param {string} ext
16
+ */
17
+ export function timesheetExportFilename (periodStart, ext) {
18
+ return `timesheet-${formatDateKey(periodStart ?? Date.now())}.${ext}`
19
+ }
20
+
21
+ /**
22
+ * Trigger a browser download from a Blob or data URL.
23
+ * @param {Blob | string} data
24
+ * @param {string} filename
25
+ */
26
+ export function triggerDownload (data, filename) {
27
+ const url = typeof data === 'string' ? data : URL.createObjectURL(data)
28
+ const a = document.createElement('a')
29
+ a.href = url
30
+ a.download = filename
31
+ document.body.appendChild(a)
32
+ a.click()
33
+ a.remove()
34
+ if (typeof data !== 'string') URL.revokeObjectURL(url)
35
+ }
36
+
37
+ /**
38
+ * Theme tokens name Roboto but the app never loads it as a webfont.
39
+ * html-to-image clones into SVG foreignObject — without @font-face embeds,
40
+ * the PNG falls back to a different system font than the live page.
41
+ */
42
+ async function ensureExportFonts () {
43
+ if (typeof document === 'undefined') return
44
+
45
+ let link = document.getElementById(ROBOTO_LINK_ID)
46
+ if (!link) {
47
+ link = document.createElement('link')
48
+ link.id = ROBOTO_LINK_ID
49
+ link.rel = 'stylesheet'
50
+ link.href = ROBOTO_STYLESHEET_HREF
51
+ document.head.appendChild(link)
52
+ await new Promise((resolve) => {
53
+ link.onload = () => resolve()
54
+ link.onerror = () => resolve()
55
+ setTimeout(resolve, 4000)
56
+ })
57
+ }
58
+
59
+ if (!document.fonts?.load) return
60
+ try {
61
+ await Promise.all([
62
+ document.fonts.load(`400 16px Roboto`),
63
+ document.fonts.load(`500 16px Roboto`),
64
+ document.fonts.load(`600 16px Roboto`),
65
+ document.fonts.load(`700 16px Roboto`),
66
+ ])
67
+ await document.fonts.ready
68
+ } catch {
69
+ // Export still proceeds with system fallbacks.
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Capture a DOM node as a PNG data URL (high-DPI).
75
+ * Client-side snapshot via html-to-image (not a server render).
76
+ * Always uses Cloud Light base background.
77
+ * @param {HTMLElement} node
78
+ * @returns {Promise<string>}
79
+ */
80
+ export async function captureNodeAsPng (node) {
81
+ if (!node) throw new Error('Nothing to capture')
82
+
83
+ await ensureExportFonts()
84
+
85
+ const { toPng, getFontEmbedCSS } = await import('html-to-image')
86
+
87
+ let fontEmbedCSS
88
+ try {
89
+ fontEmbedCSS = await getFontEmbedCSS(node)
90
+ } catch {
91
+ fontEmbedCSS = undefined
92
+ }
93
+
94
+ return toPng(node, {
95
+ cacheBust: true,
96
+ pixelRatio: 2,
97
+ backgroundColor: TIMESHEET_EXPORT_BASE_BG,
98
+ preferredFontFormat: 'woff2',
99
+ ...(fontEmbedCSS ? { fontEmbedCSS } : {}),
100
+ // Off-screen export card uses translateX(-200vw); include full layout box.
101
+ style: {
102
+ transform: 'none',
103
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
104
+ },
105
+ })
106
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/timesheets/actions/generate',
3
+ access: 'workspace',
4
+ label: 'timesheets.generate',
5
+ }