signalk-maintenance-tracker 1.2.0 → 1.4.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 +82 -0
- package/README.md +1 -1
- package/dist/api/router.js +1 -1
- package/dist/api/router.js.map +1 -1
- package/dist/db/migrations.js +9 -0
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/tasks.repo.js +7 -6
- package/dist/db/tasks.repo.js.map +1 -1
- package/dist/domain/status.js +12 -8
- package/dist/domain/status.js.map +1 -1
- package/dist/service.js +23 -0
- package/dist/service.js.map +1 -1
- package/dist/signalk/notifications.js +3 -3
- package/dist/signalk/notifications.js.map +1 -1
- package/dist/types.js +2 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/public/app/api/stowage.js +12 -0
- package/public/app/components/MarkdownView.js +7 -3
- package/public/app/components/TaskFormModal.js +2 -1
- package/public/app/lib/autolink.js +136 -0
- package/public/app/lib/format.js +20 -3
- package/public/app/lib/softbreak.js +163 -0
- package/public/app/pages/TaskDetailPage.js +34 -6
- package/public/app/pages/TaskListPage.js +70 -22
- package/public/app/types.js +3 -1
- package/public/styles/main.css +32 -7
- package/public/styles/tokens.css +6 -4
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare-URL autolinking for markdown output (§7.8). snarkdown only links
|
|
3
|
+
* explicit [text](url), so a note that just pastes a URL renders as dead text.
|
|
4
|
+
* This turns http(s):// and www. runs in the text into anchors.
|
|
5
|
+
*
|
|
6
|
+
* Runs *after* sanitizeHtml() on already-clean HTML: every href here is built
|
|
7
|
+
* from a match that must start with http://, https:// or www., so this pass
|
|
8
|
+
* cannot reintroduce a javascript: URL. Text inside existing links and inside
|
|
9
|
+
* code is left alone. Only <MarkdownView/> consumes this.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Bare URL run: everything up to whitespace or a tag; trimmed below. */
|
|
13
|
+
const URL_RE = /(?:https?:\/\/|www\.)[^\s<]+/gi;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Elements whose text must not be linkified.
|
|
17
|
+
* @type {Record<string, boolean>}
|
|
18
|
+
*/
|
|
19
|
+
const SKIP_TAGS = {
|
|
20
|
+
A: true,
|
|
21
|
+
CODE: true,
|
|
22
|
+
PRE: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Sentence punctuation that follows a URL far more often than it ends one. */
|
|
26
|
+
const TRAILING = '.,;:!?\'"';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} clean sanitized HTML from sanitizeHtml()
|
|
30
|
+
* @returns {string} the same HTML with bare URLs wrapped in anchors
|
|
31
|
+
*/
|
|
32
|
+
export function autolinkHtml(clean) {
|
|
33
|
+
const doc = new DOMParser().parseFromString(clean, 'text/html');
|
|
34
|
+
autolinkNode(doc.body);
|
|
35
|
+
return doc.body.innerHTML;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {Node} parent */
|
|
39
|
+
function autolinkNode(parent) {
|
|
40
|
+
// Snapshot: linkifying replaces text nodes with fragments as we walk.
|
|
41
|
+
const children = Array.prototype.slice.call(parent.childNodes);
|
|
42
|
+
for (let i = 0; i < children.length; i++) {
|
|
43
|
+
const node = children[i];
|
|
44
|
+
if (node.nodeType === 3) {
|
|
45
|
+
linkifyText(/** @type {Text} */ (node));
|
|
46
|
+
} else if (
|
|
47
|
+
node.nodeType === 1 &&
|
|
48
|
+
!SKIP_TAGS[/** @type {Element} */ (node).tagName]
|
|
49
|
+
) {
|
|
50
|
+
autolinkNode(node);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Replace one text node with its text plus <a> elements for any URLs in it.
|
|
57
|
+
* @param {Text} node
|
|
58
|
+
*/
|
|
59
|
+
function linkifyText(node) {
|
|
60
|
+
const text = node.nodeValue || '';
|
|
61
|
+
const doc = node.ownerDocument;
|
|
62
|
+
if (!doc) return;
|
|
63
|
+
let frag = null;
|
|
64
|
+
let last = 0;
|
|
65
|
+
let match;
|
|
66
|
+
URL_RE.lastIndex = 0;
|
|
67
|
+
while ((match = URL_RE.exec(text)) !== null) {
|
|
68
|
+
const url = trimTrailing(match[0]);
|
|
69
|
+
// Resume after what we kept, so trimmed punctuation stays plain text.
|
|
70
|
+
URL_RE.lastIndex = match.index + url.length;
|
|
71
|
+
if (!url) continue;
|
|
72
|
+
if (!frag) frag = doc.createDocumentFragment();
|
|
73
|
+
if (match.index > last) {
|
|
74
|
+
frag.appendChild(doc.createTextNode(text.slice(last, match.index)));
|
|
75
|
+
}
|
|
76
|
+
const href = url.indexOf('www.') === 0 ? 'https://' + url : url;
|
|
77
|
+
const a = doc.createElement('a');
|
|
78
|
+
a.setAttribute('href', href);
|
|
79
|
+
// The host is the readable part; the full URL stays on hover.
|
|
80
|
+
a.setAttribute('title', href);
|
|
81
|
+
a.textContent = hostnameOf(href) || url;
|
|
82
|
+
frag.appendChild(a);
|
|
83
|
+
last = match.index + url.length;
|
|
84
|
+
}
|
|
85
|
+
if (!frag) return;
|
|
86
|
+
if (last < text.length) {
|
|
87
|
+
frag.appendChild(doc.createTextNode(text.slice(last)));
|
|
88
|
+
}
|
|
89
|
+
node.parentNode && node.parentNode.replaceChild(frag, node);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The host is what identifies a link to a reader; a pasted path is usually
|
|
94
|
+
* noise ("example.com" beats a 90-character tracking URL). Returns '' if the
|
|
95
|
+
* URL will not parse, and the caller falls back to showing it whole.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} href
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function hostnameOf(href) {
|
|
101
|
+
try {
|
|
102
|
+
return new URL(href).hostname;
|
|
103
|
+
} catch (_e) {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Drop punctuation a sentence left on the end of a URL: "see https://x.com."
|
|
110
|
+
* A closing paren only counts as the URL's when the URL opened one, so both
|
|
111
|
+
* "(see https://x.com)" and "https://x.com/a_(b)" come out right.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} url
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function trimTrailing(url) {
|
|
117
|
+
let end = url.length;
|
|
118
|
+
while (end > 0) {
|
|
119
|
+
const ch = url.charAt(end - 1);
|
|
120
|
+
if (TRAILING.indexOf(ch) !== -1) {
|
|
121
|
+
end--;
|
|
122
|
+
} else if (ch === ')' && closesMoreThanOpens(url.slice(0, end))) {
|
|
123
|
+
end--;
|
|
124
|
+
} else {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return url.slice(0, end);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {string} s */
|
|
132
|
+
function closesMoreThanOpens(s) {
|
|
133
|
+
const opens = s.match(/\(/g);
|
|
134
|
+
const closes = s.match(/\)/g);
|
|
135
|
+
return (closes ? closes.length : 0) > (opens ? opens.length : 0);
|
|
136
|
+
}
|
package/public/app/lib/format.js
CHANGED
|
@@ -121,8 +121,25 @@ export function formatUser(name) {
|
|
|
121
121
|
return isTokenUser(trimmed) ? trimmed.slice(0, 8) : trimmed;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Every task status, most urgent first (mirrors STATUS_RANK in src/types.ts).
|
|
126
|
+
* @type {import('../types.js').Status[]}
|
|
127
|
+
*/
|
|
128
|
+
export const STATUSES = ['overdue', 'due_soon', 'ok', 'info', 'archived'];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Status → human label, cased like the stock badges ("Low stock").
|
|
132
|
+
* @type {Record<string, string>}
|
|
133
|
+
*/
|
|
134
|
+
const STATUS_LABELS = {
|
|
135
|
+
overdue: 'Overdue',
|
|
136
|
+
due_soon: 'Due Soon',
|
|
137
|
+
ok: 'OK',
|
|
138
|
+
info: 'Info',
|
|
139
|
+
archived: 'Archived',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** @param {string} status */
|
|
125
143
|
export function statusLabel(status) {
|
|
126
|
-
|
|
127
|
-
return status;
|
|
144
|
+
return STATUS_LABELS[status] || status;
|
|
128
145
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authored line breaks for markdown output (§7.8). People type notes into a
|
|
3
|
+
* textarea and mean every newline they press, but markdown does not: snarkdown
|
|
4
|
+
* drops a lone newline to whitespace, and collapses any run of blank lines into
|
|
5
|
+
* a single <br>. So "Maxwell HRC10\n\n\n<url>" came out as one run-on line, and
|
|
6
|
+
* spacing the author added could not be got back.
|
|
7
|
+
*
|
|
8
|
+
* Two steps, on either side of snarkdown:
|
|
9
|
+
*
|
|
10
|
+
* - keepBlankLines() runs first, splitting each run of newlines with a marker
|
|
11
|
+
* character so snarkdown sees single newlines it leaves alone instead of the
|
|
12
|
+
* blank-line run it would collapse.
|
|
13
|
+
* - softBreakHtml() runs last, turning the newlines left in the text into <br>
|
|
14
|
+
* and dropping the markers.
|
|
15
|
+
*
|
|
16
|
+
* The usual one-step trick — rewriting "\n" to " \n" before snarkdown — does
|
|
17
|
+
* not work here: it merges list items and leaves trailing spaces inside code
|
|
18
|
+
* fences. Only <MarkdownView/> consumes this.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stands in for a blank line between two newlines. A control character, so it
|
|
23
|
+
* is inert to snarkdown's grammar and cannot occur in a note by accident.
|
|
24
|
+
*/
|
|
25
|
+
const MARKER = '\u0001';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Unmarked newlines at either edge of a text node. Marked ones are blank lines
|
|
29
|
+
* the author typed and survive; these are snarkdown's own output formatting.
|
|
30
|
+
*/
|
|
31
|
+
const LEAD_RE = /^\n+/;
|
|
32
|
+
const TAIL_RE = /\n+$/;
|
|
33
|
+
|
|
34
|
+
/** Fenced code, kept whole: blank lines in there are code, not layout. */
|
|
35
|
+
const FENCE_RE = /(```[\s\S]*?```)/g;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split runs of blank lines so snarkdown cannot collapse them. Runs before
|
|
39
|
+
* snarkdown; softBreakHtml() clears up after it.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} markdown as the author typed it
|
|
42
|
+
* @returns {string} markdown with blank-line runs marked
|
|
43
|
+
*/
|
|
44
|
+
export function keepBlankLines(markdown) {
|
|
45
|
+
// Odd indices are the fences themselves — left exactly as written.
|
|
46
|
+
const chunks = String(markdown).replace(/\r\n?/g, '\n').split(FENCE_RE);
|
|
47
|
+
for (let i = 0; i < chunks.length; i += 2) {
|
|
48
|
+
chunks[i] = chunks[i]
|
|
49
|
+
.split(MARKER)
|
|
50
|
+
.join('')
|
|
51
|
+
.replace(/\n{2,}/g, markRun);
|
|
52
|
+
}
|
|
53
|
+
return chunks.join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string} run two or more newlines */
|
|
57
|
+
function markRun(run) {
|
|
58
|
+
let out = '\n';
|
|
59
|
+
for (let i = 1; i < run.length; i++) out += MARKER + '\n';
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Newlines are content here, not layout.
|
|
65
|
+
* @type {Record<string, boolean>}
|
|
66
|
+
*/
|
|
67
|
+
const SKIP_TAGS = {
|
|
68
|
+
CODE: true,
|
|
69
|
+
PRE: true,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Elements that already break the line. A newline next to one of these came
|
|
74
|
+
* from snarkdown's own output formatting, not from the author.
|
|
75
|
+
* @type {Record<string, boolean>}
|
|
76
|
+
*/
|
|
77
|
+
const BLOCK_TAGS = {
|
|
78
|
+
BLOCKQUOTE: true,
|
|
79
|
+
DIV: true,
|
|
80
|
+
H1: true,
|
|
81
|
+
H2: true,
|
|
82
|
+
H3: true,
|
|
83
|
+
H4: true,
|
|
84
|
+
H5: true,
|
|
85
|
+
H6: true,
|
|
86
|
+
HR: true,
|
|
87
|
+
LI: true,
|
|
88
|
+
OL: true,
|
|
89
|
+
P: true,
|
|
90
|
+
PRE: true,
|
|
91
|
+
TABLE: true,
|
|
92
|
+
UL: true,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} clean sanitized HTML from sanitizeHtml()
|
|
97
|
+
* @returns {string} the same HTML with authored newlines as <br>
|
|
98
|
+
*/
|
|
99
|
+
export function softBreakHtml(clean) {
|
|
100
|
+
const doc = new DOMParser().parseFromString(clean, 'text/html');
|
|
101
|
+
breakNode(doc.body);
|
|
102
|
+
return doc.body.innerHTML;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {Node} parent */
|
|
106
|
+
function breakNode(parent) {
|
|
107
|
+
// Snapshot: breaking replaces text nodes with fragments as we walk.
|
|
108
|
+
const children = Array.prototype.slice.call(parent.childNodes);
|
|
109
|
+
for (let i = 0; i < children.length; i++) {
|
|
110
|
+
const node = children[i];
|
|
111
|
+
if (node.nodeType === 3) {
|
|
112
|
+
breakText(/** @type {Text} */ (node));
|
|
113
|
+
} else if (
|
|
114
|
+
node.nodeType === 1 &&
|
|
115
|
+
!SKIP_TAGS[/** @type {Element} */ (node).tagName]
|
|
116
|
+
) {
|
|
117
|
+
breakNode(node);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Replace one text node with its lines separated by <br>.
|
|
124
|
+
* @param {Text} node
|
|
125
|
+
*/
|
|
126
|
+
function breakText(node) {
|
|
127
|
+
const doc = node.ownerDocument;
|
|
128
|
+
let text = node.nodeValue || '';
|
|
129
|
+
if (!doc || (text.indexOf('\n') === -1 && text.indexOf(MARKER) === -1)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// A newline against a block is snarkdown's formatting, not a typed line.
|
|
133
|
+
if (isBlockEdge(node.previousSibling)) text = text.replace(LEAD_RE, '');
|
|
134
|
+
if (isBlockEdge(node.nextSibling)) text = text.replace(TAIL_RE, '');
|
|
135
|
+
if (text.indexOf('\n') === -1) {
|
|
136
|
+
node.nodeValue = text.split(MARKER).join('');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const lines = text.split('\n');
|
|
140
|
+
const frag = doc.createDocumentFragment();
|
|
141
|
+
for (let i = 0; i < lines.length; i++) {
|
|
142
|
+
if (i > 0) frag.appendChild(doc.createElement('br'));
|
|
143
|
+
// A marked line is a blank one: the <br> pair around it is the whole line.
|
|
144
|
+
const line = lines[i].split(MARKER).join('');
|
|
145
|
+
if (line) frag.appendChild(doc.createTextNode(line));
|
|
146
|
+
}
|
|
147
|
+
node.parentNode && node.parentNode.replaceChild(frag, node);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* True at the start/end of the enclosing block, or against a sibling that
|
|
152
|
+
* breaks the line on its own — a <br> there would be a stray blank line.
|
|
153
|
+
*
|
|
154
|
+
* @param {Node|null} sibling
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
function isBlockEdge(sibling) {
|
|
158
|
+
if (!sibling) return true;
|
|
159
|
+
return (
|
|
160
|
+
sibling.nodeType === 1 &&
|
|
161
|
+
BLOCK_TAGS[/** @type {Element} */ (sibling).tagName] === true
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { html } from '../lib/html.js';
|
|
6
6
|
import { useState } from '../../vendor/preact-hooks.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
useTask,
|
|
9
|
+
useTaskLogs,
|
|
10
|
+
updateTask,
|
|
11
|
+
deleteTask,
|
|
12
|
+
deleteLog,
|
|
13
|
+
} from '../api/hooks.js';
|
|
8
14
|
import { useAuth } from '../auth/auth.js';
|
|
9
15
|
import {
|
|
10
16
|
formatDate,
|
|
@@ -28,6 +34,7 @@ import { LogEntryModal } from '../components/LogEntryModal.js';
|
|
|
28
34
|
import { ConfirmModal } from '../components/ConfirmModal.js';
|
|
29
35
|
import { DownloadLogModal } from '../components/DownloadLogModal.js';
|
|
30
36
|
import { apiFetch } from '../api/client.js';
|
|
37
|
+
import { stowageItemUrl } from '../api/stowage.js';
|
|
31
38
|
|
|
32
39
|
/** @typedef {import('../types.js').TaskDTO} TaskDTO */
|
|
33
40
|
/** @typedef {import('../types.js').LogDTO} LogDTO */
|
|
@@ -196,6 +203,23 @@ export function TaskDetailPage(props) {
|
|
|
196
203
|
>
|
|
197
204
|
<i class="bi bi-pencil" />Edit
|
|
198
205
|
</button>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
class="btn btn-warning"
|
|
209
|
+
onClick=${async () => {
|
|
210
|
+
await updateTask(task.slug, {
|
|
211
|
+
is_archived: !task.is_archived,
|
|
212
|
+
});
|
|
213
|
+
toast(
|
|
214
|
+
task.is_archived
|
|
215
|
+
? 'Task unarchived.'
|
|
216
|
+
: 'Task archived.',
|
|
217
|
+
'success',
|
|
218
|
+
);
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<i class="bi bi-archive" />${task.is_archived ? 'Unarchive' : 'Archive'}
|
|
222
|
+
</button>
|
|
199
223
|
<button
|
|
200
224
|
type="button"
|
|
201
225
|
class="btn btn-danger"
|
|
@@ -214,15 +238,17 @@ export function TaskDetailPage(props) {
|
|
|
214
238
|
// Nothing to say without a description or parts: drop the card.
|
|
215
239
|
task.description || consumables.length
|
|
216
240
|
? html`<div class="card">
|
|
217
|
-
<h3>Description</h3>
|
|
218
241
|
${
|
|
219
242
|
task.description
|
|
220
|
-
? html
|
|
221
|
-
|
|
243
|
+
? html`<h3>Description</h3>
|
|
244
|
+
<${MarkdownView} markdown=${task.description} />`
|
|
245
|
+
: null
|
|
222
246
|
}
|
|
223
247
|
${
|
|
224
248
|
consumables.length
|
|
225
|
-
? html`<div
|
|
249
|
+
? html`<div
|
|
250
|
+
style=${task.description ? 'margin-top:16px' : ''}
|
|
251
|
+
>
|
|
226
252
|
<h3>Consumables</h3>
|
|
227
253
|
<ul class="consumables-list">
|
|
228
254
|
${consumables.map(
|
|
@@ -231,7 +257,9 @@ export function TaskDetailPage(props) {
|
|
|
231
257
|
class="consumables-row"
|
|
232
258
|
>
|
|
233
259
|
<span class="consumables-name"
|
|
234
|
-
|
|
260
|
+
><a href=${stowageItemUrl(c.item_id)}
|
|
261
|
+
>${c.item_name}</a
|
|
262
|
+
>${' '}
|
|
235
263
|
<span class="muted"
|
|
236
264
|
>× ${c.qty_per_service}</span
|
|
237
265
|
></span
|
|
@@ -7,7 +7,12 @@ import { useState, useEffect } from '../../vendor/preact-hooks.js';
|
|
|
7
7
|
import { useTasks, useTags, deleteTask } from '../api/hooks.js';
|
|
8
8
|
import { useAuth } from '../auth/auth.js';
|
|
9
9
|
import { useListParams } from '../lib/useListParams.js';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
STATUSES,
|
|
12
|
+
formatRemainingHours,
|
|
13
|
+
formatRemainingTime,
|
|
14
|
+
statusLabel,
|
|
15
|
+
} from '../lib/format.js';
|
|
11
16
|
import { toast } from '../lib/toasts.js';
|
|
12
17
|
import { Table } from '../components/Table.js';
|
|
13
18
|
import { Pagination } from '../components/Pagination.js';
|
|
@@ -28,6 +33,7 @@ export function TaskListPage() {
|
|
|
28
33
|
const page = parseInt(params.page || '1', 10) || 1;
|
|
29
34
|
const search = params.search || '';
|
|
30
35
|
const tagsCsv = params.tags || '';
|
|
36
|
+
const statusCsv = params.status || '';
|
|
31
37
|
const sort = params.sort || '';
|
|
32
38
|
const order = params.order || '';
|
|
33
39
|
|
|
@@ -48,6 +54,7 @@ export function TaskListPage() {
|
|
|
48
54
|
const tasksRes = useTasks({
|
|
49
55
|
search: search || undefined,
|
|
50
56
|
tags: tagsCsv || undefined,
|
|
57
|
+
status: statusCsv || undefined,
|
|
51
58
|
sort: sort || undefined,
|
|
52
59
|
order: order || undefined,
|
|
53
60
|
page: page,
|
|
@@ -64,15 +71,29 @@ export function TaskListPage() {
|
|
|
64
71
|
);
|
|
65
72
|
const [deleting, setDeleting] = useState(/** @type {TaskDTO|null} */ (null));
|
|
66
73
|
|
|
74
|
+
// Tags and status are single-select, so search, tag and status simply AND
|
|
75
|
+
// together. A task has exactly one status, and tags are used as categories,
|
|
76
|
+
// so combining two of either narrows to nothing more often than not. Clicking
|
|
77
|
+
// a chip replaces the current selection; clicking the selected one clears it.
|
|
78
|
+
// The API still accepts CSV, so a hand-written multi-value URL keeps
|
|
79
|
+
// filtering (and highlighting) — it just collapses on the next click.
|
|
67
80
|
const selectedTags = tagsCsv ? tagsCsv.split(',').filter(Boolean) : [];
|
|
68
81
|
/** @param {string} tag */
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
selectedTags.indexOf(tag) === -1
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
const selectTag = (tag) =>
|
|
83
|
+
update({
|
|
84
|
+
tags: selectedTags.indexOf(tag) === -1 ? tag : undefined,
|
|
85
|
+
page: undefined,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const selectedStatuses = statusCsv
|
|
89
|
+
? statusCsv.split(',').filter(Boolean)
|
|
90
|
+
: [];
|
|
91
|
+
/** @param {string} status */
|
|
92
|
+
const selectStatus = (status) =>
|
|
93
|
+
update({
|
|
94
|
+
status: selectedStatuses.indexOf(status) === -1 ? status : undefined,
|
|
95
|
+
page: undefined,
|
|
96
|
+
});
|
|
76
97
|
|
|
77
98
|
/** @param {string} key */
|
|
78
99
|
const onSort = (key) => {
|
|
@@ -161,6 +182,7 @@ export function TaskListPage() {
|
|
|
161
182
|
},
|
|
162
183
|
];
|
|
163
184
|
|
|
185
|
+
const tagList = tagsRes.data ? tagsRes.data.data : [];
|
|
164
186
|
const pageData = tasksRes.data;
|
|
165
187
|
|
|
166
188
|
return html`
|
|
@@ -191,19 +213,45 @@ export function TaskListPage() {
|
|
|
191
213
|
}
|
|
192
214
|
</div>
|
|
193
215
|
|
|
194
|
-
<div class="
|
|
195
|
-
${
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
216
|
+
<div class="chip-filters">
|
|
217
|
+
${
|
|
218
|
+
tagList.length
|
|
219
|
+
? html`
|
|
220
|
+
<div class="chips" role="group" aria-labelledby="tag-filter-label">
|
|
221
|
+
<span class="chips-label" id="tag-filter-label">Tags:</span>
|
|
222
|
+
${tagList.map(
|
|
223
|
+
(tag) => html`
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
key=${tag.name}
|
|
227
|
+
class=${'chip' + (selectedTags.indexOf(tag.name) !== -1 ? ' selected' : '')}
|
|
228
|
+
aria-pressed=${selectedTags.indexOf(tag.name) !== -1}
|
|
229
|
+
onClick=${() => selectTag(tag.name)}
|
|
230
|
+
>
|
|
231
|
+
${tag.name}
|
|
232
|
+
</button>
|
|
233
|
+
`,
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
`
|
|
237
|
+
: null
|
|
238
|
+
}
|
|
239
|
+
<div class="chips" role="group" aria-labelledby="status-filter-label">
|
|
240
|
+
<span class="chips-label" id="status-filter-label">Status:</span>
|
|
241
|
+
${STATUSES.map(
|
|
242
|
+
(status) => html`
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
key=${status}
|
|
246
|
+
class=${'chip' + (selectedStatuses.indexOf(status) !== -1 ? ' selected' : '')}
|
|
247
|
+
aria-pressed=${selectedStatuses.indexOf(status) !== -1}
|
|
248
|
+
onClick=${() => selectStatus(status)}
|
|
249
|
+
>
|
|
250
|
+
${statusLabel(status)}
|
|
251
|
+
</button>
|
|
252
|
+
`,
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
207
255
|
</div>
|
|
208
256
|
|
|
209
257
|
${
|
|
@@ -219,7 +267,7 @@ export function TaskListPage() {
|
|
|
219
267
|
order=${order}
|
|
220
268
|
onSort=${onSort}
|
|
221
269
|
loading=${tasksRes.loading}
|
|
222
|
-
emptyMessage=${search || tagsCsv ? 'No tasks match your filters.' : 'No maintenance tasks yet.'}
|
|
270
|
+
emptyMessage=${search || tagsCsv || statusCsv ? 'No tasks match your filters.' : 'No maintenance tasks yet.'}
|
|
223
271
|
/>
|
|
224
272
|
${
|
|
225
273
|
pageData
|
package/public/app/types.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/** @typedef {'days'|'weeks'|'months'|'years'} TimeUnit */
|
|
8
|
-
/** @typedef {'overdue'|'due_soon'|'ok'|'
|
|
8
|
+
/** @typedef {'overdue'|'due_soon'|'ok'|'info'|'archived'} Status */
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @typedef {Object} TaskConsumableDTO
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* @property {number|null} remaining_time_ms
|
|
48
48
|
* @property {number|null} time_fraction
|
|
49
49
|
* @property {Status|null} time_status
|
|
50
|
+
* @property {boolean} is_archived
|
|
50
51
|
* @property {Status} status
|
|
51
52
|
* @property {number} status_rank
|
|
52
53
|
* @property {number} urgency
|
|
@@ -100,6 +101,7 @@
|
|
|
100
101
|
* @property {string[]} [tags]
|
|
101
102
|
* @property {string|null} [last_maintenance]
|
|
102
103
|
* @property {number|null} [last_runtime]
|
|
104
|
+
* @property {boolean} [is_archived]
|
|
103
105
|
* @property {TaskConsumableDTO[]} [consumables]
|
|
104
106
|
*/
|
|
105
107
|
|
package/public/styles/main.css
CHANGED
|
@@ -246,6 +246,16 @@ pre {
|
|
|
246
246
|
border-color: var(--ok-hover);
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
.btn-warning {
|
|
250
|
+
background: var(--warn);
|
|
251
|
+
border-color: var(--warn);
|
|
252
|
+
color: var(--accent-text);
|
|
253
|
+
}
|
|
254
|
+
.btn-warning:hover {
|
|
255
|
+
background: var(--warn-hover);
|
|
256
|
+
border-color: var(--warn-hover);
|
|
257
|
+
}
|
|
258
|
+
|
|
249
259
|
.btn-icon {
|
|
250
260
|
display: inline-flex;
|
|
251
261
|
align-items: center;
|
|
@@ -430,6 +440,21 @@ pre {
|
|
|
430
440
|
margin-bottom: 4px;
|
|
431
441
|
}
|
|
432
442
|
|
|
443
|
+
/* The task list's two filter rows (tags, status), each behind its own label.
|
|
444
|
+
The labels share a min-width so both rows' chips start on the same column. */
|
|
445
|
+
.chip-filters {
|
|
446
|
+
margin-bottom: 8px;
|
|
447
|
+
}
|
|
448
|
+
.chip-filters .chips {
|
|
449
|
+
margin-bottom: 0;
|
|
450
|
+
}
|
|
451
|
+
.chips-label {
|
|
452
|
+
flex: 0 0 auto;
|
|
453
|
+
min-width: 3.6em;
|
|
454
|
+
color: var(--text-muted);
|
|
455
|
+
font-size: 13.5px;
|
|
456
|
+
}
|
|
457
|
+
|
|
433
458
|
.chip {
|
|
434
459
|
display: inline-flex;
|
|
435
460
|
align-items: center;
|
|
@@ -452,10 +477,6 @@ pre {
|
|
|
452
477
|
border-color: var(--accent);
|
|
453
478
|
color: var(--accent-text);
|
|
454
479
|
}
|
|
455
|
-
.chip .chip-count {
|
|
456
|
-
opacity: 0.7;
|
|
457
|
-
margin-left: 5px;
|
|
458
|
-
}
|
|
459
480
|
.chip .bi {
|
|
460
481
|
margin-left: 6px;
|
|
461
482
|
}
|
|
@@ -624,9 +645,13 @@ pre {
|
|
|
624
645
|
background: var(--ok-bg);
|
|
625
646
|
color: var(--ok);
|
|
626
647
|
}
|
|
627
|
-
.badge.
|
|
628
|
-
background: var(--
|
|
629
|
-
color: var(--
|
|
648
|
+
.badge.info {
|
|
649
|
+
background: var(--accent-bg);
|
|
650
|
+
color: var(--accent);
|
|
651
|
+
}
|
|
652
|
+
.badge.archived {
|
|
653
|
+
background: var(--info-bg);
|
|
654
|
+
color: var(--info);
|
|
630
655
|
}
|
|
631
656
|
|
|
632
657
|
/* stock status badge (parts linked via signalk-stowage-mgmt) */
|
package/public/styles/tokens.css
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
--danger-hover: #b02525;
|
|
19
19
|
--danger-bg: #ffe3e3;
|
|
20
20
|
--warn: #e8590c;
|
|
21
|
+
--warn-hover: #d9480f;
|
|
21
22
|
--warn-bg: #ffe8cc;
|
|
22
23
|
--ok: #2b8a3e;
|
|
23
24
|
--ok-hover: #237434;
|
|
24
25
|
--ok-bg: #d3f9d8;
|
|
25
|
-
--
|
|
26
|
-
--
|
|
26
|
+
--info: #5c6570;
|
|
27
|
+
--info-bg: #e9ecef;
|
|
27
28
|
--shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
|
28
29
|
--overlay: rgba(20, 24, 28, 0.5);
|
|
29
30
|
}
|
|
@@ -44,12 +45,13 @@
|
|
|
44
45
|
--danger-hover: #ff8787;
|
|
45
46
|
--danger-bg: #3d2224;
|
|
46
47
|
--warn: #ffa94d;
|
|
48
|
+
--warn-hover: #ffc078;
|
|
47
49
|
--warn-bg: #3d2f1f;
|
|
48
50
|
--ok: #69db7c;
|
|
49
51
|
--ok-hover: #8ce99a;
|
|
50
52
|
--ok-bg: #1e3325;
|
|
51
|
-
--
|
|
52
|
-
--
|
|
53
|
+
--info: #9aa4ae;
|
|
54
|
+
--info-bg: #272d33;
|
|
53
55
|
--shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
|
54
56
|
--overlay: rgba(0, 0, 0, 0.65);
|
|
55
57
|
}
|