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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +201 -0
- package/README.md +75 -0
- package/dist/api/router.js +193 -0
- package/dist/api/router.js.map +1 -0
- package/dist/auth.js +22 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/db/database.js +47 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/logs.repo.js +91 -0
- package/dist/db/logs.repo.js.map +1 -0
- package/dist/db/migrations.js +57 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/tags.repo.js +78 -0
- package/dist/db/tags.repo.js.map +1 -0
- package/dist/db/tasks.repo.js +78 -0
- package/dist/db/tasks.repo.js.map +1 -0
- package/dist/domain/slug.js +27 -0
- package/dist/domain/slug.js.map +1 -0
- package/dist/domain/status.js +110 -0
- package/dist/domain/status.js.map +1 -0
- package/dist/index.js +130 -0
- package/dist/index.js.map +1 -0
- package/dist/service.js +370 -0
- package/dist/service.js.map +1 -0
- package/dist/signalk/notifications.js +115 -0
- package/dist/signalk/notifications.js.map +1 -0
- package/dist/signalk/runtime.js +103 -0
- package/dist/signalk/runtime.js.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/docs/screenshots/completed.png +0 -0
- package/docs/screenshots/dark-mode.png +0 -0
- package/docs/screenshots/edit-task.png +0 -0
- package/docs/screenshots/homepage.png +0 -0
- package/docs/screenshots/main-log.png +0 -0
- package/docs/screenshots/task-detail.png +0 -0
- package/package.json +82 -0
- package/public/app/api/client.js +75 -0
- package/public/app/api/hooks.js +159 -0
- package/public/app/api/resource.js +156 -0
- package/public/app/api/signalkPaths.js +66 -0
- package/public/app/app.js +71 -0
- package/public/app/auth/auth.js +158 -0
- package/public/app/components/AuthControl.js +22 -0
- package/public/app/components/ConfirmModal.js +55 -0
- package/public/app/components/LogEntryModal.js +150 -0
- package/public/app/components/LoginModal.js +86 -0
- package/public/app/components/MarkdownView.js +17 -0
- package/public/app/components/Modal.js +89 -0
- package/public/app/components/Pagination.js +35 -0
- package/public/app/components/PathPicker.js +66 -0
- package/public/app/components/ProgressBar.js +26 -0
- package/public/app/components/StatusBadge.js +9 -0
- package/public/app/components/Table.js +99 -0
- package/public/app/components/TagInput.js +93 -0
- package/public/app/components/TaskFormModal.js +310 -0
- package/public/app/components/ThemeToggle.js +17 -0
- package/public/app/components/Toaster.js +24 -0
- package/public/app/lib/format.js +90 -0
- package/public/app/lib/html.js +6 -0
- package/public/app/lib/router.js +105 -0
- package/public/app/lib/sanitize.js +64 -0
- package/public/app/lib/slug.js +16 -0
- package/public/app/lib/theme.js +52 -0
- package/public/app/lib/toasts.js +33 -0
- package/public/app/lib/useListParams.js +28 -0
- package/public/app/main.js +20 -0
- package/public/app/pages/MasterLogPage.js +279 -0
- package/public/app/pages/TaskDetailPage.js +294 -0
- package/public/app/pages/TaskListPage.js +279 -0
- package/public/app/types.js +90 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/favicon.ico +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/index.html +82 -0
- package/public/manifest.webmanifest +37 -0
- package/public/styles/main.css +787 -0
- package/public/styles/tokens.css +55 -0
- package/public/vendor/VERSIONS.json +9 -0
- package/public/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/public/vendor/dayjs/constant.js +25 -0
- package/public/vendor/dayjs/index.d.ts +1 -0
- package/public/vendor/dayjs/index.js +541 -0
- package/public/vendor/dayjs/locale/en.js +12 -0
- package/public/vendor/dayjs/utils.js +58 -0
- package/public/vendor/htm.d.ts +1 -0
- package/public/vendor/htm.js +1 -0
- package/public/vendor/preact-hooks.d.ts +1 -0
- package/public/vendor/preact-hooks.js +2 -0
- package/public/vendor/preact.d.ts +1 -0
- package/public/vendor/preact.js +2 -0
- package/public/vendor/signals-core.d.ts +1 -0
- package/public/vendor/signals-core.js +1 -0
- package/public/vendor/signals.d.ts +1 -0
- package/public/vendor/signals.js +1 -0
- package/public/vendor/snarkdown.d.ts +1 -0
- 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
|
+
}
|