signalk-maintenance-tracker 1.1.0 → 1.2.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 CHANGED
@@ -1,3 +1,54 @@
1
+ # v1.2.0
2
+
3
+ A task detail page that puts the numbers you actually compare side by side,
4
+ plus figures for tasks that have no schedule at all.
5
+
6
+ ## Highlights
7
+
8
+ - **Schedule and Runtime are now their own tabular cards.** The old prose
9
+ readout ("Current 1360 h · last done at 1240.5 h · due at 1440.5 h") buried
10
+ the numbers. Each dimension gets its own card with an Interval / Today /
11
+ Last / Elapsed / Next / Remaining table — current reading above the
12
+ last-service one, elapsed as their difference right below — with the
13
+ progress bar under the table and the remaining figure colored by status.
14
+ Rows that don't apply simply fall away, and the runtime card disappears
15
+ entirely for tasks that don't track runtime.
16
+ - **Interval-less tasks now show their figures too.** "Last done" and "hours
17
+ since" only ever needed a logged completion, not an interval, so a purely
18
+ informational task now reports how long (and how many engine hours) it has
19
+ been since it was last serviced. The new `elapsed_time_ms` field is on the
20
+ task API alongside `elapsed_runtime`.
21
+ - **The task edit form explains its read-only fields.** Last maintenance and
22
+ runtime-at-last-maintenance are shown with a note that they come from the
23
+ task's most recent log entry, and that **Mark complete** (or editing the log)
24
+ is how you change them.
25
+
26
+ ## Smaller changes and fixes
27
+
28
+ - Runtime subscriptions now throttle with `minPeriod` instead of `period`.
29
+ Pairing `period` with policy `instant` made the SignalK server log a warning
30
+ and ignore the throttle; each path is now limited to one delta per 5s as
31
+ intended.
32
+ - Tags moved from the description card into the task's title bar, and the
33
+ description card is dropped entirely when a task has neither a description
34
+ nor consumables.
35
+ - Log notes are muted wherever the log is rendered, so they read as annotation
36
+ rather than data.
37
+ - Danger and success buttons use the theme's accent text color instead of a
38
+ hardcoded white, which was low-contrast in some MFD themes.
39
+
40
+ # v1.1.1
41
+
42
+ A small release with one important fix for your SD card's lifespan.
43
+
44
+ ## Fixes
45
+
46
+ - **Runtime updates no longer hammer the disk.** Every SignalK runtime delta
47
+ was written straight to the database — up to several commits (and disk syncs)
48
+ per second while the engine was running. Runtime values are now kept in
49
+ memory and flushed in a single transaction at most once per minute, with a
50
+ final flush on shutdown so engine hours still survive restarts.
51
+
1
52
  # v1.1.0
2
53
 
3
54
  This release teaches Maintenance Tracker to talk to your spares locker, adds
@@ -54,6 +54,7 @@ function computeTask(task, currentRuntime, now, cfg) {
54
54
  due_runtime_at: null,
55
55
  runtime_fraction: null,
56
56
  runtime_status: null,
57
+ elapsed_time_ms: null,
57
58
  scheduled_due_date: null,
58
59
  scheduled_remaining_ms: null,
59
60
  scheduled_fraction: null,
@@ -68,12 +69,21 @@ function computeTask(task, currentRuntime, now, cfg) {
68
69
  status_rank: types_1.STATUS_RANK.unknown,
69
70
  urgency: -Infinity,
70
71
  };
72
+ // Elapsed-since-last-service figures stand on their own: they need only a
73
+ // logged completion (plus, for runtime, a live reading), not an interval. A
74
+ // task with no schedule at all still shows "last done X, N hours since".
75
+ if (task.last_runtime != null && currentRuntime != null) {
76
+ out.elapsed_runtime = currentRuntime - task.last_runtime;
77
+ }
78
+ if (task.last_maintenance != null) {
79
+ out.elapsed_time_ms =
80
+ now.getTime() - new Date(task.last_maintenance).getTime();
81
+ }
71
82
  // Runtime dimension: configured when both interval and path are set.
72
83
  if (task.runtime_interval != null && task.runtime_path != null) {
73
84
  if (task.last_runtime != null && currentRuntime != null) {
74
85
  const elapsed = currentRuntime - task.last_runtime;
75
86
  const remaining = task.runtime_interval - elapsed;
76
- out.elapsed_runtime = elapsed;
77
87
  out.remaining_runtime = remaining;
78
88
  out.due_runtime_at = task.last_runtime + task.runtime_interval;
79
89
  out.runtime_fraction = elapsed / task.runtime_interval;
@@ -1 +1 @@
1
- {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/domain/status.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/domain/status.ts"],"names":[],"mappings":"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"}
@@ -1,25 +1,38 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RuntimeManager = void 0;
3
+ exports.RuntimeManager = exports.SUBSCRIBE_MIN_PERIOD_MS = exports.FLUSH_INTERVAL_MS = void 0;
4
+ /** Floor between runtime_cache commits — deltas can arrive many times per
5
+ * second, and persisting each one hammers the disk. */
6
+ exports.FLUSH_INTERVAL_MS = 60_000;
7
+ /** Throttle applied to each subscribed path (policy 'instant'): the first
8
+ * delta arrives immediately, then at most one per interval. */
9
+ exports.SUBSCRIBE_MIN_PERIOD_MS = 5000;
4
10
  /**
5
11
  * Subscribes to the union of all task runtime paths on vessels.self, keeps an
6
12
  * in-memory map of the latest value, and persists it to runtime_cache so
7
13
  * values survive restarts (§10.2).
8
14
  *
15
+ * Deltas only touch the in-memory map; dirty values are flushed to the DB in
16
+ * one transaction at most once per FLUSH_INTERVAL_MS (and on stop()).
17
+ *
9
18
  * This is the single seconds→hours conversion boundary: SignalK runtime paths
10
19
  * are seconds; everything stored/returned here is hours.
11
20
  */
12
21
  class RuntimeManager {
13
22
  app;
14
23
  db;
24
+ flushIntervalMs;
15
25
  values = new Map();
16
26
  paths = [];
17
27
  unsubscribes = [];
18
28
  listeners = [];
19
29
  lastUpdate = null;
20
- constructor(app, db) {
30
+ dirty = new Set();
31
+ flushTimer = null;
32
+ constructor(app, db, flushIntervalMs = exports.FLUSH_INTERVAL_MS) {
21
33
  this.app = app;
22
34
  this.db = db;
35
+ this.flushIntervalMs = flushIntervalMs;
23
36
  this.loadCache();
24
37
  }
25
38
  loadCache() {
@@ -55,7 +68,11 @@ class RuntimeManager {
55
68
  context: 'vessels.self',
56
69
  subscribe: next.map((path) => ({
57
70
  path,
58
- period: 5000,
71
+ // minPeriod (not period) is the throttle that goes with policy
72
+ // 'instant' — period implies policy 'fixed' and the server logs a
73
+ // warning if both are given. Runtime hours creep upward slowly, so
74
+ // one delta per SUBSCRIBE_MIN_PERIOD_MS per path is plenty.
75
+ minPeriod: exports.SUBSCRIBE_MIN_PERIOD_MS,
59
76
  policy: 'instant',
60
77
  })),
61
78
  }, this.unsubscribes, (err) => {
@@ -71,19 +88,51 @@ class RuntimeManager {
71
88
  const hours = v.value / 3600; // SignalK runtime is seconds (§10.2)
72
89
  const timestamp = new Date().toISOString();
73
90
  this.values.set(v.path, { value: hours, timestamp });
74
- this.db
75
- .prepare(`INSERT INTO runtime_cache (path, value, timestamp) VALUES (?, ?, ?)
76
- ON CONFLICT(path) DO UPDATE SET value = excluded.value, timestamp = excluded.timestamp`)
77
- .run(v.path, hours, timestamp);
91
+ this.dirty.add(v.path);
78
92
  this.lastUpdate = timestamp;
79
93
  changed = true;
80
94
  }
81
95
  }
82
- if (changed)
96
+ if (changed) {
97
+ this.scheduleFlush();
83
98
  for (const fn of this.listeners)
84
99
  fn();
100
+ }
101
+ }
102
+ scheduleFlush() {
103
+ if (this.flushTimer)
104
+ return;
105
+ this.flushTimer = setTimeout(() => this.flush(), this.flushIntervalMs);
106
+ this.flushTimer.unref?.();
107
+ }
108
+ /** Persist all dirty values in a single transaction. */
109
+ flush() {
110
+ if (this.flushTimer) {
111
+ clearTimeout(this.flushTimer);
112
+ this.flushTimer = null;
113
+ }
114
+ if (!this.dirty.size)
115
+ return;
116
+ const upsert = this.db.prepare(`INSERT INTO runtime_cache (path, value, timestamp) VALUES (?, ?, ?)
117
+ ON CONFLICT(path) DO UPDATE SET value = excluded.value, timestamp = excluded.timestamp`);
118
+ this.db.exec('BEGIN');
119
+ try {
120
+ for (const path of this.dirty) {
121
+ const entry = this.values.get(path);
122
+ if (entry)
123
+ upsert.run(path, entry.value, entry.timestamp);
124
+ }
125
+ this.db.exec('COMMIT');
126
+ this.dirty.clear();
127
+ }
128
+ catch (err) {
129
+ this.db.exec('ROLLBACK');
130
+ this.app.error?.(`maintenance-tracker runtime cache flush failed: ${err}`);
131
+ }
85
132
  }
86
133
  stop() {
134
+ // Flush before the plugin closes the DB so values survive restarts.
135
+ this.flush();
87
136
  this.teardown();
88
137
  this.paths = [];
89
138
  }
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../../src/signalk/runtime.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../../src/signalk/runtime.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-maintenance-tracker",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Track recurring boat maintenance tasks with runtime and calendar based intervals.",
5
5
  "main": "dist/index.js",
6
6
  "license": "MIT",
@@ -0,0 +1,27 @@
1
+ import { html } from '../lib/html.js';
2
+
3
+ /**
4
+ * Label/value readout for the task detail page's Schedule and Runtime cards
5
+ * (§7.4). Rows whose value is null are dropped, so a caller can list every
6
+ * field its dimension could have and let the inapplicable ones fall away.
7
+ *
8
+ * @param {{ rows: { label: string, value: any }[] }} props
9
+ */
10
+ export function StatTable(props) {
11
+ const rows = props.rows.filter((r) => r.value !== null);
12
+ if (!rows.length) return null;
13
+ return html`
14
+ <table class="stat-table">
15
+ <tbody>
16
+ ${rows.map(
17
+ (r) => html`
18
+ <tr key=${r.label}>
19
+ <th scope="row">${r.label}</th>
20
+ <td>${r.value}</td>
21
+ </tr>
22
+ `,
23
+ )}
24
+ </tbody>
25
+ </table>
26
+ `;
27
+ }
@@ -13,6 +13,7 @@ import { ConsumablesPicker } from './ConsumablesPicker.js';
13
13
  import { PathPicker } from './PathPicker.js';
14
14
  import { createTask, updateTask, useTags, useHealth } from '../api/hooks.js';
15
15
  import { slugify } from '../lib/slug.js';
16
+ import { formatDate, formatHours } from '../lib/format.js';
16
17
  import { toast } from '../lib/toasts.js';
17
18
 
18
19
  /** @typedef {import('../types.js').TaskDTO} TaskDTO */
@@ -421,7 +422,31 @@ export function TaskFormModal(props) {
421
422
  </div>
422
423
  </div>
423
424
  `
424
- : null
425
+ : html`
426
+ <div class="field-row">
427
+ <div class="field">
428
+ <label class="field-label">Last maintenance</label>
429
+ <div class="input input-static">
430
+ ${formatDate(task.last_maintenance)}
431
+ </div>
432
+ </div>
433
+ <div class="field">
434
+ <label class="field-label"
435
+ >Runtime at last maintenance</label
436
+ >
437
+ <div class="input input-static">
438
+ ${formatHours(task.last_runtime)}
439
+ </div>
440
+ </div>
441
+ </div>
442
+ <div class="field-hint" style="margin-top:-10px">
443
+ <i class="bi bi-info-circle" /> These come from the task's
444
+ most recent log entry, so they aren't editable here. Use${' '}
445
+ <strong>Mark complete</strong>${' '}
446
+ to record work — it accepts a past date and runtime — or edit
447
+ the latest entry in the maintenance log to correct them.
448
+ </div>
449
+ `
425
450
  }
426
451
  </form>
427
452
  <//>
@@ -62,6 +62,16 @@ export function formatRemainingTime(ms) {
62
62
  return humanizeMs(ms);
63
63
  }
64
64
 
65
+ /**
66
+ * Elapsed span since something happened: "3 days". Negative spans (a
67
+ * future-dated log entry) read as "in 3 days".
68
+ * @param {number|null|undefined} ms
69
+ */
70
+ export function formatElapsedTime(ms) {
71
+ if (ms === null || ms === undefined) return '—';
72
+ return ms < 0 ? 'in ' + humanizeMs(-ms) : humanizeMs(ms);
73
+ }
74
+
65
75
  /**
66
76
  * Value for <input type="date">: today (local) by default, or the UTC date
67
77
  * part of a stored ISO string. The YYYY-MM-DD value is sent to the API as-is
@@ -8,10 +8,12 @@ import { useTask, useTaskLogs, deleteTask, deleteLog } from '../api/hooks.js';
8
8
  import { useAuth } from '../auth/auth.js';
9
9
  import {
10
10
  formatDate,
11
+ formatElapsedTime,
11
12
  formatHours,
12
13
  formatRemainingHours,
13
14
  formatRemainingTime,
14
15
  formatUser,
16
+ toDateInput,
15
17
  } from '../lib/format.js';
16
18
  import { navigate } from '../lib/router.js';
17
19
  import { toast } from '../lib/toasts.js';
@@ -19,6 +21,7 @@ import { Table } from '../components/Table.js';
19
21
  import { StatusBadge } from '../components/StatusBadge.js';
20
22
  import { StockBadge } from '../components/StockBadge.js';
21
23
  import { ProgressBar } from '../components/ProgressBar.js';
24
+ import { StatTable } from '../components/StatTable.js';
22
25
  import { MarkdownView } from '../components/MarkdownView.js';
23
26
  import { TaskFormModal } from '../components/TaskFormModal.js';
24
27
  import { LogEntryModal } from '../components/LogEntryModal.js';
@@ -112,26 +115,68 @@ export function TaskDetailPage(props) {
112
115
  const timeConfigured = task.time_interval !== null;
113
116
  const dueDateConfigured = task.due_date !== null;
114
117
 
118
+ // Each card draws whatever its dimension actually knows. An interval is what
119
+ // makes something due, but a bare log entry or runtime reading still answers
120
+ // "when/at what hours was this last done, and how long since" — so a task
121
+ // with no interval at all is not an empty card.
122
+ const hasSchedule =
123
+ timeConfigured || dueDateConfigured || task.last_maintenance !== null;
124
+ const hasRuntime =
125
+ runtimeConfigured ||
126
+ task.runtime_path !== null ||
127
+ task.current_runtime !== null ||
128
+ task.last_runtime !== null;
129
+
130
+ const consumables = task.consumables || [];
131
+
132
+ // With both a recurring interval and a one-time deadline set, the deadline
133
+ // takes a row of its own; on its own it simply is the next thing due.
134
+ const bothTimeDimensions = timeConfigured && dueDateConfigured;
135
+
136
+ // "Next" is whichever time sub-dimension comes first — the same choice the
137
+ // backend makes for remaining_time_ms, so the two rows always agree.
138
+ const nextDueIso =
139
+ task.scheduled_remaining_ms !== null && task.due_date_remaining_ms !== null
140
+ ? task.scheduled_remaining_ms <= task.due_date_remaining_ms
141
+ ? task.scheduled_due_date
142
+ : task.due_date
143
+ : (task.scheduled_due_date ?? task.due_date);
144
+
145
+ /** Remaining figure colored by its sub-status, as in the task list. */
146
+ const remaining = (
147
+ /** @type {string} */ text,
148
+ /** @type {import('../types.js').Status|null} */ status,
149
+ ) => html`<span class=${'remaining ' + (status || '')}>${text}</span>`;
150
+
115
151
  // Per-task "due soon" lead windows, shown only when overriding the plugin
116
152
  // default (null = default, 0 = no warning).
117
- const warnNote = (
153
+ const warnHint = (
118
154
  /** @type {number|null} */ value,
119
155
  /** @type {string} */ unit,
120
156
  ) =>
121
157
  value === null
122
- ? ''
123
- : value === 0
124
- ? ' · no due-soon warning'
125
- : ` · warns ${value}${unit} early`;
126
- const runtimeWarnNote = warnNote(task.runtime_warning_hours, 'h');
127
- const timeWarnNote = warnNote(task.time_warning_days, 'd');
158
+ ? null
159
+ : html`<div class="field-hint">
160
+ ${
161
+ value === 0
162
+ ? 'No due-soon warning.'
163
+ : 'Warns ' + value + unit + ' early.'
164
+ }
165
+ </div>`;
128
166
 
129
167
  return html`
130
168
  <div>
131
169
  <div class="page-header">
132
170
  <h1 class="page-title">
133
171
  ${task.name} <${StatusBadge} status=${task.status} />
134
- <${StockBadge} consumables=${task.consumables} />
172
+ <${StockBadge} consumables=${consumables} />
173
+ ${
174
+ task.tags.length
175
+ ? html`<span class="chips">
176
+ ${task.tags.map((tag) => html`<span key=${tag} class="tag">${tag}</span>`)}
177
+ </span>`
178
+ : null
179
+ }
135
180
  </h1>
136
181
  ${
137
182
  auth.isLoggedIn
@@ -165,126 +210,174 @@ export function TaskDetailPage(props) {
165
210
  </div>
166
211
 
167
212
  <div class="detail-grid">
168
- <div class="card">
169
- <h3>Description</h3>
170
- ${
171
- task.description
172
- ? html`<${MarkdownView} markdown=${task.description} />`
173
- : html`<p class="muted" style="margin:0">No description.</p>`
174
- }
175
- ${
176
- task.tags.length
177
- ? html`<div class="chips" style="margin-top:12px">
178
- ${task.tags.map((tag) => html`<span key=${tag} class="tag">${tag}</span>`)}
179
- </div>`
180
- : null
181
- }
182
- ${
183
- (task.consumables || []).length
184
- ? html`<div style="margin-top:12px">
185
- <div class="field-label" style="margin-bottom:4px">
186
- Consumables
187
- </div>
188
- <ul class="consumables-list">
189
- ${task.consumables.map(
190
- (c) => html`<li key=${c.item_id} class="consumables-row">
191
- <span class="consumables-name"
192
- >${c.item_name}${' '}
193
- <span class="muted"
194
- >× ${c.qty_per_service}</span
195
- ></span
196
- >
197
- </li>`,
198
- )}
199
- </ul>
200
- </div>`
201
- : null
202
- }
203
- </div>
213
+ ${
214
+ // Nothing to say without a description or parts: drop the card.
215
+ task.description || consumables.length
216
+ ? html`<div class="card">
217
+ <h3>Description</h3>
218
+ ${
219
+ task.description
220
+ ? html`<${MarkdownView} markdown=${task.description} />`
221
+ : html`<p class="muted" style="margin:0">No description.</p>`
222
+ }
223
+ ${
224
+ consumables.length
225
+ ? html`<div style="margin-top:16px">
226
+ <h3>Consumables</h3>
227
+ <ul class="consumables-list">
228
+ ${consumables.map(
229
+ (c) => html`<li
230
+ key=${c.item_id}
231
+ class="consumables-row"
232
+ >
233
+ <span class="consumables-name"
234
+ >${c.item_name}${' '}
235
+ <span class="muted"
236
+ >× ${c.qty_per_service}</span
237
+ ></span
238
+ >
239
+ </li>`,
240
+ )}
241
+ </ul>
242
+ </div>`
243
+ : null
244
+ }
245
+ </div>`
246
+ : null
247
+ }
204
248
 
205
249
  <div class="card">
206
250
  <h3>Schedule</h3>
207
251
  ${
208
- !runtimeConfigured && !timeConfigured && !dueDateConfigured
252
+ !hasSchedule
209
253
  ? html`<p class="muted" style="margin:0">
210
- Informational task — no intervals configured.
254
+ No interval or due date configured.
211
255
  </p>`
212
- : null
213
- }
214
- ${
215
- runtimeConfigured
216
- ? html`
217
- <div class="stat-row">
218
- <div class="stat-label">
219
- <span>Runtime — every ${formatHours(task.runtime_interval)}</span>
220
- <span class="stat-value"
221
- >${formatRemainingHours(task.remaining_runtime)}${task.remaining_runtime !== null && task.remaining_runtime >= 0 ? ' left' : ''}</span
222
- >
223
- </div>
224
- <${ProgressBar}
225
- fraction=${task.runtime_fraction}
226
- status=${task.runtime_status}
227
- />
228
- <div class="field-hint">
229
- Current ${formatHours(task.current_runtime)} · last done
230
- at ${formatHours(task.last_runtime)} · due
231
- at ${formatHours(task.due_runtime_at)}${runtimeWarnNote}
232
- </div>
233
- </div>
256
+ : html`
257
+ <${StatTable}
258
+ rows=${[
259
+ {
260
+ label: 'Interval',
261
+ value: timeConfigured
262
+ ? 'every ' +
263
+ task.time_interval +
264
+ ' ' +
265
+ task.time_interval_unit
266
+ : null,
267
+ },
268
+ {
269
+ // Only when a recurring interval owns the Next row —
270
+ // on its own the deadline *is* the next thing due.
271
+ label: 'Deadline',
272
+ value: bothTimeDimensions
273
+ ? formatDate(task.due_date)
274
+ : null,
275
+ },
276
+ { label: 'Today', value: toDateInput() },
277
+ {
278
+ label: 'Last',
279
+ value:
280
+ task.last_maintenance !== null
281
+ ? formatDate(task.last_maintenance)
282
+ : null,
283
+ },
284
+ {
285
+ label: 'Elapsed',
286
+ value:
287
+ task.elapsed_time_ms !== null
288
+ ? formatElapsedTime(task.elapsed_time_ms)
289
+ : null,
290
+ },
291
+ {
292
+ label: 'Next',
293
+ value:
294
+ nextDueIso !== null ? formatDate(nextDueIso) : null,
295
+ },
296
+ {
297
+ label: 'Remaining',
298
+ value:
299
+ task.remaining_time_ms !== null
300
+ ? remaining(
301
+ formatRemainingTime(task.remaining_time_ms),
302
+ task.time_status,
303
+ )
304
+ : null,
305
+ },
306
+ ]}
307
+ />
308
+ ${warnHint(task.time_warning_days, 'd')}
309
+ <${ProgressBar}
310
+ fraction=${task.time_fraction}
311
+ status=${task.time_status}
312
+ />
234
313
  `
235
- : null
236
- }
237
- ${
238
- timeConfigured
239
- ? html`
240
- <div class="stat-row">
241
- <div class="stat-label">
242
- <span>Time — every ${task.time_interval} ${task.time_interval_unit}</span>
243
- <span class="stat-value"
244
- >${formatRemainingTime(task.scheduled_remaining_ms)}${task.scheduled_remaining_ms !== null && task.scheduled_remaining_ms >= 0 ? ' left' : ''}</span
245
- >
246
- </div>
247
- <${ProgressBar}
248
- fraction=${task.scheduled_fraction}
249
- status=${task.scheduled_status}
250
- />
251
- <div class="field-hint">
252
- Last done ${formatDate(task.last_maintenance)} · next
253
- due ${formatDate(task.scheduled_due_date)}${timeWarnNote}
254
- </div>
255
- </div>
256
- `
257
- : null
258
- }
259
- ${
260
- dueDateConfigured
261
- ? html`
262
- <div class="stat-row">
263
- <div class="stat-label">
264
- <span>Due date — ${formatDate(task.due_date)}</span>
265
- <span class="stat-value"
266
- >${formatRemainingTime(task.due_date_remaining_ms)}${task.due_date_remaining_ms !== null && task.due_date_remaining_ms >= 0 ? ' left' : ''}</span
267
- >
268
- </div>
269
- <${ProgressBar}
270
- fraction=${task.due_date_fraction}
271
- status=${task.due_date_status}
272
- />
273
- <div class="field-hint">
274
- One-time deadline.${timeWarnNote}
275
- </div>
276
- </div>
277
- `
278
- : null
279
- }
280
- ${
281
- task.runtime_path
282
- ? html`<div class="field-hint">
283
- Runtime source: <code>${task.runtime_path}</code>
284
- </div>`
285
- : null
286
314
  }
287
315
  </div>
316
+
317
+ ${
318
+ // Nothing to say about runtime on a task that doesn't track it —
319
+ // the remaining cards spread to fill the row.
320
+ hasRuntime
321
+ ? html`
322
+ <div class="card">
323
+ <h3>Runtime</h3>
324
+ <${StatTable}
325
+ rows=${[
326
+ {
327
+ label: 'Interval',
328
+ value: runtimeConfigured
329
+ ? 'every ' + formatHours(task.runtime_interval)
330
+ : null,
331
+ },
332
+ {
333
+ label: 'Current',
334
+ value:
335
+ task.current_runtime !== null
336
+ ? formatHours(task.current_runtime)
337
+ : null,
338
+ },
339
+ {
340
+ label: 'Last',
341
+ value:
342
+ task.last_runtime !== null
343
+ ? formatHours(task.last_runtime)
344
+ : null,
345
+ },
346
+ {
347
+ label: 'Elapsed',
348
+ value:
349
+ task.elapsed_runtime !== null
350
+ ? formatHours(task.elapsed_runtime)
351
+ : null,
352
+ },
353
+ {
354
+ label: 'Next',
355
+ value:
356
+ task.due_runtime_at !== null
357
+ ? formatHours(task.due_runtime_at)
358
+ : null,
359
+ },
360
+ {
361
+ label: 'Remaining',
362
+ value:
363
+ task.remaining_runtime !== null
364
+ ? remaining(
365
+ formatRemainingHours(task.remaining_runtime),
366
+ task.runtime_status,
367
+ )
368
+ : null,
369
+ },
370
+ ]}
371
+ />
372
+ ${warnHint(task.runtime_warning_hours, 'h')}
373
+ <${ProgressBar}
374
+ fraction=${task.runtime_fraction}
375
+ status=${task.runtime_status}
376
+ />
377
+ </div>
378
+ `
379
+ : null
380
+ }
288
381
  </div>
289
382
 
290
383
  <div class="page-header" style="margin-top:24px">
@@ -36,6 +36,7 @@
36
36
  * @property {number|null} due_runtime_at
37
37
  * @property {number|null} runtime_fraction
38
38
  * @property {Status|null} runtime_status
39
+ * @property {number|null} elapsed_time_ms
39
40
  * @property {string|null} scheduled_due_date
40
41
  * @property {number|null} scheduled_remaining_ms
41
42
  * @property {number|null} scheduled_fraction
@@ -155,6 +155,19 @@ pre {
155
155
  .page-title .badge {
156
156
  margin-left: 10px;
157
157
  }
158
+ /* tags trail the badges on the same 10px rhythm; their own margins give way to
159
+ a gap so no trailing space knocks the row off center */
160
+ .page-title .chips {
161
+ margin-left: 10px;
162
+ margin-bottom: 0;
163
+ gap: 4px 10px;
164
+ }
165
+ .page-title .chips > * {
166
+ margin: 0;
167
+ }
168
+ .page-title .tag {
169
+ font-weight: 400;
170
+ }
158
171
 
159
172
  /* header action buttons; wraps below the title on narrow screens */
160
173
  .page-actions {
@@ -216,7 +229,7 @@ pre {
216
229
  .btn-danger {
217
230
  background: var(--danger);
218
231
  border-color: var(--danger);
219
- color: #fff;
232
+ color: var(--accent-text);
220
233
  }
221
234
  .btn-danger:hover {
222
235
  background: var(--danger-hover);
@@ -226,7 +239,7 @@ pre {
226
239
  .btn-success {
227
240
  background: var(--ok);
228
241
  border-color: var(--ok);
229
- color: #fff;
242
+ color: var(--accent-text);
230
243
  }
231
244
  .btn-success:hover {
232
245
  background: var(--ok-hover);
@@ -304,6 +317,12 @@ pre {
304
317
  min-height: 110px;
305
318
  resize: vertical;
306
319
  }
320
+ /* Read-only value shown in an input's slot, so a derived field keeps the
321
+ layout of the editable one it replaces. */
322
+ .input-static {
323
+ background: var(--bg-inset);
324
+ color: var(--text-muted);
325
+ }
307
326
  /* iOS gives date inputs native chrome with its own oversized intrinsic height;
308
327
  flatten it so it matches the other inputs (min-height keeps it from collapsing) */
309
328
  .input[type='date'] {
@@ -549,8 +568,11 @@ pre {
549
568
  background: transparent;
550
569
  }
551
570
 
571
+ /* notes annotate a log entry rather than being its data, so they read one
572
+ step back from the row's figures — in the task log and the master log alike */
552
573
  .log-notes {
553
574
  display: flex;
575
+ color: var(--text-muted);
554
576
  }
555
577
  .log-notes-label {
556
578
  flex-shrink: 0;
@@ -751,9 +773,11 @@ pre {
751
773
  grid-template-columns: 1fr;
752
774
  grid-gap: 16px;
753
775
  }
754
- @media (min-width: 800px) {
776
+ /* description / schedule / runtime share the row evenly, and the survivors
777
+ spread to fill it when a card is hidden (e.g. no runtime source) */
778
+ @media (min-width: 900px) {
755
779
  .detail-grid {
756
- grid-template-columns: 3fr 2fr;
780
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
757
781
  }
758
782
  }
759
783
 
@@ -771,19 +795,43 @@ pre {
771
795
  color: var(--text-muted);
772
796
  }
773
797
 
774
- .stat-row {
775
- margin-bottom: 12px;
776
- }
777
- .stat-row .stat-label {
778
- display: flex;
779
- justify-content: space-between;
798
+ /* label/value readout in the schedule + runtime cards */
799
+ .stat-table {
800
+ width: 100%;
801
+ border-collapse: collapse;
780
802
  font-size: 15px;
803
+ }
804
+ .stat-table th,
805
+ .stat-table td {
806
+ padding: 5px 0;
807
+ vertical-align: baseline;
808
+ border-bottom: 1px solid var(--border);
809
+ }
810
+ .stat-table tr:last-child th,
811
+ .stat-table tr:last-child td {
812
+ border-bottom: 0;
813
+ padding-bottom: 0;
814
+ }
815
+ .stat-table th {
816
+ text-align: left;
817
+ font-weight: 400;
781
818
  color: var(--text-muted);
782
- margin-bottom: 4px;
819
+ white-space: nowrap;
783
820
  }
784
- .stat-row .stat-value {
821
+ .stat-table td {
822
+ text-align: right;
785
823
  color: var(--text);
786
824
  font-weight: 600;
825
+ font-variant-numeric: tabular-nums;
826
+ }
827
+ .stat-table + .field-hint {
828
+ margin-top: 10px;
829
+ }
830
+ /* the bar reads below the numbers it summarises, so the two cards' tables
831
+ start on the same line whether or not a card has a bar to draw */
832
+ .stat-table + .progress,
833
+ .field-hint + .progress {
834
+ margin-top: 12px;
787
835
  }
788
836
 
789
837
  .markdown p:first-child {