signalk-maintenance-tracker 1.0.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 (105) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +201 -0
  3. package/README.md +75 -0
  4. package/dist/api/router.js +193 -0
  5. package/dist/api/router.js.map +1 -0
  6. package/dist/auth.js +22 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/config.js +50 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/db/database.js +47 -0
  11. package/dist/db/database.js.map +1 -0
  12. package/dist/db/logs.repo.js +91 -0
  13. package/dist/db/logs.repo.js.map +1 -0
  14. package/dist/db/migrations.js +57 -0
  15. package/dist/db/migrations.js.map +1 -0
  16. package/dist/db/tags.repo.js +78 -0
  17. package/dist/db/tags.repo.js.map +1 -0
  18. package/dist/db/tasks.repo.js +78 -0
  19. package/dist/db/tasks.repo.js.map +1 -0
  20. package/dist/domain/slug.js +27 -0
  21. package/dist/domain/slug.js.map +1 -0
  22. package/dist/domain/status.js +110 -0
  23. package/dist/domain/status.js.map +1 -0
  24. package/dist/index.js +130 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/service.js +370 -0
  27. package/dist/service.js.map +1 -0
  28. package/dist/signalk/notifications.js +115 -0
  29. package/dist/signalk/notifications.js.map +1 -0
  30. package/dist/signalk/runtime.js +103 -0
  31. package/dist/signalk/runtime.js.map +1 -0
  32. package/dist/types.js +11 -0
  33. package/dist/types.js.map +1 -0
  34. package/docs/screenshots/completed.png +0 -0
  35. package/docs/screenshots/dark-mode.png +0 -0
  36. package/docs/screenshots/edit-task.png +0 -0
  37. package/docs/screenshots/homepage.png +0 -0
  38. package/docs/screenshots/main-log.png +0 -0
  39. package/docs/screenshots/task-detail.png +0 -0
  40. package/package.json +82 -0
  41. package/public/app/api/client.js +75 -0
  42. package/public/app/api/hooks.js +159 -0
  43. package/public/app/api/resource.js +156 -0
  44. package/public/app/api/signalkPaths.js +66 -0
  45. package/public/app/app.js +71 -0
  46. package/public/app/auth/auth.js +158 -0
  47. package/public/app/components/AuthControl.js +22 -0
  48. package/public/app/components/ConfirmModal.js +55 -0
  49. package/public/app/components/LogEntryModal.js +150 -0
  50. package/public/app/components/LoginModal.js +86 -0
  51. package/public/app/components/MarkdownView.js +17 -0
  52. package/public/app/components/Modal.js +89 -0
  53. package/public/app/components/Pagination.js +35 -0
  54. package/public/app/components/PathPicker.js +66 -0
  55. package/public/app/components/ProgressBar.js +26 -0
  56. package/public/app/components/StatusBadge.js +9 -0
  57. package/public/app/components/Table.js +99 -0
  58. package/public/app/components/TagInput.js +93 -0
  59. package/public/app/components/TaskFormModal.js +310 -0
  60. package/public/app/components/ThemeToggle.js +17 -0
  61. package/public/app/components/Toaster.js +24 -0
  62. package/public/app/lib/format.js +90 -0
  63. package/public/app/lib/html.js +6 -0
  64. package/public/app/lib/router.js +105 -0
  65. package/public/app/lib/sanitize.js +64 -0
  66. package/public/app/lib/slug.js +16 -0
  67. package/public/app/lib/theme.js +52 -0
  68. package/public/app/lib/toasts.js +33 -0
  69. package/public/app/lib/useListParams.js +28 -0
  70. package/public/app/main.js +20 -0
  71. package/public/app/pages/MasterLogPage.js +279 -0
  72. package/public/app/pages/TaskDetailPage.js +294 -0
  73. package/public/app/pages/TaskListPage.js +279 -0
  74. package/public/app/types.js +90 -0
  75. package/public/icons/apple-touch-icon.png +0 -0
  76. package/public/icons/favicon-16.png +0 -0
  77. package/public/icons/favicon-32.png +0 -0
  78. package/public/icons/favicon.ico +0 -0
  79. package/public/icons/icon-192.png +0 -0
  80. package/public/icons/icon-512.png +0 -0
  81. package/public/index.html +82 -0
  82. package/public/manifest.webmanifest +37 -0
  83. package/public/styles/main.css +787 -0
  84. package/public/styles/tokens.css +55 -0
  85. package/public/vendor/VERSIONS.json +9 -0
  86. package/public/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
  87. package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  88. package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  89. package/public/vendor/dayjs/constant.js +25 -0
  90. package/public/vendor/dayjs/index.d.ts +1 -0
  91. package/public/vendor/dayjs/index.js +541 -0
  92. package/public/vendor/dayjs/locale/en.js +12 -0
  93. package/public/vendor/dayjs/utils.js +58 -0
  94. package/public/vendor/htm.d.ts +1 -0
  95. package/public/vendor/htm.js +1 -0
  96. package/public/vendor/preact-hooks.d.ts +1 -0
  97. package/public/vendor/preact-hooks.js +2 -0
  98. package/public/vendor/preact.d.ts +1 -0
  99. package/public/vendor/preact.js +2 -0
  100. package/public/vendor/signals-core.d.ts +1 -0
  101. package/public/vendor/signals-core.js +1 -0
  102. package/public/vendor/signals.d.ts +1 -0
  103. package/public/vendor/signals.js +1 -0
  104. package/public/vendor/snarkdown.d.ts +1 -0
  105. package/public/vendor/snarkdown.js +2 -0
@@ -0,0 +1,26 @@
1
+ import { html } from '../lib/html.js';
2
+
3
+ /**
4
+ * CSS progress bar for runtime_fraction / time_fraction (§7.4).
5
+ * @param {{ fraction: number|null, status?: string|null }} props
6
+ */
7
+ export function ProgressBar(props) {
8
+ if (props.fraction === null || props.fraction === undefined) return null;
9
+ const pct = Math.round(Math.min(1, Math.max(0, props.fraction)) * 100);
10
+ const cls =
11
+ 'progress-fill' +
12
+ (props.status === 'overdue' || props.status === 'due_soon'
13
+ ? ' ' + props.status
14
+ : '');
15
+ return html`
16
+ <div
17
+ class="progress"
18
+ role="progressbar"
19
+ aria-valuenow=${pct}
20
+ aria-valuemin="0"
21
+ aria-valuemax="100"
22
+ >
23
+ <div class=${cls} style=${'width:' + pct + '%'} />
24
+ </div>
25
+ `;
26
+ }
@@ -0,0 +1,9 @@
1
+ import { html } from '../lib/html.js';
2
+ import { statusLabel } from '../lib/format.js';
3
+
4
+ /** @param {{ status: import('../types.js').Status }} props */
5
+ export function StatusBadge(props) {
6
+ return html`<span class=${'badge ' + props.status}
7
+ >${statusLabel(props.status)}</span
8
+ >`;
9
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Hand-rolled data table (§7.4): column definitions, header sorting,
3
+ * empty/loading states. Pagination is a sibling component.
4
+ */
5
+ import { html } from '../lib/html.js';
6
+
7
+ /**
8
+ * @typedef {Object} Column
9
+ * @property {string} key row field, and the `sort` value for sortable columns
10
+ * @property {string} label
11
+ * @property {boolean} [sortable]
12
+ * @property {string} [className]
13
+ * @property {(row: any) => any} [render]
14
+ */
15
+
16
+ /**
17
+ * @param {{
18
+ * columns: Column[],
19
+ * rows: any[],
20
+ * rowKey?: (row: any) => string|number,
21
+ * sort?: string,
22
+ * order?: string,
23
+ * onSort?: (key: string) => void,
24
+ * loading?: boolean,
25
+ * emptyMessage?: string,
26
+ * renderDetail?: (row: any) => any,
27
+ * }} props
28
+ */
29
+ export function Table(props) {
30
+ const rows = props.rows || [];
31
+ return html`
32
+ <div class="table-wrap">
33
+ <table class="table">
34
+ <thead>
35
+ <tr>
36
+ ${props.columns.map((col) => headerCell(col, props))}
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ ${rows.map((row) => {
41
+ const key = props.rowKey ? props.rowKey(row) : row.id;
42
+ const detail = props.renderDetail ? props.renderDetail(row) : null;
43
+ return html`
44
+ <tr key=${key} class=${detail ? 'has-detail' : ''}>
45
+ ${props.columns.map(
46
+ (col) =>
47
+ html`<td class=${col.className || ''}>
48
+ ${col.render ? col.render(row) : row[col.key]}
49
+ </td>`,
50
+ )}
51
+ </tr>
52
+ ${
53
+ detail
54
+ ? html`<tr key=${key + ':detail'} class="detail-row">
55
+ <td colspan=${props.columns.length}>${detail}</td>
56
+ </tr>`
57
+ : null
58
+ }
59
+ `;
60
+ })}
61
+ </tbody>
62
+ </table>
63
+ ${
64
+ rows.length === 0
65
+ ? html`<div class=${props.loading ? 'table-loading' : 'table-empty'}>
66
+ ${props.loading ? 'Loading…' : props.emptyMessage || 'Nothing here yet.'}
67
+ </div>`
68
+ : null
69
+ }
70
+ </div>
71
+ `;
72
+ }
73
+
74
+ /**
75
+ * @param {Column} col
76
+ * @param {{ sort?: string, order?: string, onSort?: (key: string) => void }} props
77
+ */
78
+ function headerCell(col, props) {
79
+ const sortable = !!col.sortable && !!props.onSort;
80
+ const active = sortable && props.sort === col.key;
81
+ const icon = active
82
+ ? html`<i
83
+ class=${'bi ' + (props.order === 'desc' ? 'bi-caret-down-fill' : 'bi-caret-up-fill')}
84
+ />`
85
+ : null;
86
+ const onClick =
87
+ sortable && props.onSort
88
+ ? () => props.onSort && props.onSort(col.key)
89
+ : undefined;
90
+ return html`
91
+ <th
92
+ class=${(col.className || '') + (sortable ? ' sortable' : '')}
93
+ onClick=${onClick}
94
+ aria-sort=${active ? (props.order === 'desc' ? 'descending' : 'ascending') : undefined}
95
+ >
96
+ ${col.label}${icon}
97
+ </th>
98
+ `;
99
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Creatable multi-select for task tags (§7.5): chips for the selected tags,
3
+ * a text input that adds on Enter/comma, and suggestions from GET /tags.
4
+ */
5
+ import { html } from '../lib/html.js';
6
+ import { useState } from '../../vendor/preact-hooks.js';
7
+
8
+ /**
9
+ * @param {{ value: string[], onChange: (tags: string[]) => void, suggestions: string[] }} props
10
+ */
11
+ export function TagInput(props) {
12
+ const [text, setText] = useState('');
13
+ const [open, setOpen] = useState(false);
14
+
15
+ const selected = props.value || [];
16
+ const lowerSelected = selected.map((t) => t.toLowerCase());
17
+ const matches = (props.suggestions || [])
18
+ .filter((s) => lowerSelected.indexOf(s.toLowerCase()) === -1)
19
+ .filter((s) => !text || s.toLowerCase().indexOf(text.toLowerCase()) !== -1)
20
+ .slice(0, 8);
21
+
22
+ /** @param {string} tag */
23
+ const add = (tag) => {
24
+ const trimmed = tag.trim();
25
+ if (!trimmed) return;
26
+ if (lowerSelected.indexOf(trimmed.toLowerCase()) !== -1) {
27
+ setText('');
28
+ return;
29
+ }
30
+ props.onChange(selected.concat([trimmed]));
31
+ setText('');
32
+ };
33
+
34
+ /** @param {string} tag */
35
+ const remove = (tag) => {
36
+ props.onChange(selected.filter((t) => t !== tag));
37
+ };
38
+
39
+ /** @param {KeyboardEvent & { currentTarget: HTMLInputElement }} e */
40
+ const onKeyDown = (e) => {
41
+ if (e.key === 'Enter' || e.key === ',') {
42
+ e.preventDefault();
43
+ add(text);
44
+ } else if (e.key === 'Backspace' && !text && selected.length) {
45
+ remove(selected[selected.length - 1]);
46
+ }
47
+ };
48
+
49
+ return html`
50
+ <div>
51
+ ${
52
+ selected.length
53
+ ? html`<div class="chips" style="margin-bottom:6px">
54
+ ${selected.map(
55
+ (tag) => html`
56
+ <button
57
+ type="button"
58
+ key=${tag}
59
+ class="chip selected"
60
+ onClick=${() => remove(tag)}
61
+ title="Remove tag"
62
+ >
63
+ ${tag}<i class="bi bi-x" />
64
+ </button>
65
+ `,
66
+ )}
67
+ </div>`
68
+ : null
69
+ }
70
+ <div class="combo">
71
+ <input
72
+ class="input"
73
+ placeholder="Add tag and press Enter"
74
+ value=${text}
75
+ onInput=${(/** @type {any} */ e) => {
76
+ setText(e.currentTarget.value);
77
+ setOpen(true);
78
+ }}
79
+ onKeyDown=${onKeyDown}
80
+ onFocus=${() => setOpen(true)}
81
+ onBlur=${() => setTimeout(() => setOpen(false), 150)}
82
+ />
83
+ ${
84
+ open && matches.length
85
+ ? html`<ul class="combo-list">
86
+ ${matches.map((s) => html`<li key=${s} onMouseDown=${() => add(s)}>${s}</li>`)}
87
+ </ul>`
88
+ : null
89
+ }
90
+ </div>
91
+ </div>
92
+ `;
93
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Task create/edit form (§7.5). On create the slug is a live preview derived
3
+ * from the name until the user edits it; on edit the slug is an editable
4
+ * field with a deep-link warning (§6.4). Seed last_maintenance/last_runtime
5
+ * are offered on create only.
6
+ */
7
+ import { html } from '../lib/html.js';
8
+ import { useState } from '../../vendor/preact-hooks.js';
9
+ import { Modal } from './Modal.js';
10
+ import { MarkdownView } from './MarkdownView.js';
11
+ import { TagInput } from './TagInput.js';
12
+ import { PathPicker } from './PathPicker.js';
13
+ import { createTask, updateTask, useTags } from '../api/hooks.js';
14
+ import { slugify } from '../lib/slug.js';
15
+ import { toast } from '../lib/toasts.js';
16
+
17
+ /** @typedef {import('../types.js').TaskDTO} TaskDTO */
18
+ /** @typedef {import('../types.js').TaskInput} TaskInput */
19
+ /** @typedef {import('../types.js').TimeUnit} TimeUnit */
20
+
21
+ const TIME_UNITS = ['days', 'weeks', 'months', 'years'];
22
+
23
+ /**
24
+ * @param {{ task: TaskDTO|null, onClose: () => void, onSaved?: (task: TaskDTO) => void }} props
25
+ */
26
+ export function TaskFormModal(props) {
27
+ const task = props.task;
28
+ const isEdit = !!task;
29
+
30
+ const [name, setName] = useState(task ? task.name : '');
31
+ const [slug, setSlug] = useState(task ? task.slug : '');
32
+ const [slugTouched, setSlugTouched] = useState(isEdit);
33
+ const [description, setDescription] = useState(
34
+ task && task.description ? task.description : '',
35
+ );
36
+ const [preview, setPreview] = useState(false);
37
+ const [tags, setTags] = useState(task ? task.tags.slice() : []);
38
+ const [runtimeInterval, setRuntimeInterval] = useState(
39
+ task && task.runtime_interval !== null ? String(task.runtime_interval) : '',
40
+ );
41
+ const [runtimePath, setRuntimePath] = useState(
42
+ task && task.runtime_path ? task.runtime_path : '',
43
+ );
44
+ const [timeInterval, setTimeInterval] = useState(
45
+ task && task.time_interval !== null ? String(task.time_interval) : '',
46
+ );
47
+ const [timeUnit, setTimeUnit] = useState(
48
+ task && task.time_interval_unit ? task.time_interval_unit : 'months',
49
+ );
50
+ const [seedMaintenance, setSeedMaintenance] = useState('');
51
+ const [seedRuntime, setSeedRuntime] = useState('');
52
+ const [error, setError] = useState('');
53
+ const [busy, setBusy] = useState(false);
54
+
55
+ const tagsRes = useTags();
56
+ const suggestions = (tagsRes.data ? tagsRes.data.data : []).map(
57
+ (t) => t.name,
58
+ );
59
+
60
+ const effectiveSlug = slugTouched ? slug : slugify(name || '');
61
+ const slugChanged = isEdit && task && effectiveSlug !== task.slug;
62
+
63
+ /** @param {Event} e */
64
+ const onSubmit = async (e) => {
65
+ e.preventDefault();
66
+ setError('');
67
+
68
+ if (!name.trim()) {
69
+ setError('Name is required.');
70
+ return;
71
+ }
72
+ /** @type {TaskInput} */
73
+ const input = {
74
+ name: name.trim(),
75
+ description: description.trim() ? description : null,
76
+ tags: tags,
77
+ runtime_path: runtimePath.trim() ? runtimePath.trim() : null,
78
+ runtime_interval: null,
79
+ time_interval: null,
80
+ time_interval_unit: null,
81
+ };
82
+ if (runtimeInterval.trim() !== '') {
83
+ const hours = Number(runtimeInterval);
84
+ if (!isFinite(hours) || hours <= 0) {
85
+ setError('Runtime interval must be a positive number of hours.');
86
+ return;
87
+ }
88
+ input.runtime_interval = hours;
89
+ }
90
+ if (timeInterval.trim() !== '') {
91
+ const magnitude = Number(timeInterval);
92
+ if (
93
+ !isFinite(magnitude) ||
94
+ magnitude <= 0 ||
95
+ Math.floor(magnitude) !== magnitude
96
+ ) {
97
+ setError('Time interval must be a positive whole number.');
98
+ return;
99
+ }
100
+ input.time_interval = magnitude;
101
+ input.time_interval_unit = /** @type {TimeUnit} */ (timeUnit);
102
+ }
103
+ if (isEdit) {
104
+ if (slugChanged) input.slug = effectiveSlug;
105
+ } else {
106
+ if (slugTouched && slug.trim()) input.slug = slug.trim();
107
+ if (seedMaintenance) input.last_maintenance = seedMaintenance;
108
+ if (seedRuntime.trim() !== '') {
109
+ const seed = Number(seedRuntime);
110
+ if (!isFinite(seed) || seed < 0) {
111
+ setError('Seed runtime must be a non-negative number of hours.');
112
+ return;
113
+ }
114
+ input.last_runtime = seed;
115
+ }
116
+ }
117
+
118
+ setBusy(true);
119
+ try {
120
+ const saved =
121
+ isEdit && task
122
+ ? await updateTask(task.slug, input)
123
+ : await createTask(input);
124
+ toast(isEdit ? 'Task updated.' : 'Task created.', 'success');
125
+ if (props.onSaved) props.onSaved(saved);
126
+ props.onClose();
127
+ } catch (err) {
128
+ setError(err instanceof Error ? err.message : 'Save failed');
129
+ setBusy(false);
130
+ }
131
+ };
132
+
133
+ const footer = html`
134
+ <button type="button" class="btn" onClick=${props.onClose} disabled=${busy}>
135
+ Cancel
136
+ </button>
137
+ <button
138
+ type="submit"
139
+ form="task-form"
140
+ class="btn btn-primary"
141
+ disabled=${busy}
142
+ >
143
+ ${busy ? 'Saving…' : isEdit ? 'Save changes' : 'Create task'}
144
+ </button>
145
+ `;
146
+
147
+ return html`
148
+ <${Modal}
149
+ title=${isEdit ? 'Edit task' : 'New task'}
150
+ onClose=${props.onClose}
151
+ footer=${footer}
152
+ >
153
+ <form id="task-form" onSubmit=${onSubmit}>
154
+ ${error ? html`<div class="form-error">${error}</div>` : null}
155
+
156
+ <div class="field">
157
+ <label class="field-label" for="task-name">Name</label>
158
+ <input
159
+ id="task-name"
160
+ class="input"
161
+ value=${name}
162
+ onInput=${(/** @type {any} */ e) => setName(e.currentTarget.value)}
163
+ />
164
+ </div>
165
+
166
+ <div class="field">
167
+ <label class="field-label" for="task-slug">Slug</label>
168
+ <input
169
+ id="task-slug"
170
+ class="input slug-preview"
171
+ value=${effectiveSlug}
172
+ onInput=${(/** @type {any} */ e) => {
173
+ setSlugTouched(true);
174
+ setSlug(e.currentTarget.value);
175
+ }}
176
+ />
177
+ ${
178
+ slugChanged
179
+ ? html`<div class="field-hint">
180
+ <i class="bi bi-exclamation-triangle" /> Changing the slug
181
+ breaks existing deep links to this task.
182
+ </div>`
183
+ : html`<div class="field-hint">
184
+ Used in URLs and SignalK notifications.
185
+ </div>`
186
+ }
187
+ </div>
188
+
189
+ <div class="field">
190
+ <label class="field-label" for="task-description">
191
+ Description (markdown)${' '}
192
+ <button
193
+ type="button"
194
+ class="btn-link"
195
+ onClick=${() => setPreview(!preview)}
196
+ >
197
+ ${preview ? 'edit' : 'preview'}
198
+ </button>
199
+ </label>
200
+ ${
201
+ preview
202
+ ? html`<div class="card">
203
+ <${MarkdownView}
204
+ markdown=${description || '_Nothing to preview._'}
205
+ />
206
+ </div>`
207
+ : html`<textarea
208
+ id="task-description"
209
+ class="textarea"
210
+ value=${description}
211
+ onInput=${(/** @type {any} */ e) => setDescription(e.currentTarget.value)}
212
+ />`
213
+ }
214
+ </div>
215
+
216
+ <div class="field">
217
+ <label class="field-label">Tags</label>
218
+ <${TagInput}
219
+ value=${tags}
220
+ onChange=${setTags}
221
+ suggestions=${suggestions}
222
+ />
223
+ </div>
224
+
225
+ <div class="field-row">
226
+ <div class="field">
227
+ <label class="field-label" for="task-runtime-interval"
228
+ >Runtime interval (hours)</label
229
+ >
230
+ <input
231
+ id="task-runtime-interval"
232
+ class="input"
233
+ type="number"
234
+ min="0"
235
+ step="any"
236
+ value=${runtimeInterval}
237
+ onInput=${(/** @type {any} */ e) => setRuntimeInterval(e.currentTarget.value)}
238
+ />
239
+ <div class="field-hint">Empty = no runtime tracking.</div>
240
+ </div>
241
+ <div class="field">
242
+ <label class="field-label" for="task-time-interval"
243
+ >Time interval</label
244
+ >
245
+ <div class="field-row">
246
+ <input
247
+ id="task-time-interval"
248
+ class="input"
249
+ type="number"
250
+ min="0"
251
+ step="1"
252
+ value=${timeInterval}
253
+ onInput=${(/** @type {any} */ e) => setTimeInterval(e.currentTarget.value)}
254
+ />
255
+ <select
256
+ class="select"
257
+ aria-label="Time interval unit"
258
+ value=${timeUnit}
259
+ onInput=${(/** @type {any} */ e) => setTimeUnit(e.currentTarget.value)}
260
+ >
261
+ ${TIME_UNITS.map((u) => html`<option key=${u} value=${u}>${u}</option>`)}
262
+ </select>
263
+ </div>
264
+ <div class="field-hint">Empty = no calendar tracking.</div>
265
+ </div>
266
+ </div>
267
+
268
+ <div class="field">
269
+ <label class="field-label">Runtime path (SignalK)</label>
270
+ <${PathPicker} value=${runtimePath} onChange=${setRuntimePath} />
271
+ </div>
272
+
273
+ ${
274
+ !isEdit
275
+ ? html`
276
+ <div class="field-row">
277
+ <div class="field">
278
+ <label class="field-label" for="task-seed-date"
279
+ >Last maintenance (optional seed)</label
280
+ >
281
+ <input
282
+ id="task-seed-date"
283
+ class="input"
284
+ type="date"
285
+ value=${seedMaintenance}
286
+ onInput=${(/** @type {any} */ e) => setSeedMaintenance(e.currentTarget.value)}
287
+ />
288
+ </div>
289
+ <div class="field">
290
+ <label class="field-label" for="task-seed-runtime"
291
+ >Runtime at last maintenance (h)</label
292
+ >
293
+ <input
294
+ id="task-seed-runtime"
295
+ class="input"
296
+ type="number"
297
+ min="0"
298
+ step="any"
299
+ value=${seedRuntime}
300
+ onInput=${(/** @type {any} */ e) => setSeedRuntime(e.currentTarget.value)}
301
+ />
302
+ </div>
303
+ </div>
304
+ `
305
+ : null
306
+ }
307
+ </form>
308
+ <//>
309
+ `;
310
+ }
@@ -0,0 +1,17 @@
1
+ import { html } from '../lib/html.js';
2
+ import { theme, toggleTheme } from '../lib/theme.js';
3
+
4
+ export function ThemeToggle() {
5
+ const current = theme.value;
6
+ return html`
7
+ <button
8
+ type="button"
9
+ class="btn-icon"
10
+ aria-label=${current === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
11
+ title=${current === 'dark' ? 'Light theme' : 'Dark theme'}
12
+ onClick=${toggleTheme}
13
+ >
14
+ <i class=${'bi ' + (current === 'dark' ? 'bi-sun' : 'bi-moon-stars')} />
15
+ </button>
16
+ `;
17
+ }
@@ -0,0 +1,24 @@
1
+ import { html } from '../lib/html.js';
2
+ import { toasts, dismissToast } from '../lib/toasts.js';
3
+
4
+ /** Renders the toast queue (§7.2); click a toast to dismiss it. */
5
+ export function Toaster() {
6
+ const items = toasts.value;
7
+ if (!items.length) return null;
8
+ return html`
9
+ <div class="toaster">
10
+ ${items.map(
11
+ (t) => html`
12
+ <div
13
+ key=${t.id}
14
+ class=${'toast ' + t.kind}
15
+ role="status"
16
+ onClick=${() => dismissToast(t.id)}
17
+ >
18
+ ${t.message}
19
+ </div>
20
+ `,
21
+ )}
22
+ </div>
23
+ `;
24
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Display formatting helpers. All date math beyond formatting happens on the
3
+ * backend (§6.2); day.js here is for parsing/formatting only.
4
+ */
5
+ import dayjs from '../../vendor/dayjs/index.js';
6
+
7
+ const MS_PER_HOUR = 3600 * 1000;
8
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
9
+
10
+ /**
11
+ * Calendar-date display. Uses the stored UTC date part directly — parsing to
12
+ * local time would shift UTC-midnight dates back a day in western timezones.
13
+ * @param {string|null|undefined} iso
14
+ */
15
+ export function formatDate(iso) {
16
+ return iso ? String(iso).slice(0, 10) : '—';
17
+ }
18
+
19
+ /**
20
+ * Runtime hours for display: "1240.5 h". Null-safe.
21
+ * @param {number|null|undefined} hours
22
+ */
23
+ export function formatHours(hours) {
24
+ if (hours === null || hours === undefined) return '—';
25
+ const rounded = Math.round(hours * 10) / 10;
26
+ return String(rounded) + ' h';
27
+ }
28
+
29
+ /**
30
+ * Remaining runtime column: negative = overdue.
31
+ * @param {number|null|undefined} hours
32
+ */
33
+ export function formatRemainingHours(hours) {
34
+ if (hours === null || hours === undefined) return '—';
35
+ if (hours < 0) return formatHours(-hours) + ' overdue';
36
+ return formatHours(hours);
37
+ }
38
+
39
+ /**
40
+ * Humanize a millisecond span: "3 days", "5 hours", "< 1 hour".
41
+ * @param {number} ms non-negative
42
+ */
43
+ export function humanizeMs(ms) {
44
+ if (ms >= MS_PER_DAY) {
45
+ const days = Math.round(ms / MS_PER_DAY);
46
+ return days + (days === 1 ? ' day' : ' days');
47
+ }
48
+ if (ms >= MS_PER_HOUR) {
49
+ const hours = Math.round(ms / MS_PER_HOUR);
50
+ return hours + (hours === 1 ? ' hour' : ' hours');
51
+ }
52
+ return '< 1 hour';
53
+ }
54
+
55
+ /**
56
+ * Remaining time column: negative = overdue.
57
+ * @param {number|null|undefined} ms
58
+ */
59
+ export function formatRemainingTime(ms) {
60
+ if (ms === null || ms === undefined) return '—';
61
+ if (ms < 0) return humanizeMs(-ms) + ' overdue';
62
+ return humanizeMs(ms);
63
+ }
64
+
65
+ /**
66
+ * Value for <input type="date">: today (local) by default, or the UTC date
67
+ * part of a stored ISO string. The YYYY-MM-DD value is sent to the API as-is
68
+ * (valid ISO-8601; the backend normalizes it to UTC midnight).
69
+ * @param {string} [iso]
70
+ */
71
+ export function toDateInput(iso) {
72
+ return iso === undefined
73
+ ? dayjs().format('YYYY-MM-DD')
74
+ : String(iso).slice(0, 10);
75
+ }
76
+
77
+ /**
78
+ * @param {string|null|undefined} s
79
+ * @param {number} max
80
+ */
81
+ export function truncate(s, max) {
82
+ if (!s) return '';
83
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
84
+ }
85
+
86
+ /** Status → human label. @param {string} status */
87
+ export function statusLabel(status) {
88
+ if (status === 'due_soon') return 'due soon';
89
+ return status;
90
+ }
@@ -0,0 +1,6 @@
1
+ import htm from '../../vendor/htm.js';
2
+ import { h, Fragment } from '../../vendor/preact.js';
3
+
4
+ /** Tagged template for JSX-free Preact (§3): html`<div>...</div>` */
5
+ export const html = htm.bind(h);
6
+ export { Fragment };