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,105 @@
1
+ /**
2
+ * Tiny hand-rolled hash router (§7.1). Everything after '#' is client-side:
3
+ * '#/tasks/oil-change?page=2' → { path: '/tasks/oil-change', query: {page:'2'} }.
4
+ */
5
+ import { signal } from '../../vendor/signals.js';
6
+
7
+ /** @typedef {{ path: string, query: Record<string, string> }} Route */
8
+
9
+ /**
10
+ * @param {string} hash
11
+ * @returns {Route}
12
+ */
13
+ export function parseHash(hash) {
14
+ let h = hash || '';
15
+ if (h.charAt(0) === '#') h = h.slice(1);
16
+ if (h === '') h = '/';
17
+ let path = h;
18
+ let queryString = '';
19
+ const qIndex = h.indexOf('?');
20
+ if (qIndex >= 0) {
21
+ path = h.slice(0, qIndex);
22
+ queryString = h.slice(qIndex + 1);
23
+ }
24
+ if (path.charAt(0) !== '/') path = '/' + path;
25
+ /** @type {Record<string, string>} */
26
+ const query = {};
27
+ new URLSearchParams(queryString).forEach(function (value, key) {
28
+ query[key] = value;
29
+ });
30
+ return { path: path, query: query };
31
+ }
32
+
33
+ /**
34
+ * @param {string} path
35
+ * @param {Record<string, string|number|undefined|null>} [query]
36
+ * @returns {string} location.hash value, always starting with '#/'
37
+ */
38
+ export function formatHash(path, query) {
39
+ const usp = new URLSearchParams();
40
+ if (query) {
41
+ for (const key of Object.keys(query)) {
42
+ const value = query[key];
43
+ if (value !== undefined && value !== null && value !== '')
44
+ usp.set(key, String(value));
45
+ }
46
+ }
47
+ const qs = usp.toString();
48
+ return '#' + path + (qs ? '?' + qs : '');
49
+ }
50
+
51
+ /**
52
+ * Match '/tasks/:slug' against '/tasks/oil-change' → {slug:'oil-change'},
53
+ * or null when the pattern does not match.
54
+ * @param {string} pattern
55
+ * @param {string} path
56
+ * @returns {Record<string, string>|null}
57
+ */
58
+ export function matchPath(pattern, path) {
59
+ const patternParts = pattern.split('/').filter(Boolean);
60
+ const pathParts = path.split('/').filter(Boolean);
61
+ if (patternParts.length !== pathParts.length) return null;
62
+ /** @type {Record<string, string>} */
63
+ const params = {};
64
+ for (let i = 0; i < patternParts.length; i++) {
65
+ if (patternParts[i].charAt(0) === ':') {
66
+ params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
67
+ } else if (patternParts[i] !== pathParts[i]) {
68
+ return null;
69
+ }
70
+ }
71
+ return params;
72
+ }
73
+
74
+ /** The current route; components read route.value to re-render on navigation. */
75
+ export const route = signal(
76
+ parseHash(typeof location !== 'undefined' ? location.hash : ''),
77
+ );
78
+
79
+ /** Install the hashchange listener and sync the signal to the current hash. */
80
+ export function initRouter() {
81
+ window.addEventListener('hashchange', function () {
82
+ route.value = parseHash(location.hash);
83
+ });
84
+ route.value = parseHash(location.hash);
85
+ }
86
+
87
+ /**
88
+ * @param {string} path
89
+ * @param {Record<string, string|number|undefined|null>} [query]
90
+ */
91
+ export function navigate(path, query) {
92
+ location.hash = formatHash(path, query);
93
+ }
94
+
95
+ /**
96
+ * Replace the current route's query string without adding a history entry
97
+ * (used by list params so every keystroke doesn't pollute Back).
98
+ * @param {Record<string, string|number|undefined|null>} query
99
+ */
100
+ export function replaceQuery(query) {
101
+ const target = formatHash(route.value.path, query);
102
+ const url = location.pathname + location.search + target;
103
+ history.replaceState(null, '', url);
104
+ route.value = parseHash(target);
105
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * HTML sanitizer for markdown output (§7.8). snarkdown emits HTML; before it
3
+ * is inserted via dangerouslySetInnerHTML, strip anything that could run
4
+ * script: blocked elements, on* handler attributes, and javascript: URLs.
5
+ * Only <MarkdownView/> consumes this.
6
+ */
7
+
8
+ /** @type {Record<string, boolean>} */
9
+ const BLOCKED_TAGS = {
10
+ SCRIPT: true,
11
+ STYLE: true,
12
+ IFRAME: true,
13
+ OBJECT: true,
14
+ EMBED: true,
15
+ LINK: true,
16
+ META: true,
17
+ BASE: true,
18
+ FORM: true,
19
+ };
20
+
21
+ /** @type {Record<string, boolean>} */
22
+ const URL_ATTRS = {
23
+ href: true,
24
+ src: true,
25
+ 'xlink:href': true,
26
+ action: true,
27
+ formaction: true,
28
+ };
29
+
30
+ /**
31
+ * @param {string} dirty raw HTML from snarkdown
32
+ * @returns {string} sanitized HTML
33
+ */
34
+ export function sanitizeHtml(dirty) {
35
+ const doc = new DOMParser().parseFromString(dirty, 'text/html');
36
+ cleanElement(doc.body);
37
+ return doc.body.innerHTML;
38
+ }
39
+
40
+ /** @param {Element} root */
41
+ function cleanElement(root) {
42
+ // Snapshot children first: removing while iterating a live collection skips nodes.
43
+ const children = Array.prototype.slice.call(root.children);
44
+ for (let i = 0; i < children.length; i++) {
45
+ const el = children[i];
46
+ if (BLOCKED_TAGS[el.tagName]) {
47
+ el.parentNode && el.parentNode.removeChild(el);
48
+ continue;
49
+ }
50
+ const attrs = Array.prototype.slice.call(el.attributes);
51
+ for (let j = 0; j < attrs.length; j++) {
52
+ const name = attrs[j].name.toLowerCase();
53
+ if (name.indexOf('on') === 0) {
54
+ el.removeAttribute(attrs[j].name);
55
+ } else if (
56
+ URL_ATTRS[name] &&
57
+ /^\s*(javascript|vbscript|data):/i.test(attrs[j].value)
58
+ ) {
59
+ el.removeAttribute(attrs[j].name);
60
+ }
61
+ }
62
+ cleanElement(el);
63
+ }
64
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Client-side mirror of the backend slugifier (src/domain/slug.ts) for the
3
+ * live slug preview in the task form (§6.4). The server remains authoritative.
4
+ * @param {string} input
5
+ * @returns {string}
6
+ */
7
+ export function slugify(input) {
8
+ const slug = input
9
+ .normalize('NFKD')
10
+ .replace(/[̀-ͯ]/g, '') // strip diacritics after decomposition
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/-{2,}/g, '-')
14
+ .replace(/^-+|-+$/g, '');
15
+ return slug || 'task';
16
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Light/dark theme via data-theme on <html> + CSS custom properties (§7.3).
3
+ * index.html sets the attribute before first paint; this module owns it after.
4
+ */
5
+ import { signal } from '../../vendor/signals.js';
6
+
7
+ const STORAGE_KEY = 'smt-theme';
8
+
9
+ /** @returns {'light'|'dark'} */
10
+ function initialTheme() {
11
+ // B&G / Navico MFDs pass ?mode=night or ?mode=day instead of honoring
12
+ // prefers-color-scheme; the param overrides everything, including the
13
+ // saved preference. Must match the first-paint script in index.html.
14
+ const modeMatch = /[?&]mode=([^&]*)/.exec(window.location.search);
15
+ if (modeMatch) {
16
+ return decodeURIComponent(modeMatch[1]) === 'night' ? 'dark' : 'light';
17
+ }
18
+ let stored;
19
+ try {
20
+ stored = localStorage.getItem(STORAGE_KEY);
21
+ } catch {
22
+ stored = null; // storage can be blocked; fall through to media query
23
+ }
24
+ if (stored === 'light' || stored === 'dark') return stored;
25
+ // Progressive enhancement: prefers-color-scheme is Chrome 76+. On the
26
+ // Chromium 69 floor it never matches and we default to light (§7.9).
27
+ if (
28
+ typeof window !== 'undefined' &&
29
+ window.matchMedia &&
30
+ window.matchMedia('(prefers-color-scheme: dark)').matches
31
+ ) {
32
+ return 'dark';
33
+ }
34
+ return 'light';
35
+ }
36
+
37
+ /** @type {import('../../vendor/signals.js').Signal<'light'|'dark'>} */
38
+ export const theme = signal(initialTheme());
39
+
40
+ export function applyTheme() {
41
+ document.documentElement.setAttribute('data-theme', theme.value);
42
+ }
43
+
44
+ export function toggleTheme() {
45
+ theme.value = theme.value === 'dark' ? 'light' : 'dark';
46
+ try {
47
+ localStorage.setItem(STORAGE_KEY, theme.value);
48
+ } catch {
49
+ // non-fatal: theme just won't persist
50
+ }
51
+ applyTheme();
52
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Signal-backed toast queue (§7.2); rendered by <Toaster/> in the shell.
3
+ */
4
+ import { signal } from '../../vendor/signals.js';
5
+
6
+ /** @typedef {{ id: number, message: string, kind: 'info'|'success'|'error' }} Toast */
7
+
8
+ let nextId = 1;
9
+
10
+ /** @type {import('../../vendor/signals.js').Signal<Toast[]>} */
11
+ export const toasts = signal([]);
12
+
13
+ /**
14
+ * @param {string} message
15
+ * @param {'info'|'success'|'error'} [kind]
16
+ */
17
+ export function toast(message, kind) {
18
+ const id = nextId++;
19
+ toasts.value = toasts.value.concat([
20
+ { id: id, message: message, kind: kind || 'info' },
21
+ ]);
22
+ setTimeout(function () {
23
+ dismissToast(id);
24
+ }, 5000);
25
+ return id;
26
+ }
27
+
28
+ /** @param {number} id */
29
+ export function dismissToast(id) {
30
+ toasts.value = toasts.value.filter(function (t) {
31
+ return t.id !== id;
32
+ });
33
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * List state (search, tags, sort, page) lives in the hash query string so
3
+ * views are shareable and survive refresh (§7.4). Updates use replaceQuery so
4
+ * typing in a filter doesn't flood browser history.
5
+ */
6
+ import { route, replaceQuery } from './router.js';
7
+
8
+ /**
9
+ * @returns {{
10
+ * params: Record<string, string>,
11
+ * update: (patch: Record<string, string|number|undefined|null>) => void,
12
+ * }}
13
+ */
14
+ export function useListParams() {
15
+ const params = route.value.query;
16
+
17
+ /** @param {Record<string, string|number|undefined|null>} patch */
18
+ const update = (patch) => {
19
+ /** @type {Record<string, string|number|undefined|null>} */
20
+ const next = {};
21
+ const current = route.value.query;
22
+ for (const key of Object.keys(current)) next[key] = current[key];
23
+ for (const key of Object.keys(patch)) next[key] = patch[key];
24
+ replaceQuery(next);
25
+ };
26
+
27
+ return { params: params, update: update };
28
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * App root (§7.2): install the router, sync the theme, establish the auth
3
+ * session, and mount <App/> into #app.
4
+ *
5
+ * Importing the vendored @preact/signals integrates signal reads with Preact
6
+ * rendering; it happens transitively through every module that uses signals.
7
+ */
8
+ import { render } from '../vendor/preact.js';
9
+ import { html } from './lib/html.js';
10
+ import { App } from './app.js';
11
+ import { initRouter } from './lib/router.js';
12
+ import { applyTheme } from './lib/theme.js';
13
+ import { refreshLoginStatus } from './auth/auth.js';
14
+
15
+ initRouter();
16
+ applyTheme();
17
+ refreshLoginStatus();
18
+
19
+ const root = document.getElementById('app');
20
+ if (root) render(html`<${App} />`, root);
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Master log (§7.4): one row per log entry across all tasks. Server-side
3
+ * search/sort/pagination, truncated-but-expandable notes.
4
+ */
5
+ import { html } from '../lib/html.js';
6
+ import { useState, useEffect } from '../../vendor/preact-hooks.js';
7
+ import { useLogs } from '../api/hooks.js';
8
+ import { apiFetch, buildQuery } from '../api/client.js';
9
+ import { useListParams } from '../lib/useListParams.js';
10
+ import { formatDate, formatHours, truncate } from '../lib/format.js';
11
+ import { Table } from '../components/Table.js';
12
+ import { Pagination } from '../components/Pagination.js';
13
+ import { MarkdownView } from '../components/MarkdownView.js';
14
+
15
+ /** @typedef {import('../types.js').LogDTO} LogDTO */
16
+
17
+ const PAGE_SIZE = 25;
18
+ const NOTE_PREVIEW_CHARS = 120;
19
+ /** Server-side pageSize cap (see MAX_PAGE_SIZE in src/service.ts). */
20
+ const EXPORT_PAGE_SIZE = 200;
21
+
22
+ /** @param {string|number|null|undefined} value */
23
+ function csvField(value) {
24
+ const s = value === null || value === undefined ? '' : String(value);
25
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
26
+ }
27
+
28
+ /** @param {LogDTO[]} entries */
29
+ function buildCsv(entries) {
30
+ const rows = [['Task', 'Date', 'Runtime Hours', 'Logged By', 'Notes']];
31
+ for (const e of entries) {
32
+ rows.push([
33
+ csvField(e.task_name),
34
+ csvField(e.maintenance_date),
35
+ csvField(e.runtime_hours),
36
+ csvField(e.logged_by),
37
+ csvField(e.notes),
38
+ ]);
39
+ }
40
+ return rows.map((r) => r.join(',')).join('\r\n') + '\r\n';
41
+ }
42
+
43
+ /** Fetch every log entry, paging past the server's pageSize cap. */
44
+ async function fetchAllLogs() {
45
+ /** @type {LogDTO[]} */
46
+ const entries = [];
47
+ for (let page = 1; ; page += 1) {
48
+ /** @type {import('../types.js').Page<LogDTO>} */
49
+ const res = await apiFetch(
50
+ '/logs' + buildQuery({ page, pageSize: EXPORT_PAGE_SIZE }),
51
+ );
52
+ entries.push(...res.data);
53
+ if (res.data.length === 0 || entries.length >= res.total) break;
54
+ }
55
+ return entries;
56
+ }
57
+
58
+ export function MasterLogPage() {
59
+ const { params, update } = useListParams();
60
+
61
+ const page = parseInt(params.page || '1', 10) || 1;
62
+ const search = params.search || '';
63
+ const sort = params.sort || '';
64
+ const order = params.order || '';
65
+
66
+ const [searchText, setSearchText] = useState(search);
67
+ useEffect(() => {
68
+ setSearchText(search);
69
+ }, [search]);
70
+ useEffect(() => {
71
+ if (searchText === search) return undefined;
72
+ const timer = setTimeout(
73
+ () => update({ search: searchText, page: undefined }),
74
+ 300,
75
+ );
76
+ return () => clearTimeout(timer);
77
+ }, [searchText]);
78
+
79
+ const logsRes = useLogs({
80
+ search: search || undefined,
81
+ sort: sort || undefined,
82
+ order: order || undefined,
83
+ page: page,
84
+ pageSize: PAGE_SIZE,
85
+ });
86
+
87
+ const [downloading, setDownloading] = useState(false);
88
+ const [downloadError, setDownloadError] = useState(
89
+ /** @type {string|null} */ (null),
90
+ );
91
+ const downloadCsv = async () => {
92
+ if (downloading) return;
93
+ setDownloading(true);
94
+ setDownloadError(null);
95
+ try {
96
+ const entries = await fetchAllLogs();
97
+ const now = new Date();
98
+ /** @param {number} n */
99
+ const pad = (n) => String(n).padStart(2, '0');
100
+ const stamp =
101
+ now.getFullYear() +
102
+ '-' +
103
+ pad(now.getMonth() + 1) +
104
+ '-' +
105
+ pad(now.getDate());
106
+ const blob = new Blob([buildCsv(entries)], { type: 'text/csv' });
107
+ const url = URL.createObjectURL(blob);
108
+ const a = document.createElement('a');
109
+ a.href = url;
110
+ a.download = 'signalk-maintenance-log-' + stamp + '.csv';
111
+ document.body.appendChild(a);
112
+ a.click();
113
+ a.remove();
114
+ URL.revokeObjectURL(url);
115
+ } catch (err) {
116
+ setDownloadError(err instanceof Error ? err.message : 'Download failed');
117
+ } finally {
118
+ setDownloading(false);
119
+ }
120
+ };
121
+
122
+ const [expanded, setExpanded] = useState(
123
+ /** @type {Record<string, boolean>} */ ({}),
124
+ );
125
+ /** @param {number} id */
126
+ const toggleExpanded = (id) => {
127
+ /** @type {Record<string, boolean>} */
128
+ const next = {};
129
+ for (const key of Object.keys(expanded)) next[key] = expanded[key];
130
+ next[id] = !next[id];
131
+ setExpanded(next);
132
+ };
133
+
134
+ /** @param {string} key */
135
+ const onSort = (key) => {
136
+ if (sort === key) update({ order: order === 'asc' ? 'desc' : 'asc' });
137
+ else
138
+ update({ sort: key, order: key === 'maintenance_date' ? 'desc' : 'asc' });
139
+ };
140
+
141
+ /** @type {import('../components/Table.js').Column[]} */
142
+ const columns = [
143
+ {
144
+ key: 'task',
145
+ label: 'Task',
146
+ sortable: true,
147
+ render: (/** @type {LogDTO} */ e) =>
148
+ html`<a href=${'#/tasks/' + encodeURIComponent(e.task_slug)}
149
+ >${e.task_name}</a
150
+ >`,
151
+ },
152
+ {
153
+ key: 'maintenance_date',
154
+ label: 'Date',
155
+ sortable: true,
156
+ className: 'num',
157
+ render: (/** @type {LogDTO} */ e) => formatDate(e.maintenance_date),
158
+ },
159
+ {
160
+ key: 'runtime_hours',
161
+ label: 'Runtime',
162
+ sortable: true,
163
+ className: 'num',
164
+ render: (/** @type {LogDTO} */ e) => formatHours(e.runtime_hours),
165
+ },
166
+ {
167
+ key: 'logged_by',
168
+ label: 'By',
169
+ render: (/** @type {LogDTO} */ e) =>
170
+ e.logged_by || html`<span class="muted">—</span>`,
171
+ },
172
+ ];
173
+
174
+ /** Notes render on their own full-width row under the entry. @param {LogDTO} e */
175
+ const renderNotes = (e) => {
176
+ if (!e.notes) return null;
177
+ const isLong = e.notes.length > NOTE_PREVIEW_CHARS;
178
+ const body =
179
+ expanded[e.id] || !isLong
180
+ ? html`
181
+ <${MarkdownView} markdown=${e.notes} />
182
+ ${
183
+ isLong
184
+ ? html`<button
185
+ type="button"
186
+ class="btn-link"
187
+ onClick=${() => toggleExpanded(e.id)}
188
+ >
189
+ less
190
+ </button>`
191
+ : null
192
+ }
193
+ `
194
+ : html`
195
+ ${truncate(e.notes, NOTE_PREVIEW_CHARS)}${' '}
196
+ <button
197
+ type="button"
198
+ class="btn-link"
199
+ onClick=${() => toggleExpanded(e.id)}
200
+ >
201
+ more
202
+ </button>
203
+ `;
204
+ return html`<div class="log-notes">
205
+ <strong class="log-notes-label">Notes:</strong>
206
+ <div class="log-notes-body">${body}</div>
207
+ </div>`;
208
+ };
209
+
210
+ const pageData = logsRes.data;
211
+
212
+ return html`
213
+ <div>
214
+ <div class="page-header">
215
+ <h1 class="page-title">Maintenance Log</h1>
216
+ <button
217
+ type="button"
218
+ class="btn btn-primary"
219
+ disabled=${downloading}
220
+ onClick=${downloadCsv}
221
+ >
222
+ <i
223
+ class="bi bi-download"
224
+ />${downloading ? 'Preparing…' : 'Download Log'}
225
+ </button>
226
+ </div>
227
+
228
+ ${
229
+ downloadError
230
+ ? html`<div class="error-box">
231
+ Failed to download log: ${downloadError}
232
+ </div>`
233
+ : null
234
+ }
235
+
236
+ <div class="toolbar">
237
+ <div class="search-box">
238
+ <i class="bi bi-search" />
239
+ <input
240
+ class="input"
241
+ placeholder="Search log…"
242
+ aria-label="Search log"
243
+ value=${searchText}
244
+ onInput=${(/** @type {any} */ e) => setSearchText(e.currentTarget.value)}
245
+ />
246
+ </div>
247
+ </div>
248
+
249
+ ${
250
+ logsRes.error && !pageData
251
+ ? html`<div class="error-box">
252
+ Failed to load log: ${logsRes.error.message}
253
+ </div>`
254
+ : html`
255
+ <${Table}
256
+ columns=${columns}
257
+ rows=${pageData ? pageData.data : []}
258
+ renderDetail=${renderNotes}
259
+ sort=${sort}
260
+ order=${order}
261
+ onSort=${onSort}
262
+ loading=${logsRes.loading}
263
+ emptyMessage=${search ? 'No log entries match your search.' : 'No maintenance logged yet.'}
264
+ />
265
+ ${
266
+ pageData
267
+ ? html`<${Pagination}
268
+ page=${pageData.page}
269
+ pageSize=${pageData.pageSize}
270
+ total=${pageData.total}
271
+ onPage=${(/** @type {number} */ p) => update({ page: p })}
272
+ />`
273
+ : null
274
+ }
275
+ `
276
+ }
277
+ </div>
278
+ `;
279
+ }