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
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "signalk-maintenance-tracker",
3
+ "version": "1.0.0",
4
+ "description": "Track recurring boat maintenance tasks with runtime and calendar based intervals.",
5
+ "main": "dist/index.js",
6
+ "license": "MIT",
7
+ "author": "Zach Hoeken <hoeken@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/hoeken/signalk-maintenance-tracker"
11
+ },
12
+ "keywords": [
13
+ "signalk-node-server-plugin",
14
+ "signalk-webapp",
15
+ "signalk-category-utility",
16
+ "signalk",
17
+ "maintenance",
18
+ "boat"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "watch:backend": "tsc -w",
23
+ "typecheck:frontend": "npm --prefix frontend run typecheck",
24
+ "test:backend": "vitest run",
25
+ "test:frontend": "npm --prefix frontend run test",
26
+ "test": "npm run test:backend && npm run test:frontend",
27
+ "icons": "npm --prefix frontend run icons",
28
+ "clean": "rm -rf dist",
29
+ "lint": "eslint .",
30
+ "lint:fix": "eslint --fix .",
31
+ "format": "eslint --fix . && prettier --write .",
32
+ "format:check": "eslint . && prettier --check .",
33
+ "prepublishOnly": "npm run build",
34
+ "release": "git tag -d v$npm_package_version; git tag v$npm_package_version && git push && git push origin v$npm_package_version",
35
+ "prepare": "husky"
36
+ },
37
+ "lint-staged": {
38
+ "*.{ts,js,mjs,cjs}": [
39
+ "eslint --fix",
40
+ "prettier --write"
41
+ ],
42
+ "*.{json,css,html,md,yml,yaml}": "prettier --write"
43
+ },
44
+ "signalk": {
45
+ "displayName": "Maintenance Tracker",
46
+ "appIcon": "icons/icon-192.png",
47
+ "screenshots": [
48
+ "./docs/screenshots/homepage.png",
49
+ "./docs/screenshots/dark-mode.png",
50
+ "./docs/screenshots/task-detail.png",
51
+ "./docs/screenshots/edit-task.png",
52
+ "./docs/screenshots/completed.png",
53
+ "./docs/screenshots/main-log.png"
54
+ ]
55
+ },
56
+ "files": [
57
+ "dist/",
58
+ "public/",
59
+ "docs/screenshots/",
60
+ "CHANGELOG.md"
61
+ ],
62
+ "engines": {
63
+ "node": ">=22.5"
64
+ },
65
+ "devDependencies": {
66
+ "@eslint/js": "^10.0.1",
67
+ "@types/express": "^4.17.21",
68
+ "@types/node": "^24.0.0",
69
+ "@types/supertest": "^6.0.0",
70
+ "eslint": "^10.6.0",
71
+ "eslint-config-prettier": "^10.1.8",
72
+ "express": "^4.19.0",
73
+ "globals": "^17.7.0",
74
+ "husky": "^9.1.7",
75
+ "lint-staged": "^17.0.8",
76
+ "prettier": "^3.9.4",
77
+ "supertest": "^7.0.0",
78
+ "typescript": "^5.5.0",
79
+ "typescript-eslint": "^8.63.0",
80
+ "vitest": "^3.0.0"
81
+ }
82
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Thin fetch wrapper for the plugin REST API (§7.6). Prefixes the base path,
3
+ * sends the same-origin session cookie, normalizes JSON + errors, and routes
4
+ * 401/403 into the auth layer.
5
+ */
6
+ import { onUnauthorized } from '../auth/auth.js';
7
+
8
+ export const API_BASE = '/plugins/signalk-maintenance-tracker/api';
9
+
10
+ export class ApiError extends Error {
11
+ /**
12
+ * @param {number} status
13
+ * @param {string} code
14
+ * @param {string} message
15
+ */
16
+ constructor(status, code, message) {
17
+ super(message);
18
+ this.name = 'ApiError';
19
+ this.status = status;
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * @param {string} path e.g. '/tasks?page=1'
26
+ * @param {{ method?: string, body?: unknown }} [options]
27
+ * @returns {Promise<any>} parsed JSON body (null for 204)
28
+ */
29
+ export async function apiFetch(path, options) {
30
+ const opts = options || {};
31
+ /** @type {RequestInit} */
32
+ const init = { method: opts.method || 'GET', credentials: 'same-origin' };
33
+ if (opts.body !== undefined) {
34
+ init.headers = { 'Content-Type': 'application/json' };
35
+ init.body = JSON.stringify(opts.body);
36
+ }
37
+ const res = await fetch(API_BASE + path, init);
38
+ if (res.status === 401 || res.status === 403) onUnauthorized();
39
+ const text = await res.text();
40
+ /** @type {any} */
41
+ let body = null;
42
+ if (text) {
43
+ try {
44
+ body = JSON.parse(text);
45
+ } catch {
46
+ body = null;
47
+ }
48
+ }
49
+ if (!res.ok) {
50
+ const errInfo = body && body.error ? body.error : {};
51
+ throw new ApiError(
52
+ res.status,
53
+ errInfo.code || 'http_' + res.status,
54
+ errInfo.message || 'Request failed (' + res.status + ')',
55
+ );
56
+ }
57
+ return body;
58
+ }
59
+
60
+ /**
61
+ * Build a query string from params, skipping empty values.
62
+ * @param {Record<string, string|number|undefined|null>} [params]
63
+ * @returns {string} '' or '?a=b&c=d'
64
+ */
65
+ export function buildQuery(params) {
66
+ if (!params) return '';
67
+ const usp = new URLSearchParams();
68
+ for (const key of Object.keys(params)) {
69
+ const value = params[key];
70
+ if (value !== undefined && value !== null && value !== '')
71
+ usp.set(key, String(value));
72
+ }
73
+ const qs = usp.toString();
74
+ return qs ? '?' + qs : '';
75
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Signals-backed resource hooks + mutations over the plugin REST API (§7.6).
3
+ * Mutations invalidate the affected keys on success so the UI updates
4
+ * immediately instead of waiting for the next poll.
5
+ */
6
+ import { apiFetch, buildQuery } from './client.js';
7
+ import { useResource, invalidate } from './resource.js';
8
+
9
+ /** @typedef {import('../types.js').TaskDTO} TaskDTO */
10
+ /** @typedef {import('../types.js').LogDTO} LogDTO */
11
+ /** @typedef {import('../types.js').TagDTO} TagDTO */
12
+ /** @typedef {import('../types.js').TaskInput} TaskInput */
13
+ /** @typedef {import('../types.js').LogInput} LogInput */
14
+
15
+ /** Default poll interval for live-updating lists (§7.6). */
16
+ export const POLL_MS = 5000;
17
+
18
+ /** @param {string} slug */
19
+ function encodeSlug(slug) {
20
+ return encodeURIComponent(slug);
21
+ }
22
+
23
+ /**
24
+ * @param {Record<string, string|number|undefined|null>} [params]
25
+ * @returns {import('./resource.js').ResourceState<import('../types.js').Page<TaskDTO>>}
26
+ */
27
+ export function useTasks(params) {
28
+ const query = buildQuery(params);
29
+ return useResource('tasks' + query, () => apiFetch('/tasks' + query), {
30
+ refetchInterval: POLL_MS,
31
+ });
32
+ }
33
+
34
+ /**
35
+ * @param {string} slug
36
+ * @returns {import('./resource.js').ResourceState<TaskDTO>}
37
+ */
38
+ export function useTask(slug) {
39
+ return useResource(
40
+ 'task/' + slug,
41
+ () => apiFetch('/tasks/' + encodeSlug(slug)),
42
+ {
43
+ refetchInterval: POLL_MS,
44
+ },
45
+ );
46
+ }
47
+
48
+ /**
49
+ * @param {string} slug
50
+ * @returns {import('./resource.js').ResourceState<{data: LogDTO[]}>}
51
+ */
52
+ export function useTaskLogs(slug) {
53
+ return useResource(
54
+ 'task/' + slug + '/logs',
55
+ () => apiFetch('/tasks/' + encodeSlug(slug) + '/logs'),
56
+ {
57
+ refetchInterval: POLL_MS,
58
+ },
59
+ );
60
+ }
61
+
62
+ /**
63
+ * @param {Record<string, string|number|undefined|null>} [params]
64
+ * @returns {import('./resource.js').ResourceState<import('../types.js').Page<LogDTO>>}
65
+ */
66
+ export function useLogs(params) {
67
+ const query = buildQuery(params);
68
+ return useResource('logs' + query, () => apiFetch('/logs' + query), {
69
+ refetchInterval: POLL_MS,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * @returns {import('./resource.js').ResourceState<{data: TagDTO[]}>}
75
+ */
76
+ export function useTags() {
77
+ return useResource('tags', () => apiFetch('/tags'), {
78
+ refetchInterval: POLL_MS,
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Plugin health/version (§8.5). Fetched once; the version can't change
84
+ * without a server restart, so no polling.
85
+ * @returns {import('./resource.js').ResourceState<{version: string}>}
86
+ */
87
+ export function useHealth() {
88
+ return useResource('health', () => apiFetch('/health'));
89
+ }
90
+
91
+ // ---- mutations ----
92
+
93
+ /**
94
+ * @param {TaskInput} input
95
+ * @returns {Promise<TaskDTO>}
96
+ */
97
+ export async function createTask(input) {
98
+ const task = await apiFetch('/tasks', { method: 'POST', body: input });
99
+ invalidate('tasks', 'tags');
100
+ return task;
101
+ }
102
+
103
+ /**
104
+ * @param {string} slug
105
+ * @param {TaskInput} input
106
+ * @returns {Promise<TaskDTO>}
107
+ */
108
+ export async function updateTask(slug, input) {
109
+ const task = await apiFetch('/tasks/' + encodeSlug(slug), {
110
+ method: 'PUT',
111
+ body: input,
112
+ });
113
+ invalidate('tasks', 'task/' + slug, 'tags', 'logs');
114
+ if (task && task.slug !== slug) invalidate('task/' + task.slug);
115
+ return task;
116
+ }
117
+
118
+ /** @param {string} slug */
119
+ export async function deleteTask(slug) {
120
+ await apiFetch('/tasks/' + encodeSlug(slug), { method: 'DELETE' });
121
+ invalidate('tasks', 'task/' + slug, 'tags', 'logs');
122
+ }
123
+
124
+ /**
125
+ * Mark complete (§7.5): create a log entry for the task.
126
+ * @param {string} slug
127
+ * @param {LogInput} input
128
+ * @returns {Promise<LogDTO>}
129
+ */
130
+ export async function addLog(slug, input) {
131
+ const entry = await apiFetch('/tasks/' + encodeSlug(slug) + '/logs', {
132
+ method: 'POST',
133
+ body: input,
134
+ });
135
+ invalidate('tasks', 'task/' + slug, 'logs');
136
+ return entry;
137
+ }
138
+
139
+ /**
140
+ * @param {number} id
141
+ * @param {LogInput} input
142
+ * @returns {Promise<LogDTO>}
143
+ */
144
+ export async function updateLog(id, input) {
145
+ const entry = await apiFetch('/logs/' + id, { method: 'PUT', body: input });
146
+ invalidate('tasks', 'logs');
147
+ if (entry && entry.task_slug) invalidate('task/' + entry.task_slug);
148
+ return entry;
149
+ }
150
+
151
+ /**
152
+ * @param {number} id
153
+ * @param {string} [taskSlug] invalidates that task's detail/logs when known
154
+ */
155
+ export async function deleteLog(id, taskSlug) {
156
+ await apiFetch('/logs/' + id, { method: 'DELETE' });
157
+ invalidate('tasks', 'logs');
158
+ if (taskSlug) invalidate('task/' + taskSlug);
159
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Hand-rolled signals-backed resource layer (§7.6): cache-by-key, polling
3
+ * while subscribed, and invalidation. The essentials of react-query in a few
4
+ * dozen lines, with no post-Chromium-69 runtime APIs.
5
+ */
6
+ import { signal } from '../../vendor/signals.js';
7
+ import { useEffect } from '../../vendor/preact-hooks.js';
8
+
9
+ /**
10
+ * @template T
11
+ * @typedef {{ data: T|null, error: Error|null, loading: boolean }} ResourceState
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} Entry
16
+ * @property {string} key
17
+ * @property {() => Promise<any>} fetcher
18
+ * @property {number} refetchInterval 0 = never poll
19
+ * @property {import('../../vendor/signals.js').Signal<ResourceState<any>>} state
20
+ * @property {number} subscribers
21
+ * @property {ReturnType<typeof setInterval>|null} timer
22
+ * @property {number} fetchId
23
+ * @property {boolean} fetched
24
+ */
25
+
26
+ /** @type {Map<string, Entry>} */
27
+ const registry = new Map();
28
+
29
+ /**
30
+ * @param {string} key
31
+ * @param {() => Promise<any>} fetcher
32
+ * @param {{ refetchInterval?: number }} [options]
33
+ * @returns {Entry}
34
+ */
35
+ function getEntry(key, fetcher, options) {
36
+ let entry = registry.get(key);
37
+ if (!entry) {
38
+ entry = {
39
+ key: key,
40
+ fetcher: fetcher,
41
+ refetchInterval:
42
+ options && options.refetchInterval ? options.refetchInterval : 0,
43
+ state: signal({ data: null, error: null, loading: true }),
44
+ subscribers: 0,
45
+ timer: null,
46
+ fetchId: 0,
47
+ fetched: false,
48
+ };
49
+ registry.set(key, entry);
50
+ }
51
+ entry.fetcher = fetcher; // latest closure wins (params live in the key)
52
+ return entry;
53
+ }
54
+
55
+ /** @param {Entry} entry */
56
+ function runFetch(entry) {
57
+ const id = ++entry.fetchId;
58
+ const prev = entry.state.value;
59
+ entry.state.value = { data: prev.data, error: prev.error, loading: true };
60
+ entry.fetched = true;
61
+ entry.fetcher().then(
62
+ function (data) {
63
+ if (id === entry.fetchId)
64
+ entry.state.value = { data: data, error: null, loading: false };
65
+ },
66
+ function (error) {
67
+ // keep stale data visible; surface the error alongside it
68
+ if (id === entry.fetchId)
69
+ entry.state.value = { data: prev.data, error: error, loading: false };
70
+ },
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Subscribe to a keyed resource for the lifetime of the calling component.
76
+ * Fetches on first subscribe, polls (shared per key) while any subscriber is
77
+ * mounted, and re-fetches when a component subscribes to an idle key again.
78
+ *
79
+ * Reading the returned state inside render subscribes the component via
80
+ * @preact/signals, so it re-renders on data/error/loading changes.
81
+ *
82
+ * @template T
83
+ * @param {string} key
84
+ * @param {() => Promise<T>} fetcher
85
+ * @param {{ refetchInterval?: number }} [options]
86
+ * @returns {ResourceState<T>}
87
+ */
88
+ export function useResource(key, fetcher, options) {
89
+ const entry = getEntry(key, fetcher, options);
90
+ useEffect(
91
+ function () {
92
+ entry.subscribers++;
93
+ if (entry.subscribers === 1) {
94
+ if (!entry.fetched || !entry.state.value.loading) runFetch(entry);
95
+ if (entry.refetchInterval > 0 && !entry.timer) {
96
+ entry.timer = setInterval(function () {
97
+ runFetch(entry);
98
+ }, entry.refetchInterval);
99
+ }
100
+ }
101
+ return function () {
102
+ entry.subscribers--;
103
+ if (entry.subscribers <= 0 && entry.timer) {
104
+ clearInterval(entry.timer);
105
+ entry.timer = null;
106
+ }
107
+ };
108
+ },
109
+ [key],
110
+ );
111
+ return entry.state.value;
112
+ }
113
+
114
+ /**
115
+ * Invalidate resources by key prefix: live ones (with subscribers) refetch
116
+ * immediately; idle ones are dropped so their next mount refetches.
117
+ * 'tasks' matches 'tasks' and 'tasks?…' but not 'task/…'.
118
+ * @param {...string} prefixes
119
+ */
120
+ export function invalidate() {
121
+ const prefixes = Array.prototype.slice.call(arguments);
122
+ const keys = Array.from(registry.keys());
123
+ for (const key of keys) {
124
+ const hit = prefixes.some(function (prefix) {
125
+ return (
126
+ key === prefix ||
127
+ key.indexOf(prefix + '?') === 0 ||
128
+ key.indexOf(prefix + '/') === 0
129
+ );
130
+ });
131
+ if (!hit) continue;
132
+ const entry = registry.get(key);
133
+ if (!entry) continue;
134
+ if (entry.subscribers > 0) runFetch(entry);
135
+ else registry.delete(key);
136
+ }
137
+ }
138
+
139
+ /** Refetch/drop everything (used after login, §7.7). */
140
+ export function invalidateAll() {
141
+ const keys = Array.from(registry.keys());
142
+ for (const key of keys) {
143
+ const entry = registry.get(key);
144
+ if (!entry) continue;
145
+ if (entry.subscribers > 0) runFetch(entry);
146
+ else registry.delete(key);
147
+ }
148
+ }
149
+
150
+ /** Test helper: forget all cached resources. */
151
+ export function resetResources() {
152
+ for (const entry of registry.values()) {
153
+ if (entry.timer) clearInterval(entry.timer);
154
+ }
155
+ registry.clear();
156
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * SignalK runtime-path discovery for the task editor (§8.4). Fetches the
3
+ * vessels/self snapshot ONCE per app session (lazily, on first task-editor
4
+ * open), flattens it to candidate path names, and serves autocomplete from
5
+ * the cached list. Never a source of runtime *values* — those come from the
6
+ * plugin API as current_runtime.
7
+ */
8
+ import { useResource } from './resource.js';
9
+
10
+ /**
11
+ * Keys that are leaf metadata, not path segments, in the REST snapshot.
12
+ * @type {Record<string, boolean>}
13
+ */
14
+ const NON_PATH_KEYS = {
15
+ value: true,
16
+ values: true,
17
+ timestamp: true,
18
+ $source: true,
19
+ source: true,
20
+ meta: true,
21
+ pgn: true,
22
+ sentence: true,
23
+ };
24
+
25
+ /**
26
+ * Flatten a vessels/self REST snapshot into dotted candidate paths. A node
27
+ * carrying a `value` key is a leaf; only numeric (or still-null) leaves are
28
+ * candidates for a runtime path.
29
+ * @param {any} node
30
+ * @param {string} [prefix]
31
+ * @param {string[]} [out]
32
+ * @returns {string[]} sorted candidate paths
33
+ */
34
+ export function flattenPaths(node, prefix, out) {
35
+ const paths = out || [];
36
+ if (!node || typeof node !== 'object') return paths;
37
+ if (Object.prototype.hasOwnProperty.call(node, 'value')) {
38
+ if (prefix && (typeof node.value === 'number' || node.value === null))
39
+ paths.push(prefix);
40
+ return paths;
41
+ }
42
+ for (const key of Object.keys(node)) {
43
+ if (NON_PATH_KEYS[key]) continue;
44
+ const child = node[key];
45
+ if (!child || typeof child !== 'object') continue; // identity strings like uuid/mmsi
46
+ flattenPaths(child, prefix ? prefix + '.' + key : key, paths);
47
+ }
48
+ if (!prefix) paths.sort();
49
+ return paths;
50
+ }
51
+
52
+ /**
53
+ * @returns {import('./resource.js').ResourceState<string[]>}
54
+ */
55
+ export function useSignalKPaths() {
56
+ // No refetchInterval: fetched once and cached for the session (§8.4).
57
+ return useResource('sk-paths', async () => {
58
+ const res = await fetch('/signalk/v1/api/vessels/self', {
59
+ credentials: 'same-origin',
60
+ });
61
+ if (!res.ok)
62
+ throw new Error('SignalK snapshot failed (' + res.status + ')');
63
+ const snapshot = await res.json();
64
+ return flattenPaths(snapshot);
65
+ });
66
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * App shell (§7.2): header (title, nav, theme toggle), route switch, footer
3
+ * (plugin links, auth control), toaster, and the globally-mounted login modal.
4
+ */
5
+ import { html } from './lib/html.js';
6
+ import { route, matchPath } from './lib/router.js';
7
+ import { useHealth } from './api/hooks.js';
8
+ import { Toaster } from './components/Toaster.js';
9
+ import { ThemeToggle } from './components/ThemeToggle.js';
10
+ import { AuthControl } from './components/AuthControl.js';
11
+ import { LoginModal } from './components/LoginModal.js';
12
+ import { TaskListPage } from './pages/TaskListPage.js';
13
+ import { TaskDetailPage } from './pages/TaskDetailPage.js';
14
+ import { MasterLogPage } from './pages/MasterLogPage.js';
15
+
16
+ export function App() {
17
+ const current = route.value;
18
+ const detailParams = matchPath('/tasks/:slug', current.path);
19
+ const health = useHealth();
20
+ const version = health.data && health.data.version;
21
+
22
+ let page;
23
+ if (detailParams) {
24
+ page = html`<${TaskDetailPage}
25
+ slug=${detailParams.slug}
26
+ key=${detailParams.slug}
27
+ />`;
28
+ } else if (current.path === '/log') {
29
+ page = html`<${MasterLogPage} />`;
30
+ } else {
31
+ page = html`<${TaskListPage} />`;
32
+ }
33
+
34
+ return html`
35
+ <div class="shell">
36
+ <header class="shell-header">
37
+ <a class="shell-title" href="#/">Maintenance Tracker</a>
38
+ <nav class="shell-nav">
39
+ <a
40
+ class=${'nav-link' + (!detailParams && current.path !== '/log' ? ' active' : '')}
41
+ href="#/"
42
+ >Tasks</a
43
+ >
44
+ <a
45
+ class=${'nav-link' + (current.path === '/log' ? ' active' : '')}
46
+ href="#/log"
47
+ >Log</a
48
+ >
49
+ </nav>
50
+ <${ThemeToggle} />
51
+ </header>
52
+ <main class="shell-main">${page}</main>
53
+ <footer class="shell-footer">
54
+ <div class="shell-footer-links">
55
+ <a
56
+ href="https://www.npmjs.com/package/signalk-maintenance-tracker"
57
+ target="_blank"
58
+ rel="noopener"
59
+ >signalk-maintenance-tracker</a
60
+ >
61
+ ${version && html`<span class="dot-spacer">·</span><span>v${version}</span>`}
62
+ </div>
63
+ <div class="shell-footer-auth">
64
+ <${AuthControl} />
65
+ </div>
66
+ </footer>
67
+ <${Toaster} />
68
+ <${LoginModal} />
69
+ </div>
70
+ `;
71
+ }