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