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,294 @@
1
+ /**
2
+ * Task detail (§7.4): description, tags, intervals, runtime/time progress,
3
+ * status, per-task log with edit/delete, and the complete/edit/delete modals.
4
+ */
5
+ import { html } from '../lib/html.js';
6
+ import { useState } from '../../vendor/preact-hooks.js';
7
+ import { useTask, useTaskLogs, deleteTask, deleteLog } from '../api/hooks.js';
8
+ import { useAuth } from '../auth/auth.js';
9
+ import {
10
+ formatDate,
11
+ formatHours,
12
+ formatRemainingHours,
13
+ formatRemainingTime,
14
+ } from '../lib/format.js';
15
+ import { navigate } from '../lib/router.js';
16
+ import { toast } from '../lib/toasts.js';
17
+ import { Table } from '../components/Table.js';
18
+ import { StatusBadge } from '../components/StatusBadge.js';
19
+ import { ProgressBar } from '../components/ProgressBar.js';
20
+ import { MarkdownView } from '../components/MarkdownView.js';
21
+ import { TaskFormModal } from '../components/TaskFormModal.js';
22
+ import { LogEntryModal } from '../components/LogEntryModal.js';
23
+ import { ConfirmModal } from '../components/ConfirmModal.js';
24
+
25
+ /** @typedef {import('../types.js').TaskDTO} TaskDTO */
26
+ /** @typedef {import('../types.js').LogDTO} LogDTO */
27
+
28
+ /** @param {{ slug: string }} props */
29
+ export function TaskDetailPage(props) {
30
+ const auth = useAuth();
31
+ const taskRes = useTask(props.slug);
32
+ const logsRes = useTaskLogs(props.slug);
33
+
34
+ const [editing, setEditing] = useState(false);
35
+ const [completing, setCompleting] = useState(false);
36
+ const [deletingTask, setDeletingTask] = useState(false);
37
+ const [editingEntry, setEditingEntry] = useState(
38
+ /** @type {LogDTO|null} */ (null),
39
+ );
40
+ const [deletingEntry, setDeletingEntry] = useState(
41
+ /** @type {LogDTO|null} */ (null),
42
+ );
43
+
44
+ const task = taskRes.data;
45
+
46
+ if (taskRes.error && !task) {
47
+ return html`<div class="error-box">
48
+ Failed to load task: ${taskRes.error.message}
49
+ </div>`;
50
+ }
51
+ if (!task) {
52
+ return html`<div class="table-loading">Loading…</div>`;
53
+ }
54
+
55
+ /** @type {import('../components/Table.js').Column[]} */
56
+ const logColumns = [
57
+ {
58
+ key: 'maintenance_date',
59
+ label: 'Date',
60
+ className: 'num',
61
+ render: (/** @type {LogDTO} */ e) => formatDate(e.maintenance_date),
62
+ },
63
+ {
64
+ key: 'runtime_hours',
65
+ label: 'Runtime',
66
+ className: 'num',
67
+ render: (/** @type {LogDTO} */ e) => formatHours(e.runtime_hours),
68
+ },
69
+ {
70
+ key: 'logged_by',
71
+ label: 'By',
72
+ render: (/** @type {LogDTO} */ e) =>
73
+ e.logged_by || html`<span class="muted">—</span>`,
74
+ },
75
+ ];
76
+ if (auth.isLoggedIn) {
77
+ logColumns.push({
78
+ key: 'actions',
79
+ label: '',
80
+ className: 'actions',
81
+ render: (/** @type {LogDTO} */ e) => html`
82
+ <button
83
+ type="button"
84
+ class="btn-icon primary"
85
+ aria-label="Edit log entry"
86
+ title="Edit"
87
+ onClick=${() => setEditingEntry(e)}
88
+ >
89
+ <i class="bi bi-pencil" />
90
+ </button>
91
+ <button
92
+ type="button"
93
+ class="btn-icon danger"
94
+ aria-label="Delete log entry"
95
+ title="Delete"
96
+ onClick=${() => setDeletingEntry(e)}
97
+ >
98
+ <i class="bi bi-trash" />
99
+ </button>
100
+ `,
101
+ });
102
+ }
103
+
104
+ const runtimeConfigured = task.runtime_interval !== null;
105
+ const timeConfigured = task.time_interval !== null;
106
+
107
+ return html`
108
+ <div>
109
+ <div class="page-header">
110
+ <h1 class="page-title">
111
+ ${task.name} <${StatusBadge} status=${task.status} />
112
+ </h1>
113
+ ${
114
+ auth.isLoggedIn
115
+ ? html`
116
+ <span class="page-actions">
117
+ <button
118
+ type="button"
119
+ class="btn btn-success"
120
+ onClick=${() => setCompleting(true)}
121
+ >
122
+ <i class="bi bi-check2-circle" />Mark complete
123
+ </button>
124
+ <button
125
+ type="button"
126
+ class="btn btn-primary"
127
+ onClick=${() => setEditing(true)}
128
+ >
129
+ <i class="bi bi-pencil" />Edit
130
+ </button>
131
+ <button
132
+ type="button"
133
+ class="btn btn-danger"
134
+ onClick=${() => setDeletingTask(true)}
135
+ >
136
+ <i class="bi bi-trash" />Delete
137
+ </button>
138
+ </span>
139
+ `
140
+ : null
141
+ }
142
+ </div>
143
+
144
+ <div class="detail-grid">
145
+ <div class="card">
146
+ <h3>Description</h3>
147
+ ${
148
+ task.description
149
+ ? html`<${MarkdownView} markdown=${task.description} />`
150
+ : html`<p class="muted" style="margin:0">No description.</p>`
151
+ }
152
+ ${
153
+ task.tags.length
154
+ ? html`<div class="chips" style="margin-top:12px">
155
+ ${task.tags.map((tag) => html`<span key=${tag} class="tag">${tag}</span>`)}
156
+ </div>`
157
+ : null
158
+ }
159
+ </div>
160
+
161
+ <div class="card">
162
+ <h3>Schedule</h3>
163
+ ${
164
+ !runtimeConfigured && !timeConfigured
165
+ ? html`<p class="muted" style="margin:0">
166
+ Informational task — no intervals configured.
167
+ </p>`
168
+ : null
169
+ }
170
+ ${
171
+ runtimeConfigured
172
+ ? html`
173
+ <div class="stat-row">
174
+ <div class="stat-label">
175
+ <span
176
+ >Runtime — every
177
+ ${formatHours(task.runtime_interval)}</span
178
+ >
179
+ <span class="stat-value"
180
+ >${formatRemainingHours(task.remaining_runtime)}${task.remaining_runtime !== null && task.remaining_runtime >= 0 ? ' left' : ''}</span
181
+ >
182
+ </div>
183
+ <${ProgressBar}
184
+ fraction=${task.runtime_fraction}
185
+ status=${task.runtime_status}
186
+ />
187
+ <div class="field-hint">
188
+ Current ${formatHours(task.current_runtime)} · last done
189
+ at ${formatHours(task.last_runtime)} · due at
190
+ ${formatHours(task.due_runtime_at)}
191
+ </div>
192
+ </div>
193
+ `
194
+ : null
195
+ }
196
+ ${
197
+ timeConfigured
198
+ ? html`
199
+ <div class="stat-row">
200
+ <div class="stat-label">
201
+ <span
202
+ >Time — every ${task.time_interval}
203
+ ${task.time_interval_unit}</span
204
+ >
205
+ <span class="stat-value"
206
+ >${formatRemainingTime(task.remaining_time_ms)}${task.remaining_time_ms !== null && task.remaining_time_ms >= 0 ? ' left' : ''}</span
207
+ >
208
+ </div>
209
+ <${ProgressBar}
210
+ fraction=${task.time_fraction}
211
+ status=${task.time_status}
212
+ />
213
+ <div class="field-hint">
214
+ Last done ${formatDate(task.last_maintenance)} · next due
215
+ ${formatDate(task.due_date)}
216
+ </div>
217
+ </div>
218
+ `
219
+ : null
220
+ }
221
+ ${
222
+ task.runtime_path
223
+ ? html`<div class="field-hint">
224
+ Runtime source: <code>${task.runtime_path}</code>
225
+ </div>`
226
+ : null
227
+ }
228
+ </div>
229
+ </div>
230
+
231
+ <div class="page-header" style="margin-top:24px">
232
+ <h2 class="page-title" style="font-size:17px">Maintenance log</h2>
233
+ </div>
234
+ <${Table}
235
+ columns=${logColumns}
236
+ rows=${logsRes.data ? logsRes.data.data : []}
237
+ renderDetail=${(/** @type {LogDTO} */ e) =>
238
+ e.notes
239
+ ? html`<div class="log-notes">
240
+ <strong class="log-notes-label">Notes:</strong>
241
+ <div class="log-notes-body">
242
+ <${MarkdownView} markdown=${e.notes} />
243
+ </div>
244
+ </div>`
245
+ : null}
246
+ loading=${logsRes.loading}
247
+ emptyMessage="No maintenance logged yet."
248
+ />
249
+
250
+ ${
251
+ editing
252
+ ? html`<${TaskFormModal}
253
+ task=${task}
254
+ onClose=${() => setEditing(false)}
255
+ onSaved=${(/** @type {TaskDTO} */ saved) => {
256
+ if (saved.slug !== props.slug)
257
+ navigate('/tasks/' + encodeURIComponent(saved.slug));
258
+ }}
259
+ />`
260
+ : null
261
+ }
262
+ ${completing ? html`<${LogEntryModal} task=${task} onClose=${() => setCompleting(false)} />` : null}
263
+ ${editingEntry ? html`<${LogEntryModal} entry=${editingEntry} onClose=${() => setEditingEntry(null)} />` : null}
264
+ ${
265
+ deletingTask
266
+ ? html`<${ConfirmModal}
267
+ title="Delete task"
268
+ message=${'Delete "' + task.name + '" and its entire maintenance log? This cannot be undone.'}
269
+ onConfirm=${async () => {
270
+ await deleteTask(task.slug);
271
+ toast('Task deleted.', 'success');
272
+ navigate('/');
273
+ }}
274
+ onClose=${() => setDeletingTask(false)}
275
+ />`
276
+ : null
277
+ }
278
+ ${
279
+ deletingEntry
280
+ ? html`<${ConfirmModal}
281
+ title="Delete log entry"
282
+ message="Delete this log entry? The task's last-maintenance data will be recomputed."
283
+ onConfirm=${async () => {
284
+ await deleteLog(deletingEntry.id, task.slug);
285
+ toast('Log entry deleted.', 'success');
286
+ setDeletingEntry(null);
287
+ }}
288
+ onClose=${() => setDeletingEntry(null)}
289
+ />`
290
+ : null
291
+ }
292
+ </div>
293
+ `;
294
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Task list — the main page (§7.4). Server-side search/filter/sort/paging;
3
+ * write affordances render only when logged in (§7.7).
4
+ */
5
+ import { html } from '../lib/html.js';
6
+ import { useState, useEffect } from '../../vendor/preact-hooks.js';
7
+ import { useTasks, useTags, deleteTask } from '../api/hooks.js';
8
+ import { useAuth } from '../auth/auth.js';
9
+ import { useListParams } from '../lib/useListParams.js';
10
+ import {
11
+ formatDate,
12
+ formatRemainingHours,
13
+ formatRemainingTime,
14
+ } from '../lib/format.js';
15
+ import { toast } from '../lib/toasts.js';
16
+ import { Table } from '../components/Table.js';
17
+ import { Pagination } from '../components/Pagination.js';
18
+ import { StatusBadge } from '../components/StatusBadge.js';
19
+ import { TaskFormModal } from '../components/TaskFormModal.js';
20
+ import { LogEntryModal } from '../components/LogEntryModal.js';
21
+ import { ConfirmModal } from '../components/ConfirmModal.js';
22
+
23
+ /** @typedef {import('../types.js').TaskDTO} TaskDTO */
24
+
25
+ const PAGE_SIZE = 20;
26
+
27
+ export function TaskListPage() {
28
+ const { params, update } = useListParams();
29
+ const auth = useAuth();
30
+
31
+ const page = parseInt(params.page || '1', 10) || 1;
32
+ const search = params.search || '';
33
+ const tagsCsv = params.tags || '';
34
+ const sort = params.sort || '';
35
+ const order = params.order || '';
36
+
37
+ // Debounce typed search into the URL (and thus the query key).
38
+ const [searchText, setSearchText] = useState(search);
39
+ useEffect(() => {
40
+ setSearchText(search);
41
+ }, [search]);
42
+ useEffect(() => {
43
+ if (searchText === search) return undefined;
44
+ const timer = setTimeout(
45
+ () => update({ search: searchText, page: undefined }),
46
+ 300,
47
+ );
48
+ return () => clearTimeout(timer);
49
+ }, [searchText]);
50
+
51
+ const tasksRes = useTasks({
52
+ search: search || undefined,
53
+ tags: tagsCsv || undefined,
54
+ sort: sort || undefined,
55
+ order: order || undefined,
56
+ page: page,
57
+ pageSize: PAGE_SIZE,
58
+ });
59
+ const tagsRes = useTags();
60
+
61
+ /** @type {[TaskDTO|null|undefined, any]} editor: undefined=closed, null=create, TaskDTO=edit */
62
+ const [editorTask, setEditorTask] = useState(
63
+ /** @type {TaskDTO|null|undefined} */ (undefined),
64
+ );
65
+ const [completing, setCompleting] = useState(
66
+ /** @type {TaskDTO|null} */ (null),
67
+ );
68
+ const [deleting, setDeleting] = useState(/** @type {TaskDTO|null} */ (null));
69
+
70
+ const selectedTags = tagsCsv ? tagsCsv.split(',').filter(Boolean) : [];
71
+ /** @param {string} tag */
72
+ const toggleTag = (tag) => {
73
+ const next =
74
+ selectedTags.indexOf(tag) === -1
75
+ ? selectedTags.concat([tag])
76
+ : selectedTags.filter((t) => t !== tag);
77
+ update({ tags: next.join(',') || undefined, page: undefined });
78
+ };
79
+
80
+ /** @param {string} key */
81
+ const onSort = (key) => {
82
+ if (sort === key) update({ order: order === 'asc' ? 'desc' : 'asc' });
83
+ else update({ sort: key, order: 'asc' });
84
+ };
85
+
86
+ /** @type {import('../components/Table.js').Column[]} */
87
+ const columns = [
88
+ {
89
+ key: 'status',
90
+ label: 'Status',
91
+ sortable: true,
92
+ render: (/** @type {TaskDTO} */ t) =>
93
+ html`<${StatusBadge} status=${t.status} />`,
94
+ },
95
+ {
96
+ key: 'name',
97
+ label: 'Name',
98
+ sortable: true,
99
+ render: (/** @type {TaskDTO} */ t) =>
100
+ html`<a href=${'#/tasks/' + encodeURIComponent(t.slug)}>${t.name}</a>`,
101
+ },
102
+ {
103
+ key: 'tags',
104
+ label: 'Tags',
105
+ className: 'hide-sm',
106
+ render: (/** @type {TaskDTO} */ t) =>
107
+ html`<span class="chips"
108
+ >${t.tags.map((tag) => html`<span key=${tag} class="tag">${tag}</span>`)}</span
109
+ >`,
110
+ },
111
+ {
112
+ key: 'remaining_runtime',
113
+ label: 'Runtime left',
114
+ sortable: true,
115
+ className: 'num hide-sm',
116
+ render: (/** @type {TaskDTO} */ t) =>
117
+ html`<span class=${'remaining ' + (t.runtime_status || '')}
118
+ >${formatRemainingHours(t.remaining_runtime)}</span
119
+ >`,
120
+ },
121
+ {
122
+ key: 'remaining_time',
123
+ label: 'Time left',
124
+ sortable: true,
125
+ className: 'num hide-sm',
126
+ render: (/** @type {TaskDTO} */ t) =>
127
+ html`<span class=${'remaining ' + (t.time_status || '')}
128
+ >${formatRemainingTime(t.remaining_time_ms)}</span
129
+ >`,
130
+ },
131
+ {
132
+ key: 'due_date',
133
+ label: 'Next due',
134
+ className: 'num hide-sm',
135
+ render: (/** @type {TaskDTO} */ t) => formatDate(t.due_date),
136
+ },
137
+ {
138
+ key: 'actions',
139
+ label: '',
140
+ className: 'actions',
141
+ render: (/** @type {TaskDTO} */ t) => html`
142
+ ${
143
+ auth.isLoggedIn
144
+ ? html`
145
+ <button
146
+ type="button"
147
+ class="btn-icon success"
148
+ aria-label=${'Complete ' + t.name}
149
+ title="Mark complete"
150
+ onClick=${() => setCompleting(t)}
151
+ >
152
+ <i class="bi bi-check2-circle" />
153
+ </button>
154
+ <button
155
+ type="button"
156
+ class="btn-icon primary"
157
+ aria-label=${'Edit ' + t.name}
158
+ title="Edit"
159
+ onClick=${() => setEditorTask(t)}
160
+ >
161
+ <i class="bi bi-pencil" />
162
+ </button>
163
+ <button
164
+ type="button"
165
+ class="btn-icon danger"
166
+ aria-label=${'Delete ' + t.name}
167
+ title="Delete"
168
+ onClick=${() => setDeleting(t)}
169
+ >
170
+ <i class="bi bi-trash" />
171
+ </button>
172
+ `
173
+ : null
174
+ }
175
+ `,
176
+ },
177
+ ];
178
+
179
+ const pageData = tasksRes.data;
180
+
181
+ return html`
182
+ <div>
183
+ <div class="page-header">
184
+ <h1 class="page-title">Maintenance Tasks</h1>
185
+ ${
186
+ auth.isLoggedIn
187
+ ? html`
188
+ <button
189
+ type="button"
190
+ class="btn btn-primary"
191
+ onClick=${() => setEditorTask(null)}
192
+ >
193
+ <i class="bi bi-plus-lg" />New task
194
+ </button>
195
+ `
196
+ : null
197
+ }
198
+ </div>
199
+
200
+ <div class="toolbar">
201
+ <div class="search-box">
202
+ <i class="bi bi-search" />
203
+ <input
204
+ class="input"
205
+ placeholder="Search tasks…"
206
+ aria-label="Search tasks"
207
+ value=${searchText}
208
+ onInput=${(/** @type {any} */ e) => setSearchText(e.currentTarget.value)}
209
+ />
210
+ </div>
211
+ <div class="chips">
212
+ ${(tagsRes.data ? tagsRes.data.data : []).map(
213
+ (tag) => html`
214
+ <button
215
+ type="button"
216
+ key=${tag.name}
217
+ class=${'chip' + (selectedTags.indexOf(tag.name) !== -1 ? ' selected' : '')}
218
+ onClick=${() => toggleTag(tag.name)}
219
+ >
220
+ ${tag.name}<span class="chip-count">${tag.count}</span>
221
+ </button>
222
+ `,
223
+ )}
224
+ </div>
225
+ </div>
226
+
227
+ ${
228
+ tasksRes.error && !pageData
229
+ ? html`<div class="error-box">
230
+ Failed to load tasks: ${tasksRes.error.message}
231
+ </div>`
232
+ : html`
233
+ <${Table}
234
+ columns=${columns}
235
+ rows=${pageData ? pageData.data : []}
236
+ sort=${sort}
237
+ order=${order}
238
+ onSort=${onSort}
239
+ loading=${tasksRes.loading}
240
+ emptyMessage=${search || tagsCsv ? 'No tasks match your filters.' : 'No maintenance tasks yet.'}
241
+ />
242
+ ${
243
+ pageData
244
+ ? html`<${Pagination}
245
+ page=${pageData.page}
246
+ pageSize=${pageData.pageSize}
247
+ total=${pageData.total}
248
+ onPage=${(/** @type {number} */ p) => update({ page: p })}
249
+ />`
250
+ : null
251
+ }
252
+ `
253
+ }
254
+ ${
255
+ editorTask !== undefined
256
+ ? html`<${TaskFormModal}
257
+ task=${editorTask}
258
+ onClose=${() => setEditorTask(undefined)}
259
+ />`
260
+ : null
261
+ }
262
+ ${completing ? html`<${LogEntryModal} task=${completing} onClose=${() => setCompleting(null)} />` : null}
263
+ ${
264
+ deleting
265
+ ? html`<${ConfirmModal}
266
+ title="Delete task"
267
+ message=${'Delete "' + deleting.name + '" and its entire maintenance log? This cannot be undone.'}
268
+ onConfirm=${async () => {
269
+ await deleteTask(deleting.slug);
270
+ toast('Task deleted.', 'success');
271
+ setDeleting(null);
272
+ }}
273
+ onClose=${() => setDeleting(null)}
274
+ />`
275
+ : null
276
+ }
277
+ </div>
278
+ `;
279
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared JSDoc typedefs mirroring the backend DTOs (src/types.ts, §8).
3
+ * This module has no runtime exports; other modules reference these types
4
+ * with `import('../types.js').TaskDTO`-style JSDoc imports.
5
+ */
6
+
7
+ /** @typedef {'days'|'weeks'|'months'|'years'} TimeUnit */
8
+ /** @typedef {'overdue'|'due_soon'|'ok'|'unknown'} Status */
9
+
10
+ /**
11
+ * @typedef {Object} TaskDTO
12
+ * @property {number} id
13
+ * @property {string} slug
14
+ * @property {string} name
15
+ * @property {string|null} description
16
+ * @property {string[]} tags
17
+ * @property {number|null} runtime_interval
18
+ * @property {number|null} time_interval
19
+ * @property {TimeUnit|null} time_interval_unit
20
+ * @property {string|null} runtime_path
21
+ * @property {string|null} last_maintenance
22
+ * @property {number|null} last_runtime
23
+ * @property {number|null} current_runtime
24
+ * @property {number|null} elapsed_runtime
25
+ * @property {number|null} remaining_runtime
26
+ * @property {number|null} due_runtime_at
27
+ * @property {number|null} runtime_fraction
28
+ * @property {string|null} due_date
29
+ * @property {number|null} remaining_time_ms
30
+ * @property {number|null} time_fraction
31
+ * @property {Status|null} runtime_status
32
+ * @property {Status|null} time_status
33
+ * @property {Status} status
34
+ * @property {number} status_rank
35
+ * @property {number} urgency
36
+ * @property {string} created_at
37
+ * @property {string} updated_at
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} LogDTO
42
+ * @property {number} id
43
+ * @property {number} task_id
44
+ * @property {string} maintenance_date
45
+ * @property {number|null} runtime_hours
46
+ * @property {string|null} notes
47
+ * @property {string|null} logged_by
48
+ * @property {string} created_at
49
+ * @property {string} task_slug
50
+ * @property {string} task_name
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} TagDTO
55
+ * @property {number} id
56
+ * @property {string} name
57
+ * @property {number} count
58
+ */
59
+
60
+ /**
61
+ * @template T
62
+ * @typedef {Object} Page
63
+ * @property {T[]} data
64
+ * @property {number} total
65
+ * @property {number} page
66
+ * @property {number} pageSize
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} TaskInput
71
+ * @property {string} [name]
72
+ * @property {string} [slug]
73
+ * @property {string|null} [description]
74
+ * @property {number|null} [runtime_interval]
75
+ * @property {number|null} [time_interval]
76
+ * @property {TimeUnit|null} [time_interval_unit]
77
+ * @property {string|null} [runtime_path]
78
+ * @property {string[]} [tags]
79
+ * @property {string|null} [last_maintenance]
80
+ * @property {number|null} [last_runtime]
81
+ */
82
+
83
+ /**
84
+ * @typedef {Object} LogInput
85
+ * @property {string} [maintenance_date]
86
+ * @property {number|null} [runtime_hours]
87
+ * @property {string|null} [notes]
88
+ */
89
+
90
+ export {};
Binary file
Binary file
Binary file
Binary file
Binary file