@meri-imperiumi/signalk-passage-briefing 0.5.2 → 0.6.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
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.0] - 2026-10-04
6
+
7
+ ### Added
8
+
9
+ - Timezone change events along the route (work doc #19): the briefing now says when the clock itself changes — the moment a crew sets watches and re-plans arrival in local time. Offshore, the simulated track detects crossings of the 15° zone meridians (short-arc direction from the step's longitude delta, so the antimeridian reads "180°" either way) and the unified timeline renders them as `time` events (◷ glyph): "Crossing 165°E — solar time 1 h ahead/behind — clock change due", advising but never asserting what the crew does with the clock. Crossings inside territorial waters stay quiet — the local zone governs there — using distance stints folded from the plugin's enter/leave transitions. Territorial transitions (work doc #17) are now timezone-annotated: the IANA zone at the crossing point comes from vendored tz-lookup (CC0, ~72 KB single ES module under `public/vendor/tz-lookup/` — resolved by position, which sidesteps the multi-zone-country problem entirely, and consistent with signalk-ships-time's answer) and its UTC offset at the crossing instant from the platform's `Intl` database (DST handled, no dependency); `mergeTimeline` adds the "time zone UTC+13" line when the crossing's offset differs from the vessel's current zone, or when none is published. Stamps themselves stay in the current ship's zone.
10
+ - No-sails semantics: slatting and survival regimes (work doc #26). On a cruising boat a logged `NO_SAILS` means one of three things the old pipeline could not tell apart — the slatting regime (apparent wind too low to keep flow attached: canvas down so it stops banging, the engine drives), the survival regime (wind very high: nothing set, nobody motors into it) — or a propulsion/non-passage state (motor-sailing nights, anchorage entries). The learning pipeline now enforces that reading. The regimes are defined in the physics module: `SAILS_FILL_MIN_AWS_KNOTS` (7 kt apparent, mirroring the upwind slatting gate in apparent rather than true wind — motoring into light air is exactly when canvas slats) and `SAILS_MAX_AWS_KNOTS` (45 kt apparent — Beaufort 9 territory, deliberately decoupled from the comfort bands whose 33 kt line marks seasickness, not the rig decision; the vessel carries working canvas standing well into the forties), with `noSailsRegime()` answering slatting / survival / null. The learning gate folds a `NO_SAILS` observation into a rig bin only when its wind window justifies it — the window's average TWS below the point-of-sail slatting gates (7 kt upwind, 12 kt downwind, from the comfort model) or its peak gust at the survival floor; a mid-scale bare-poles observation still caches its wind window (idempotency intact) and stays in the logbook record, but never wins the bin, and the backfill counts it in a new `skippedNoSails` the UI reports ("7 bare-poles entries kept out of the matrix"). The simulation only accepts a `NO_SAILS` suggestion when the step's real apparent wind (boat speed and heading included) sits in a regime, so already-poisoned learned cells cannot depower a passage. The drive under an accepted canvas-down follows one shared rule for rows and events: survival — no drive, the water moves the boat, and the timeline names the decision "Storm tactics" rather than claiming a tactic (heave to, drogue, run off — proper storm tactics are their own work document, #27); becalmed with drift mode enabled — drift on the current alone; otherwise the engine pushes at motor speed with honest motor hours and fuel. A plan holding `NO_SAILS` no longer makes phantom polar speed with zero motor hours. The one-time migration on plugin start clears the learned matrix, its wind-history cache and the recorded events (guarded by `PRAGMA user_version`, logbook files untouched) so the next backfill rebuilds every bin under the new rules — the recency rule had let single bare-poles entries erase a bin's real rig, and that loss is unrecoverable from the matrix alone; empty bins degrade safely to "keep the current rig". **Upgrading crews must run the Logbook Backfill once after starting this version** (the webapp card, or `POST /plugins/.../api/backfill`) — until then the learned plan is empty and the simulation keeps whatever rig the plan already had.
11
+
12
+ ### Changed
13
+
14
+ - Slatting has a name wherever the comfort tier shows: timeline details at sail changes and sparkline titles read "slatting" instead of the escalated "rough" — a 10 kn afternoon in a residual swell was rendering as weather it isn't, sending someone looking for wind that isn't there. The comfort explainer (ℹ️) carries a SLATTING entry with its thresholds ("wind < 7–12 kn by point of sail · swell ≥ 0.6 m").
15
+
16
+ ### Fixed
17
+
18
+ - Phone timeline layout: the when-gutter (relative hours + date stamp + moon glyph) squeezed the event text to the side on narrow screens. On phone (below the app's 700 px layout breakpoint) each entry now renders on two lines — the date/time gutter on top at full width, glyph and event data beneath with the whole row's width available. Desktop layout unchanged.
19
+ - No more tacks and gybes with canvas down: the maneuver detector's sailing-step test now requires the plan's rig up — a course change under power or carried by the current is not a tack or gybe. This closes a real leak: drift rows carry `motoring: false` and can hold current-driven SOG above the sailing threshold, previously qualifying as sailing steps.
20
+
5
21
  ## [0.5.2] - 2026-10-04
6
22
 
7
23
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -334,17 +334,35 @@ function createHistoryWindStats({
334
334
  * window's peak wind. The preferred sail of a bin is the most recent
335
335
  * observation.
336
336
  *
337
+ * Canvas-off observations are gated (work doc #26): a `NO_SAILS`
338
+ * observation folds into the rig matrix only when the weather
339
+ * justifies it — the two-regime decision the rig actually encodes.
340
+ * A mid-scale one is a propulsion decision (the engine drives) or a
341
+ * non-passage state (anchorage, marina); folding it would teach the
342
+ * plan to hold bare poles through sail-carrying weather.
343
+ *
337
344
  * @param {object} params
338
345
  * @param {import("./sqlite-db.js").PassageDatabase} params.db
339
346
  * @param {{timestamp: string, eventType: string, sailState: string, night?: boolean, notes?: string}} params.event
340
347
  * @param {WindWindowStats} params.stats
341
348
  * @param {boolean} [params.night] - Day/night bucket of the event
342
349
  * (defaults to the event's own flag, else day)
350
+ * @param {object} [params.physics] - The loaded sereno-physics module
351
+ * (constants for the regime gates). Omitted, the gate stays
352
+ * conservative: a `NO_SAILS` observation cannot be justified and is
353
+ * kept out of the rig matrix.
343
354
  * @returns {{twsBin: number, twaBin: number, night: boolean,
344
355
  * preferredSail: string, avgTwsTrigger: number, peakGustTrigger:
345
- * number, sampleCount: number}}
356
+ * number, sampleCount: number}|null} The learned cell, or null when
357
+ * the observation was gated out of the rig matrix
346
358
  */
347
- function applySailEvent({ db, event, stats, night = event.night ?? false }) {
359
+ function applySailEvent({
360
+ db,
361
+ event,
362
+ stats,
363
+ night = event.night ?? false,
364
+ physics = null,
365
+ }) {
348
366
  db.upsertWindHistory({
349
367
  timestamp: event.timestamp,
350
368
  twsAvg: stats.twsAvg,
@@ -352,6 +370,13 @@ function applySailEvent({ db, event, stats, night = event.night ?? false }) {
352
370
  twaAvg: stats.twaAvg,
353
371
  });
354
372
 
373
+ if (
374
+ event.sailState === "NO_SAILS" &&
375
+ noSailsWindowRegime(stats, physics) == null
376
+ ) {
377
+ return null;
378
+ }
379
+
355
380
  const twsBin = twsBinIndex(stats.twsAvg);
356
381
  const twaBin = twaBinIndex(stats.twaAvg);
357
382
  const existing = db
@@ -377,6 +402,40 @@ function applySailEvent({ db, event, stats, night = event.night ?? false }) {
377
402
  return cell;
378
403
  }
379
404
 
405
+ /**
406
+ * The canvas-off regime a wind window justifies (work doc #26): the
407
+ * rig is already down in these observations, so apparent wind ≈ true
408
+ * wind and the regimes read off the window stats. Light air uses the
409
+ * comfort model's point-of-sail slatting gates (work doc #14: 7 kt
410
+ * upwind, 12 kt downwind); survival reads the window's peak gust
411
+ * against the top of the Sereno AWS bands.
412
+ *
413
+ * @param {WindWindowStats} stats - Window statistics
414
+ * @param {object|null} physics - The sereno-physics module (regime
415
+ * gate constants); null keeps the gate conservative
416
+ * @returns {"slatting"|"survival"|null}
417
+ */
418
+ function noSailsWindowRegime(stats, physics) {
419
+ if (!stats || !physics) {
420
+ return null;
421
+ }
422
+ const { twsAvg, twsPeak, twaAvg } = stats;
423
+ if (!Number.isFinite(twsAvg)) {
424
+ return null;
425
+ }
426
+ const upwind = Math.abs(Number.isFinite(twaAvg) ? twaAvg : 0) < 90;
427
+ const lightFloor = upwind
428
+ ? physics.SLATTING_TWS_UPWIND_KNOTS
429
+ : physics.SLATTING_TWS_DOWNWIND_KNOTS;
430
+ if (twsAvg < lightFloor) {
431
+ return "slatting";
432
+ }
433
+ if (Number.isFinite(twsPeak) && twsPeak >= physics.SAILS_MAX_AWS_KNOTS) {
434
+ return "survival";
435
+ }
436
+ return null;
437
+ }
438
+
380
439
  /**
381
440
  * Runs the backfill over a list of sail events (SPEC §4.2). Events are
382
441
  * processed chronologically; an event already present in
@@ -399,7 +458,8 @@ function applySailEvent({ db, event, stats, night = event.night ?? false }) {
399
458
  * skippedNoData: number, cells: object[]}>}
400
459
  */
401
460
  async function backfillSailEvents({ db, events, getWindStats }) {
402
- const { isNight } = await loadPhysics();
461
+ const physics = await loadPhysics();
462
+ const { isNight } = physics;
403
463
  const sorted = [...events].sort(
404
464
  (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
405
465
  );
@@ -407,6 +467,7 @@ async function backfillSailEvents({ db, events, getWindStats }) {
407
467
  let learned = 0;
408
468
  let skippedCached = 0;
409
469
  let skippedNoData = 0;
470
+ let skippedNoSails = 0;
410
471
  const cells = [];
411
472
 
412
473
  for (const event of sorted) {
@@ -433,7 +494,15 @@ async function backfillSailEvents({ db, events, getWindStats }) {
433
494
  event.position.longitude,
434
495
  )
435
496
  : false);
436
- cells.push(applySailEvent({ db, event, stats, night }));
497
+ const cell = applySailEvent({ db, event, stats, night, physics });
498
+ if (cell) {
499
+ cells.push(cell);
500
+ learned++;
501
+ } else {
502
+ // Gated out of the rig matrix (work doc #26): still cached and
503
+ // recorded, but nothing was learned
504
+ skippedNoSails++;
505
+ }
437
506
  db.recordSailEvent({
438
507
  timestamp: event.timestamp,
439
508
  eventType: event.eventType,
@@ -441,7 +510,6 @@ async function backfillSailEvents({ db, events, getWindStats }) {
441
510
  night,
442
511
  notes: event.notes,
443
512
  });
444
- learned++;
445
513
  }
446
514
 
447
515
  return {
@@ -449,6 +517,7 @@ async function backfillSailEvents({ db, events, getWindStats }) {
449
517
  learned,
450
518
  skippedCached,
451
519
  skippedNoData,
520
+ skippedNoSails,
452
521
  cells,
453
522
  };
454
523
  }
package/plugin/index.js CHANGED
@@ -1443,6 +1443,17 @@ module.exports = (app) => {
1443
1443
  return join(configPath, "plugin-config-data", "signalk-logbook");
1444
1444
  }
1445
1445
 
1446
+ /** Startup grace (ms) before the delta-fed path sources are
1447
+ * recorded in the status registry (work doc #23, bergie session
1448
+ * note): the server replays current values to new subscriptions a
1449
+ * few seconds after start-up, and a snapshot taken before that
1450
+ * replay would record a transient failure/absence for paths that
1451
+ * are actually fine — navigation.state is the known case.
1452
+ * Non-path sources (resources, plugin-provided) record from the
1453
+ * first tick. */
1454
+ const PATH_SOURCE_GRACE_MS = 2 * 60 * 1000;
1455
+ let startedAt = Date.now();
1456
+
1446
1457
  /**
1447
1458
  * Records the Signal K source availability in the source status
1448
1459
  * registry (work doc #23): the checklist also covers the server
@@ -1453,9 +1464,9 @@ module.exports = (app) => {
1453
1464
  * there (companion plugin not installed, no route being sailed)
1454
1465
  * records as absent — information, not an error.
1455
1466
  *
1456
- * Runs from the cron ticker: the first minute of uptime is the
1457
- * grace period in which the server replays current values to the
1458
- * new subscriptions.
1467
+ * Runs from the cron ticker: delta-fed path sources are skipped
1468
+ * during the startup grace period, in which the server replays
1469
+ * current values to the new subscriptions.
1459
1470
  *
1460
1471
  * @param {Date} [now]
1461
1472
  */
@@ -1469,55 +1480,66 @@ module.exports = (app) => {
1469
1480
  typeof app.getSelfPath === "function"
1470
1481
  ? unwrap(app.getSelfPath(path))
1471
1482
  : null;
1472
- const pathSources = [
1473
- {
1474
- id: "sk-internet-state",
1475
- path: INTERNET_STATE_PATH,
1476
- label: "Internet state (signalk-internet)",
1477
- required: true,
1478
- },
1479
- {
1480
- id: "sk-navigation-state",
1481
- path: NAVIGATION_STATE_PATH,
1482
- label: "Navigation state",
1483
- required: true,
1484
- },
1485
- {
1486
- id: "sk-house-soc",
1487
- path: HOUSE_SOC_PATH,
1488
- label: "House battery SoC",
1489
- required: true,
1490
- },
1491
- {
1492
- id: "sk-active-route",
1493
- path: ACTIVE_ROUTE_PATH,
1494
- label: "Active route",
1495
- required: false,
1496
- },
1497
- {
1498
- id: "sk-energy-prediction",
1499
- path: ENERGY_FORECAST_PATH,
1500
- label: "Energy outlook (energy-predictor)",
1501
- required: false,
1502
- },
1503
- ];
1504
- for (const source of pathSources) {
1505
- const missing = observations[source.path] == null;
1506
- sourceStatus.record({
1507
- id: source.id,
1508
- label: source.label,
1509
- kind: "signalk",
1510
- url: source.path,
1511
- ...(missing
1512
- ? source.required
1513
- ? {
1514
- error: new Error("No value received since startup"),
1515
- errorClass: "unavailable",
1516
- }
1517
- : { absent: true }
1518
- : {}),
1519
- now,
1520
- });
1483
+ // Delta-fed paths: during the startup grace the server has not
1484
+ // replayed current values yet, so a snapshot here would lie about
1485
+ // sources that are about to populate (navigation.state arrives a
1486
+ // few seconds in). Skip them; they record from the next tick.
1487
+ if (now.getTime() - startedAt >= PATH_SOURCE_GRACE_MS) {
1488
+ const pathSources = [
1489
+ {
1490
+ id: "sk-internet-state",
1491
+ path: INTERNET_STATE_PATH,
1492
+ label: "Internet state (signalk-internet)",
1493
+ required: true,
1494
+ },
1495
+ {
1496
+ id: "sk-navigation-state",
1497
+ path: NAVIGATION_STATE_PATH,
1498
+ label: "Navigation state",
1499
+ // Optional: many boats never publish navigation.state, and
1500
+ // everything that reads it degrades gracefully — the state
1501
+ // machine fetches regardless (unset ≠ moored), and the
1502
+ // departure anchor treats unset as not underway (work doc
1503
+ // #15). A FAIL here would be a permanent false alarm.
1504
+ required: false,
1505
+ },
1506
+ {
1507
+ id: "sk-house-soc",
1508
+ path: HOUSE_SOC_PATH,
1509
+ label: "House battery SoC",
1510
+ required: true,
1511
+ },
1512
+ {
1513
+ id: "sk-active-route",
1514
+ path: ACTIVE_ROUTE_PATH,
1515
+ label: "Active route",
1516
+ required: false,
1517
+ },
1518
+ {
1519
+ id: "sk-energy-prediction",
1520
+ path: ENERGY_FORECAST_PATH,
1521
+ label: "Energy outlook (energy-predictor)",
1522
+ required: false,
1523
+ },
1524
+ ];
1525
+ for (const source of pathSources) {
1526
+ const missing = observations[source.path] == null;
1527
+ sourceStatus.record({
1528
+ id: source.id,
1529
+ label: source.label,
1530
+ kind: "signalk",
1531
+ url: source.path,
1532
+ ...(missing
1533
+ ? source.required
1534
+ ? {
1535
+ error: new Error("No value received since startup"),
1536
+ errorClass: "unavailable",
1537
+ }
1538
+ : { absent: true }
1539
+ : {}),
1540
+ now,
1541
+ });
1542
+ }
1521
1543
  }
1522
1544
  // Resource-side sources
1523
1545
  const routesAvailable =
@@ -1600,6 +1622,11 @@ module.exports = (app) => {
1600
1622
  description:
1601
1623
  "Offshore passage daily briefing: multi-model weather outlook, comfort " +
1602
1624
  "physics and learned sail preferences",
1625
+ // Test seam: shift the start-up instant so the source-status grace
1626
+ // period can be traversed deterministically
1627
+ __setStartedAt: (value) => {
1628
+ startedAt = value;
1629
+ },
1603
1630
 
1604
1631
  schema: {
1605
1632
  type: "object",
@@ -193,6 +193,33 @@ class PassageDatabase {
193
193
  PRIMARY KEY (tws_bin, twa_bin, night)
194
194
  );
195
195
  `);
196
+ this.resetLearnedMatrix();
197
+ }
198
+
199
+ /**
200
+ * One-time migration (work doc #26): clears the learned matrix and
201
+ * its derived stores so the next backfill rebuilds everything under
202
+ * the new learning rules — the no-sails gate, and a learning engine
203
+ * whose recency rule no longer lets one bare-poles entry erase a
204
+ * bin's real rig. Classifying and dropping just the poisoned cells
205
+ * was considered, but whatever rig they had overwritten is
206
+ * unrecoverable from the matrix alone: the honest move is a full
207
+ * re-learn from the logbook. The logbook files themselves are
208
+ * untouched — this only empties the derived store. Until a backfill
209
+ * runs the matrix is empty and suggestions degrade to "keep the
210
+ * current rig".
211
+ */
212
+ resetLearnedMatrix() {
213
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
214
+ if (version >= 1) {
215
+ return;
216
+ }
217
+ this.db.exec(`
218
+ DELETE FROM learned_sail_matrix;
219
+ DELETE FROM wind_history_cache;
220
+ DELETE FROM logbook_sail_events;
221
+ `);
222
+ this.db.prepare("PRAGMA user_version = 1").run();
196
223
  }
197
224
 
198
225
  /**
@@ -83,6 +83,9 @@ class BackfillControls extends HTMLElement {
83
83
  this._out.textContent =
84
84
  `Learned ${summary.learned} of ${summary.total} sail changes` +
85
85
  (skipped > 0 ? ` (skipped ${skipped})` : "") +
86
+ (summary.skippedNoSails > 0
87
+ ? `, ${summary.skippedNoSails} bare-poles entries kept out of the matrix`
88
+ : "") +
86
89
  `, ${summary.cells.length} matrix cells`;
87
90
  } catch (error) {
88
91
  this._out.classList.add("err");
@@ -84,6 +84,23 @@ class ComfortInfo extends HTMLElement {
84
84
  row.appendChild(band);
85
85
  this._pop.appendChild(row);
86
86
  }
87
+ const slatting = document.createElement("div");
88
+ slatting.className = "row";
89
+ const slattingName = document.createElement("span");
90
+ slattingName.className = "name";
91
+ slattingName.textContent = "SLATTING";
92
+ const slattingText = document.createElement("span");
93
+ slattingText.textContent =
94
+ " — Light air over a residual swell: the sails slat and bang, " +
95
+ "snap-rolling the boat.";
96
+ slatting.appendChild(slattingName);
97
+ slatting.appendChild(slattingText);
98
+ const slattingBand = document.createElement("span");
99
+ slattingBand.className = "band";
100
+ slattingBand.textContent =
101
+ "wind < 7–12 kn by point of sail · swell ≥ 0.6 m";
102
+ slatting.appendChild(slattingBand);
103
+ this._pop.appendChild(slatting);
87
104
  }
88
105
  }
89
106
 
@@ -33,10 +33,10 @@ class HorizonSparkline extends HTMLElement {
33
33
  <svg viewBox="0 0 240 96" preserveAspectRatio="none" role="img"
34
34
  aria-label="24 hour comfort and wind outlook">
35
35
  <defs>
36
- <!-- Slatting hatch (work doc #14): Rough blocks triggered by
37
- light air in a residual swell render striped — heavy-weather
38
- rough keeps its solid colour. Transparent background lets
39
- the card colour show between the stripes. -->
36
+ <!-- Slatting hatch: rough comfort triggered by light air in
37
+ a residual swell renders striped — heavy-weather rough keeps
38
+ its solid colour. Transparent background lets the card
39
+ colour show between the stripes. -->
40
40
  <pattern id="slatting-hatch" width="6" height="6"
41
41
  patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
42
42
  <rect width="6" height="6" fill="none"></rect>
@@ -306,7 +306,7 @@ export function sparklineColumns(hourlyComfort, hours = 24, options = {}) {
306
306
  awsKnots: aws,
307
307
  heightPct: Math.max(4, Math.round((aws / maxAws) * 100)),
308
308
  color: comfortColor(b.comfortLevel),
309
- title: `${stamp(b.hoursFromNow)}: ${fmtKn(aws)} AWS, ${b.comfortLevel ?? "?"}${b.slatting ? " (slatting)" : ""}`,
309
+ title: `${stamp(b.hoursFromNow)}: ${fmtKn(aws)} AWS, ${b.slatting ? "slatting" : (b.comfortLevel ?? "?")}`,
310
310
  };
311
311
  });
312
312
  }
@@ -641,15 +641,22 @@ export function sailStateLabel(key) {
641
641
 
642
642
  /**
643
643
  * Label for one sail-change event: bare "No sails" says WHY the
644
- * canvas is down — the plan drifts (`No sails - drifting`) or the
645
- * engine pushes (`Motoring`) — instead of implying bare-pole sailing.
644
+ * canvas is down — the plan drifts (`No sails - drifting`), the
645
+ * engine pushes (`Motoring`), or the weather demands the canvas-off
646
+ * regime (`Storm tactics`: at survival wind the tactic — heave to,
647
+ * drogue, run off — is the crew's call, not the simulator's; work
648
+ * doc #27 will pick it up) — instead of implying bare-pole sailing.
646
649
  *
647
- * @param {{sailState: string|null, propulsion?: string|null}} event
650
+ * @param {{sailState: string|null, propulsion?: string|null,
651
+ * canvasOffRegime?: string|null}} event
648
652
  * @returns {string} Human label
649
653
  */
650
654
  function sailEventLabel(event) {
651
655
  const label = sailStateLabel(event.sailState);
652
656
  if (label === "No sails") {
657
+ if (event.canvasOffRegime === "survival") {
658
+ return "Storm tactics";
659
+ }
653
660
  if (event.propulsion === "adrift") {
654
661
  return "No sails - drifting";
655
662
  }
@@ -707,8 +714,9 @@ function episodeDetail(episode, parts) {
707
714
  * to one chronological shape — `{hoursFromNow, timestamp, stamp,
708
715
  * kind, severity, label, detail}` — sorted by time, undated last.
709
716
  * Kinds: `sail`, `maneuver`, `convective`, `sea`, `zone`, `space`,
710
- * `hazard`; new event sources become new kinds instead of new blocks
711
- * (work doc #1's lines of interest will ride in as `line`).
717
+ * `hazard`, `time`; new event sources become new kinds instead of
718
+ * new blocks (work doc #1's lines of interest will ride in as
719
+ * `line`).
712
720
  * Severities: `info`, `warn`, `severe` — the renderer's colour
713
721
  * scale. The tactical dashboard renders the 24 h slice of this list,
714
722
  * the strategic outlook the whole passage.
@@ -724,7 +732,7 @@ function episodeDetail(episode, parts) {
724
732
  * @param {object|null} exceptions - Worker exception view
725
733
  * @param {object|null} payload - Briefing payload
726
734
  * (UnifiedWeatherPayload; optional `spaceEvents`,
727
- * `zoneTransitions`, `celestialNights`)
735
+ * `zoneTransitions`, `celestialNights`, `timeZoneChanges`)
728
736
  * @returns {Array<{hoursFromNow: number|null, timestamp: string|null, stamp: string, kind: string, severity: string, label: string, detail: string, night: boolean, moon: string|null}>}
729
737
  */
730
738
  /**
@@ -982,7 +990,10 @@ export function mergeTimeline(exceptions, payload = null) {
982
990
  // Conditions at the change point (work doc #18): what the crew
983
991
  // is rigging into — true wind, sea state, comfort tier. Falls
984
992
  // back to the bare maneuver TWS for payloads without the
985
- // enriched conditions block.
993
+ // enriched conditions block. When the tier came from the
994
+ // slatting penalty (work doc #14: light air over a residual
995
+ // swell), the detail says so — "rough" alone would send someone
996
+ // looking for wind that isn't there.
986
997
  const conditions = e.conditions ?? null;
987
998
  const tws = conditions?.twsKnots ?? e.twsAtManeuver ?? null;
988
999
  // Why the change sits where it sits: watch handover when a watch
@@ -1009,7 +1020,7 @@ export function mergeTimeline(exceptions, payload = null) {
1009
1020
  conditions?.hsMeters != null
1010
1021
  ? `Hs ${conditions.hsMeters.toFixed(1)} m`
1011
1022
  : null,
1012
- conditions?.comfortLevel ?? null,
1023
+ conditions?.slatting ? "slatting" : (conditions?.comfortLevel ?? null),
1013
1024
  anchorText,
1014
1025
  ]
1015
1026
  .filter(Boolean)
@@ -1105,6 +1116,9 @@ export function mergeTimeline(exceptions, payload = null) {
1105
1116
  // (summary), falling back to the payload's undated transitions;
1106
1117
  // the timeline kind is always `zone`. Leaving territorial waters
1107
1118
  // carries the connectivity note — the metered-ocean boundary.
1119
+ // When the crossing lands in another timezone than the vessel's
1120
+ // current one, the event says so (work doc #19) — exactly the
1121
+ // moment a crew sets watches and re-plans arrival in local time.
1108
1122
  for (const z of summary.zoneTransitions ?? payload?.zoneTransitions ?? []) {
1109
1123
  const territory = z.territory?.name ?? "?";
1110
1124
  push({
@@ -1120,12 +1134,32 @@ export function mergeTimeline(exceptions, payload = null) {
1120
1134
  z.distanceFromStartNm != null
1121
1135
  ? `${Math.round(z.distanceFromStartNm)} nm from departure`
1122
1136
  : null,
1137
+ z.zoneOffsetMinutes != null &&
1138
+ (shipTime == null || z.zoneOffsetMinutes !== shipTime.offsetMinutes)
1139
+ ? `time zone ${offsetLabel(z.zoneOffsetMinutes)}`
1140
+ : null,
1123
1141
  ]
1124
1142
  .filter(Boolean)
1125
1143
  .join(" · "),
1126
1144
  });
1127
1145
  }
1128
1146
 
1147
+ // Zone meridian crossings offshore (work doc #19): solar time
1148
+ // drifts 1 h per 15° of longitude, so watch schedules quietly
1149
+ // desynchronize from the sun unless the clock follows. The event
1150
+ // advises; what the crew does with the clock is the master's
1151
+ // discretion.
1152
+ for (const c of summary.timeZoneChanges ?? payload?.timeZoneChanges ?? []) {
1153
+ push({
1154
+ hoursFromNow: c.hoursFromNow ?? relHours(c.timestamp, fetchMs),
1155
+ timestamp: c.timestamp ?? null,
1156
+ kind: "time",
1157
+ severity: "info",
1158
+ label: `Crossing ${c.meridian ?? "a zone meridian"}`,
1159
+ detail: `solar time 1 h ${c.eastbound ? "ahead" : "behind"} — clock change due`,
1160
+ });
1161
+ }
1162
+
1129
1163
  // Night indicators for every dated item (work doc #17 session
1130
1164
  // feedback): interpolate the crew's position at the item's hour
1131
1165
  // from the simulated track and test the sun — one uniform answer
@@ -31,6 +31,7 @@ const GLYPHS = {
31
31
  line: "⌀",
32
32
  energy: "🔋",
33
33
  departure: "⚓",
34
+ time: "◷",
34
35
  };
35
36
 
36
37
  /**
@@ -71,6 +72,16 @@ class PassageTimeline extends HTMLElement {
71
72
  .sev-severe .glyph, .sev-severe .label { color: var(--color-red); }
72
73
  .sev-info .glyph { color: var(--color-teal); }
73
74
  .none { color: var(--text-muted); font-size: 0.85rem; }
75
+ /* Phone: the when gutter is too wide and squeezes the event
76
+ * text, so the date line goes on top at full width and the
77
+ * event data flows beneath it. Matches the 700px layout
78
+ * breakpoint used across the app. */
79
+ @media (max-width: 699px) {
80
+ .entry { grid-template-columns: auto 1fr; }
81
+ .when { grid-area: 1 / 1 / 2 / 3; }
82
+ .glyph { grid-area: 2 / 1 / 3 / 2; }
83
+ .body { grid-area: 2 / 2 / 3 / 3; }
84
+ }
74
85
  </style>
75
86
  <div id="list"></div>
76
87
  `;
@@ -117,6 +128,7 @@ class PassageTimeline extends HTMLElement {
117
128
  glyph.title = item.kind ?? "";
118
129
 
119
130
  const body = document.createElement("span");
131
+ body.className = "body";
120
132
  const label = document.createElement("span");
121
133
  label.className = "label";
122
134
  label.textContent = item.label ?? "?";