@meri-imperiumi/signalk-passage-briefing 0.9.0 → 0.10.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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.0] - 2026-10-14
6
+
7
+ ### Added
8
+ - Rain-shower episodes on the unified timeline (probable squalls): Open-Meteo `precipitation` (hourly mm/h) and `precipitation_probability` join the fetch, ride `weatherAt`'s interpolation under the same unknown-stays-null rule as cloud cover, and hours at or over the shower bar (≥ 0.5 mm/h rate or ≥ 50 % probability — whichever the model still publishes at that horizon) merge into contiguous `rainEpisodes` carrying the peak rate and probability. They render as info-level 🌧 timeline entries (“Rain showers · 2.5 mm/h · 80 % · until …”) — tropical rain rarely arrives without squalls, so probable rain is the crew's probable-squall advisory without claiming convection's warning weight. The sky line on tactical and here gains the now rain segment above the same bars (🌧 65 %, or 🌧 0.8 mm/h when the model publishes only a rate — drier hours stay quiet); Weather API provider mode publishes no precipitation fields and degrades to absent rather than inventing them.
9
+
10
+ ### Changed
11
+ - Two comfort tiers renamed for the project's modified Sereno scale — `coffee` is now `sporty` ("Lively enough that a hot coffee needs a firm grip.") and `sick` is now `miserable` ("Nobody is enjoying this. Seasickness very likely.") — the canonical identifiers everywhere: physics module, CSS tier tokens (`--comfort-sporty`/`--comfort-miserable`), the embed widget's color map, the status-tile example mapping, and the Signal K value the plugin publishes. Briefings compiled before the rename serve raw old tier names — unstyled, never wrong-colored — until their next compile rewrites them; no compatibility mapping, one unambiguous scale.
12
+ - Sail-change and maneuver timeline entries name the point of sail: `Genoa 1 + Main — broad reach · 9.5 kn TWS · Hs 1.1 m · coffee · at dawn`. The signed TWA at the change point (nearest hourly step, wind-from-starboard positive, same convention as the maneuver tack calls) rides the event's conditions block as `twaDeg`; the timeline renders it as the standard angle-band text (close-hauled / close reach / beam reach / broad reach / running, sign-blind since the maneuver label already calls the tack). Payloads compiled before the field degrade to the previous detail line without inventing an angle.
13
+
14
+ ### Fixed
15
+ - Conditions here showed two "Update now" buttons stacked when no cached conditions existed: the no-cached-conditions strip carried its own button while the conditions card head directly below always carries one. The strip now stays text-only in here mode (the card-head button is the refresh affordance there, present with and without cached data); the route-mode strip keeps its button, as the tab views have no other on-screen refresh.
16
+ - The comfort-scale legend (the ℹ️ popover) opened past the right edge of a phone screen, dragging the page into a sideways scroll with half the legend unreachable. While open it now pins to the viewport — clamped inside the window on both axes, flipping above the summary when the space below runs out, internally scrollable when taller than the screen, and repositioning on scroll, resize and tier-highlight changes.
17
+ - The conditions-here celestial & space events card rendered raw ISO timestamps (`2026-10-12T07:06:12.650Z Tiangong pass: …`) instead of the app's formatted stamps: the renderer read a `stamp` field the plugin never sets, falling through to the raw event timestamp. Events now render through the shared ship's-time formatter like every other view (`MM-DD HH:MM` with the zone offset, `MM-DD HH:MMZ` in UTC when the vessel publishes no timezone), per the Signal K visuals time-formatting rules.
18
+ - Energy timeline events rendered their detail stamps in UTC (`battery depleted by 06-22 03:00Z`) while the same rows' timeline stamp renders in ship's time — a leftover from before the ship's-time work, forcing the crew to do arithmetic between two renderings of the same instant. The `until`/`battery depleted by` details now use the shared ship's-time formatter like every other stamp.
19
+
5
20
  ## [0.9.0] - 2026-10-12
6
21
 
7
22
  ### Added
package/README.md CHANGED
@@ -7,14 +7,14 @@ Offshore passage daily briefing webapp for Signal K: a plugin that plans and rev
7
7
  - Simulates the passage hourly from the boat's actual position: speed from the vessel's polar, current added, ETA percentiles for the remaining route
8
8
  - Recommends sail changes hour by hour from what the crew actually did, learned from the electronic logbook
9
9
  - Rates every hour on the Sereno comfort scale and flags when light air in a residual swell will be worse than a gale
10
- - Renders one unified timeline: sail work, tacks and gybes, clock changes, territorial waters, official alerts, sky events and hazards in order
10
+ - Renders one unified timeline: sail work, tacks and gybes, clock changes, territorial waters, official alerts, sky events, rain showers and hazards in order
11
11
  - Fetches bulletins, official structured alerts, global disaster events and synoptic charts for the waters the route actually crosses
12
12
  - Keeps working offline: everything fetches in the online window and survives the other 23 hours from the cache
13
13
  - Shows a mini summary on the chart plotter and learns from every logbook entry the crew writes
14
14
 
15
15
  The plugin fetches weather along the planned route — from the server's Weather API when a provider answers, Open-Meteo otherwise; fetched online, or via a GRIB/text spool when offline offshore. It runs a step-forward isochrone simulation with a monohull comfort model, learns the crew's sail preferences from the electronic logbook, and serves a two-screen webapp: a 24-hour tactical dashboard and a strategic passage summary. See [SPEC.md](SPEC.md) for the full design.
16
16
 
17
- Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-predictor](https://github.com/meri-imperiumi/signalk-energy-predictator) and [@meri-imperiumi/signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook).
17
+ Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-predictor](https://github.com/meri-imperiumi/signalk-energy-predictor) and [@meri-imperiumi/signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook).
18
18
 
19
19
  ## Data sources
20
20
 
@@ -32,13 +32,13 @@ Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-p
32
32
  - [signalk-ships-time](https://github.com/meri-imperiumi/signalk-ships-time) — `environment.time.timezoneOffset` / `.timezoneRegion`, the vessel's published timezone: briefing stamps render in ship's time when available (the offset rides on every stamp), falling back to UTC `Z` when nothing is published
33
33
  - [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule) — `watch.state.onWatch`, `watch.state.startedAt`, `watch.system` and `watch.schedule`: the running watch rotation. While a watch is running, planned sail changes anchor to the previous watch handover (both teams awake) instead of sunrise/sunset; boundaries are extrapolated across the forecast horizon from the rotation cycle
34
34
  - [@signalk/sailsconfiguration](https://www.npmjs.com/package/@signalk/sailsconfiguration) — sail inventory used to filter free-text noise out of log entries
35
- - Weather API (`app.weatherApi`) — waypoint forecasts from the registered provider, the preferred source (`weather_source: auto`) whenever one answers; [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus) serves it from its decoded ECMWF run, so briefing numbers match what the router planned with. That source carries combined sea only: swell/wind-sea partitions and the upper-air fields behind the convective warnings degrade to absent rather than being invented
35
+ - Weather API (`app.weatherApi`) — waypoint forecasts from the registered provider, the preferred source (`weather_source: auto`) whenever one answers; [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus) serves it from its decoded ECMWF run, so briefing numbers match what the router planned with. That source carries combined sea only: swell/wind-sea partitions, the upper-air fields behind the convective warnings and the precipitation fields behind the rain-shower episodes degrade to absent rather than being invented
36
36
 
37
37
  ### External
38
38
 
39
- The Open-Meteo entries below are the fallback weather source: used when no Weather API provider answers (or `weather_source` is `open-meteo`), and the only source of the partition and upper-air fields the Weather API providers don't publish.
39
+ The Open-Meteo entries below are the fallback weather source: used when no Weather API provider answers (or `weather_source` is `open-meteo`), and the only source of the partition, upper-air and precipitation fields the Weather API providers don't publish.
40
40
 
41
- - [Open-Meteo Forecast API](https://open-meteo.com/en/docs) — surface wind, gusts, MSL pressure, CAPE and the pressure-layer fields behind the K-index (best-match model)
41
+ - [Open-Meteo Forecast API](https://open-meteo.com/en/docs) — surface wind, gusts, MSL pressure, CAPE, precipitation (hourly rate and probability) and the pressure-layer fields behind the K-index (best-match model)
42
42
  - [NOAA SWPC planetary K-index forecast](https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json) — geomagnetic activity behind the aurora advisories
43
43
  - [JPL SBDB query API](https://ssd-api.jpl.nasa.gov/doc/sbdb_query.html) — comet brightness parameters (M1/K1) behind the Sky Notes comets
44
44
  - [CelesTrak](https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle) — two-line elements for the crewed stations (ISS, Tiangong); bright satellite passes are propagated locally from the fetched TLEs with SGP4, gated to nautical night, clear sky and a 15° haze line. The same service's per-catalog queries (`gp.php?CATNR=…`) supply the current elements of decaying satellites named by Space-Track's reentry predictions
@@ -64,7 +64,7 @@ All external fetches are online-gated and cached to the plugin data directory, s
64
64
 
65
65
  ## Acknowledgments
66
66
 
67
- The comfort model and the whole idea of "what will each departure actually feel like" come from SV Sabado's [passage-weather](https://github.com/sailing12388/passage-weather) ensemble departure planner by Ray Hendricks. The Sereno comfort scale (Champagne, Easy, Coffee, Rough, Sick), the encounter-period motion math, and the ISO 2631-1 comfort bands are adapted from it for a monohull (heel and waterline-pitch resonance instead of catamaran beam-roll and bridgedeck slam).
67
+ The comfort model and the whole idea of "what will each departure actually feel like" come from SV Sabado's [passage-weather](https://github.com/sailing12388/passage-weather) ensemble departure planner by Ray Hendricks. Ours is a modified Sereno comfort scale, not a direct copy: the thresholds and the encounter-period motion math are adapted for a monohull (heel and waterline-pitch resonance instead of catamaran beam-roll and bridgedeck slam), the tier names with their meanings are our own — Champagne, Easy, Sporty, Rough, Miserable — and the slatting penalty (light air over a residual swell snap-rolling the boat, which raw acceleration scores as calm) is a vector upstream lacks entirely; a catamaran slats far less, and few models bother with light-air discomfort. The ISO 2631-1 comfort bands complete the model.
68
68
 
69
69
  ## License
70
70
 
package/SPEC.md CHANGED
@@ -287,6 +287,8 @@ interface TimeStepForecast {
287
287
  twd: number; // degrees true
288
288
  mslp: number; // hPa
289
289
  cloudCover: number | null; // percent (0-100); null when the source carries none
290
+ precip: number | null; // mm/h; null when the source carries none
291
+ precipProbability: number | null; // percent (0-100); null when unknown
290
292
  };
291
293
  marine: {
292
294
  hsCombined: number; // meters
@@ -439,15 +441,17 @@ Executed inside `public/worker.js` for time steps $\Delta t = 1.0\text{ hour}$:
439
441
 
440
442
  ### 5.2 Monohull Sereno Comfort Index Formulation
441
443
 
444
+ A modified Sereno comfort scale, not a direct copy of the upstream planner's: the band thresholds and motion math derive from passage-weather, the tier names and their one-line meanings are this project's own, and the slatting & roll-dampening penalty below is a vector upstream has nothing of — a catamaran slats far less, and light-air discomfort rarely gets modeled.
445
+
442
446
  #### Vector 1: Apparent Wind Speed Rating
443
447
 
444
448
  $$AWS = \sqrt{TWS^2 + STW^2 + 2 \cdot TWS \cdot STW \cdot \cos(TWA)}$$
445
449
 
446
450
  * $AWS < 12\text{ kt} \implies \mathbf{Champagne}$
447
451
  * $12\text{ kt} \le AWS < 18\text{ kt} \implies \mathbf{Easy}$
448
- * $18\text{ kt} \le AWS < 23\text{ kt} \implies \mathbf{Coffee}$
452
+ * $18\text{ kt} \le AWS < 23\text{ kt} \implies \mathbf{Sporty}$
449
453
  * $23\text{ kt} \le AWS < 33\text{ kt} \implies \mathbf{Rough}$
450
- * $AWS \ge 33\text{ kt} \implies \mathbf{Sick}$
454
+ * $AWS \ge 33\text{ kt} \implies \mathbf{Miserable}$
451
455
 
452
456
  #### Vector 2: Monohull Vertical Acceleration ($a_z$)
453
457
 
@@ -482,9 +486,9 @@ $$a_z = a_{z,\text{base}} \cdot \mu_{\text{heel}} \cdot \mu_{\text{pitch}}$$
482
486
 
483
487
  * $a_z < 0.15\text{ m/s}^2 \implies \mathbf{Champagne}$
484
488
  * $0.15 \le a_z < 0.315\text{ m/s}^2 \implies \mathbf{Easy}$
485
- * $0.315 \le a_z < 0.630\text{ m/s}^2 \implies \mathbf{Coffee}$
489
+ * $0.315 \le a_z < 0.630\text{ m/s}^2 \implies \mathbf{Sporty}$
486
490
  * $0.630 \le a_z < 1.250\text{ m/s}^2 \implies \mathbf{Rough}$
487
- * $a_z \ge 1.250\text{ m/s}^2 \implies \mathbf{Sick}$
491
+ * $a_z \ge 1.250\text{ m/s}^2 \implies \mathbf{Miserable}$
488
492
 
489
493
  #### Slatting & Roll-Dampening Penalty (work doc #14)
490
494
 
@@ -527,9 +531,9 @@ $$\text{IntersectCount} = \sum_{j=1}^{k} \text{RayCrossesSegment}(P_{\text{inter
527
531
  --color-fg: #ffcc00;
528
532
  --color-champagne: #00ffcc;
529
533
  --color-easy: #00ff00;
530
- --color-coffee: #ffcc00;
534
+ --color-sporty: #ffcc00;
531
535
  --color-rough: #ff6600;
532
- --color-sick: #ff0000;
536
+ --color-miserable: #ff0000;
533
537
  --font-family: 'Courier New', Courier, monospace;
534
538
  }
535
539
 
@@ -565,6 +569,12 @@ function filterExceptions(simulationResult) {
565
569
  totalFuelGal: simulationResult.fuelConsumptionGal,
566
570
  macroSeaAnomalies: simulationResult.seaStateAnomalies.filter(a => a.steepnessRatio < 3.28),
567
571
  convectiveWarnings: simulationResult.upperAirAnomalies.filter(u => u.cape > 1000 || u.kIndex > 28),
572
+ // Rain-shower episodes: hours at or over the shower bar
573
+ // (precip ≥ 0.5 mm/h or probability ≥ 50 %) merged into one
574
+ // entry each with the peak rate and probability. The crew reads
575
+ // probable rain as probable squalls in the tropics — info-level
576
+ // timeline entries, not warnings
577
+ rainEpisodes: mergeEpisodes(simulationResult.rainHours),
568
578
  // Meteor-shower nights evaluated along the track (work doc #37):
569
579
  // per (shower, night) spaceEvents gated at the night's nautical-dusk
570
580
  // anchor, the radiant checked at the track's forecasted position;
@@ -605,7 +615,7 @@ Displays:
605
615
 
606
616
  #### `<horizon-sparkline>` (SVG Renderer)
607
617
 
608
- Renders a 24-column SVG bar chart where bar background color maps to `comfortLevel` (Champagne, Easy, Coffee, Rough, Sick) and bar height represents $AWS$.
618
+ Renders a 24-column SVG bar chart where bar background color maps to `comfortLevel` (Champagne, Easy, Sporty, Rough, Miserable) and bar height represents $AWS$.
609
619
 
610
620
  ---
611
621
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -5,9 +5,10 @@
5
5
  * Open-Meteo, the same source SV Sabado's passage-weather vetted
6
6
  * against raw ECMWF/NOAA GRIB:
7
7
  *
8
- * - forecast API: surface wind (kn), gusts, MSL pressure, CAPE, and
9
- * the pressure-layer fields behind the K-index (computed here from
10
- * T850/T700/T500 and dewpoints via Magnus);
8
+ * - forecast API: surface wind (kn), gusts, MSL pressure, CAPE,
9
+ * precipitation (rate and probability), and the pressure-layer
10
+ * fields behind the K-index (computed here from T850/T700/T500 and
11
+ * dewpoints via Magnus);
11
12
  * - marine API (`ncep_gfswave025`): combined sea plus wind sea and
12
13
  * swell partitions (the SPEC §3.1 marine block needs the split;
13
14
  * ECMWF's split fields come back empty per passage-weather's
@@ -74,6 +75,8 @@ const FORECAST_HOURLY = [
74
75
  "relative_humidity_700hPa",
75
76
  "wind_speed_850hPa",
76
77
  "cloud_cover",
78
+ "precipitation",
79
+ "precipitation_probability",
77
80
  ].join(",");
78
81
 
79
82
  const WAVE_HOURLY = [
@@ -445,6 +448,8 @@ function buildTimeSteps(surfaceMap, marineMap, currentMap) {
445
448
  mslp: num(surface.pressure_msl),
446
449
  gust,
447
450
  cloudCover: num(surface.cloud_cover),
451
+ precip: num(surface.precipitation),
452
+ precipProbability: num(surface.precipitation_probability),
448
453
  },
449
454
  marine: {
450
455
  hsCombined: num(marine.wave_height),
@@ -533,6 +538,8 @@ async function fetchWeatherAlongTrack({
533
538
  "relative_humidity_700hPa",
534
539
  "wind_speed_850hPa",
535
540
  "cloud_cover",
541
+ "precipitation",
542
+ "precipitation_probability",
536
543
  ]);
537
544
  const marineMap = hourlyLookup(waveBlocks[index], [
538
545
  "wave_height",
@@ -98,6 +98,10 @@ function weatherDataToTimeStep(item) {
98
98
  // The provider publishes no cloud cover; the celestial
99
99
  // visibility gate stays open (work doc #3 Phase 2)
100
100
  cloudCover: null,
101
+ // Nor any precipitation fields; rain-shower episodes degrade
102
+ // to absent rather than being invented
103
+ precip: null,
104
+ precipProbability: null,
101
105
  },
102
106
  marine: {
103
107
  hsCombined: Number.isFinite(item.water?.waveSignificantHeight)
@@ -279,9 +279,9 @@ class BriefExtWidget extends HTMLElement {
279
279
  {
280
280
  champagne: "#8dfcbb",
281
281
  easy: "#8dfcbb",
282
- coffee: "#fca847",
282
+ sporty: "#fca847",
283
283
  rough: "#fca847",
284
- sick: "#ff5e5e",
284
+ miserable: "#ff5e5e",
285
285
  }[String(comfort).toLowerCase()] ?? "var(--text-main, #c4c4c4)";
286
286
  const stale = this.values["navigation.briefing.stale"] === true;
287
287
  const ageEl = root.querySelector("#age");
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The ℹ️ next to the comfort tier: an expanding explainer for the
3
- * Sereno comfort scale (champagne → sick), highlighting the current
4
- * tier via the `tier` attribute. Used by the tactical dashboard and
3
+ * project's modified Sereno comfort scale (champagne → miserable),
4
+ * highlighting the current tier via the `tier` attribute. Used by the tactical dashboard and
5
5
  * the conditions-here view.
6
6
  *
7
7
  * @file components/comfort-info.js
@@ -32,6 +32,7 @@ class ComfortInfo extends HTMLElement {
32
32
  border: 1px solid var(--color-grey);
33
33
  font-family: var(--font-data, ui-monospace, monospace);
34
34
  font-size: 0.75rem; text-transform: none; letter-spacing: normal;
35
+ max-height: calc(100vh - 16px); overflow-y: auto;
35
36
  }
36
37
  .note { color: var(--color-grey); margin: 0 0 6px; }
37
38
  .row { margin: 4px 0; }
@@ -45,10 +46,66 @@ class ComfortInfo extends HTMLElement {
45
46
  </details>
46
47
  `;
47
48
  this._details = this.shadowRoot.querySelector("details");
49
+ this._summary = this.shadowRoot.querySelector("summary");
48
50
  this._pop = this.shadowRoot.getElementById("pop");
51
+ // Mobile-safe popover (viewport-clamped): anchored absolute it
52
+ // runs off the right edge of a phone screen and drags the page
53
+ // into a sideways scroll. While open it is pinned to the
54
+ // viewport instead, repositioning on scroll and resize.
55
+ this._details.addEventListener("toggle", () => {
56
+ if (this._details.open) {
57
+ requestAnimationFrame(() => this._positionPop());
58
+ window.addEventListener("scroll", this, true);
59
+ window.addEventListener("resize", this);
60
+ } else {
61
+ window.removeEventListener("scroll", this, true);
62
+ window.removeEventListener("resize", this);
63
+ }
64
+ });
49
65
  this.renderPop();
50
66
  }
51
67
 
68
+ /**
69
+ * Repositions the open popover as a viewport-fixed overlay clamped
70
+ * inside the window (handleEvent target for scroll/resize).
71
+ */
72
+ handleEvent() {
73
+ this._positionPop();
74
+ }
75
+
76
+ disconnectedCallback() {
77
+ window.removeEventListener("scroll", this, true);
78
+ window.removeEventListener("resize", this);
79
+ }
80
+
81
+ /** Pins the open popover inside the viewport, flipping above the
82
+ * summary when the space below runs out. */
83
+ _positionPop() {
84
+ if (!this._details?.open || !this._pop) {
85
+ return;
86
+ }
87
+ const rect = this._summary.getBoundingClientRect();
88
+ const margin = 8;
89
+ const gap = 6;
90
+ const width = this._pop.offsetWidth;
91
+ const height = this._pop.offsetHeight;
92
+ const left = Math.min(
93
+ Math.max(rect.left, margin),
94
+ Math.max(window.innerWidth - width - margin, margin),
95
+ );
96
+ let top = rect.bottom + gap;
97
+ if (top + height > window.innerHeight - margin) {
98
+ top = rect.top - height - gap;
99
+ }
100
+ top = Math.min(
101
+ Math.max(top, margin),
102
+ Math.max(window.innerHeight - height - margin, margin),
103
+ );
104
+ this._pop.style.position = "fixed";
105
+ this._pop.style.left = `${left}px`;
106
+ this._pop.style.top = `${top}px`;
107
+ }
108
+
52
109
  attributeChangedCallback() {
53
110
  this.renderPop();
54
111
  }
@@ -101,6 +158,8 @@ class ComfortInfo extends HTMLElement {
101
158
  "wind < 7–12 kn by point of sail · swell ≥ 0.6 m";
102
159
  slatting.appendChild(slattingBand);
103
160
  this._pop.appendChild(slatting);
161
+ // Content changes (tier highlight) can resize the open popover
162
+ this._positionPop();
104
163
  }
105
164
  }
106
165
 
@@ -14,6 +14,7 @@
14
14
 
15
15
  import {
16
16
  fmtKn,
17
+ fmtShip,
17
18
  fmtUtc,
18
19
  hereEnergySummary,
19
20
  hereHourly,
@@ -318,6 +319,8 @@ class ConditionsHere extends HTMLElement {
318
319
  this._skyEl.innerHTML = "";
319
320
  for (const segment of skySegments(payload ?? null, {
320
321
  cloudCover: now.cloudCover ?? null,
322
+ precip: now.precipMmH ?? null,
323
+ precipProbability: now.precipProbability ?? null,
321
324
  })) {
322
325
  const span = document.createElement("span");
323
326
  span.textContent = segment.text;
@@ -353,7 +356,7 @@ class ConditionsHere extends HTMLElement {
353
356
  for (const event of events) {
354
357
  const el = document.createElement("div");
355
358
  el.textContent =
356
- `${event.stamp ?? event.timestamp ?? ""} ${event.description ?? event.kind ?? ""}`.trim();
359
+ `${fmtShip(event.timestamp)} ${event.description ?? event.kind ?? ""}`.trim();
357
360
  // Provenance (work doc #31): satellite passes carry the live
358
361
  // tracker link; computed ephemeris carries none
359
362
  const provUrl = event.provenance?.url;
@@ -14,6 +14,7 @@ import {
14
14
  serenoComfort,
15
15
  travelDirection,
16
16
  } from "../sereno-physics.mjs";
17
+ import { pointOfSail } from "../tack-gybe.js";
17
18
 
18
19
  const DEG = Math.PI / 180;
19
20
 
@@ -24,9 +25,9 @@ const DEG = Math.PI / 180;
24
25
  export const COMFORT_TIERS = [
25
26
  { level: "champagne", css: "var(--comfort-champagne)" },
26
27
  { level: "easy", css: "var(--comfort-easy)" },
27
- { level: "coffee", css: "var(--comfort-coffee)" },
28
+ { level: "sporty", css: "var(--comfort-sporty)" },
28
29
  { level: "rough", css: "var(--comfort-rough)" },
29
- { level: "sick", css: "var(--comfort-sick)" },
30
+ { level: "miserable", css: "var(--comfort-miserable)" },
30
31
  ];
31
32
 
32
33
  const TIER_BY_LEVEL = new Map(COMFORT_TIERS.map((t) => [t.level, t.css]));
@@ -538,6 +539,8 @@ export function hereNow(payload, rows = []) {
538
539
  mslpHpa: null,
539
540
  mslpTrend: null,
540
541
  cloudCover: null,
542
+ precipMmH: null,
543
+ precipProbability: null,
541
544
  stamp: "",
542
545
  };
543
546
  }
@@ -561,6 +564,8 @@ export function hereNow(payload, rows = []) {
561
564
  mslpHpa: mslpNow,
562
565
  mslpTrend,
563
566
  cloudCover: step.surface?.cloudCover ?? null,
567
+ precipMmH: step.surface?.precip ?? null,
568
+ precipProbability: step.surface?.precipProbability ?? null,
564
569
  stamp: fmtShip(step.timestamp),
565
570
  };
566
571
  }
@@ -866,12 +871,21 @@ function moonGlyphFor(timestamp, payload) {
866
871
  * picked for (default: the payload's fetch time)
867
872
  * @param {number|null} [options.cloudCover] - Cloud cover percent at
868
873
  * that instant
874
+ * @param {number|null} [options.precip] - Precipitation rate (mm/h) at
875
+ * that instant
876
+ * @param {number|null} [options.precipProbability] - Precipitation
877
+ * probability percent at that instant
869
878
  * @returns {Array<{text: string, title: string}>} Empty when the
870
879
  * payload predates the sky data
871
880
  */
872
881
  export function skySegments(
873
882
  payload,
874
- { timestamp = null, cloudCover = null } = {},
883
+ {
884
+ timestamp = null,
885
+ cloudCover = null,
886
+ precip = null,
887
+ precipProbability = null,
888
+ } = {},
875
889
  ) {
876
890
  const night = nearestNight(
877
891
  timestamp ?? payload?.metadata?.fetchedAt ?? null,
@@ -922,6 +936,26 @@ export function skySegments(
922
936
  title: `cloud cover ${Math.round(cloudCover)} %`,
923
937
  });
924
938
  }
939
+ // Rain rides the same line as the cloud it falls from — but only
940
+ // when it is probable: the segment speaks at the same shower bars
941
+ // the timeline's rain episodes use (route-sim.mjs RAIN_MM_PER_H /
942
+ // RAIN_PROBABILITY_PERCENT; mirrored here to keep this module
943
+ // dependency-light). A 10 % chance is noise, not weather.
944
+ const rainyRate = precip != null && Number.isFinite(precip) && precip >= 0.5;
945
+ const rainyProb =
946
+ precipProbability != null &&
947
+ Number.isFinite(precipProbability) &&
948
+ precipProbability >= 50;
949
+ if (rainyProb || rainyRate) {
950
+ segments.push({
951
+ text: rainyProb
952
+ ? `🌧 ${Math.round(precipProbability)}%`
953
+ : `🌧 ${precip.toFixed(1)}mm`,
954
+ title: rainyProb
955
+ ? `precipitation probability ${Math.round(precipProbability)} %`
956
+ : `precipitation ${precip.toFixed(1)} mm/h`,
957
+ });
958
+ }
925
959
  return segments;
926
960
  }
927
961
 
@@ -1271,15 +1305,16 @@ export function mergeTimeline(exceptions, payload = null) {
1271
1305
  const label = maneuver
1272
1306
  ? `${e.maneuver === "tack" ? "Tack" : "Gybe"} to ${e.toTack ?? tack ?? "?"}`
1273
1307
  : sailEventLabel(e) || state;
1274
- // Conditions at the change point (work doc #18): what the crew
1275
- // is rigging into — true wind, sea state, comfort tier. Falls
1276
- // back to the bare maneuver TWS for payloads without the
1308
+ // Conditions at the change point (work doc #18): how the boat
1309
+ // sits to the wind, then true wind, sea state, comfort tier.
1310
+ // Falls back to the bare maneuver TWS for payloads without the
1277
1311
  // enriched conditions block. When the tier came from the
1278
1312
  // slatting penalty (work doc #14: light air over a residual
1279
1313
  // swell), the detail says so — "rough" alone would send someone
1280
1314
  // looking for wind that isn't there.
1281
1315
  const conditions = e.conditions ?? null;
1282
1316
  const tws = conditions?.twsKnots ?? e.twsAtManeuver ?? null;
1317
+ const posText = pointOfSail(conditions?.twaDeg ?? null);
1283
1318
  // Why the change sits where it sits: watch handover when a watch
1284
1319
  // schedule runs, else the sunrise/sunset it waits for
1285
1320
  const anchorText =
@@ -1300,6 +1335,7 @@ export function mergeTimeline(exceptions, payload = null) {
1300
1335
  e.distanceFromStartNm != null
1301
1336
  ? `${Math.round(e.distanceFromStartNm)} nm`
1302
1337
  : null,
1338
+ posText,
1303
1339
  tws != null ? `${fmtKn(tws)} TWS` : null,
1304
1340
  conditions?.hsMeters != null
1305
1341
  ? `Hs ${conditions.hsMeters.toFixed(1)} m`
@@ -1352,6 +1388,25 @@ export function mergeTimeline(exceptions, payload = null) {
1352
1388
  });
1353
1389
  }
1354
1390
 
1391
+ // Rain-shower episodes (probable squalls in the tropics): peak
1392
+ // hourly rate and probability over the range, info-level — rain
1393
+ // informs where convection warns
1394
+ for (const r of summary.rainEpisodes ?? []) {
1395
+ push({
1396
+ hoursFromNow: r.hoursFromNow ?? null,
1397
+ timestamp: r.timestamp ?? null,
1398
+ kind: "rain",
1399
+ severity: "info",
1400
+ label: "Rain showers",
1401
+ detail: episodeDetail(r, [
1402
+ r.precip != null ? `${r.precip.toFixed(1)} mm/h` : null,
1403
+ r.precipProbability != null
1404
+ ? `${Math.round(r.precipProbability)} %`
1405
+ : null,
1406
+ ]),
1407
+ });
1408
+ }
1409
+
1355
1410
  // Hazard notes along the track (whole passage; the tactical slice
1356
1411
  // picks up its share by hoursFromNow). Provenance (work doc #31):
1357
1412
  // the note's url is the GDACS event page the note was built from
@@ -1582,10 +1637,10 @@ export function mergeTimeline(exceptions, payload = null) {
1582
1637
  ? `${e.netWh >= 0 ? "+" : "−"}${(Math.abs(e.netWh) / 1000).toFixed(1)} kWh`
1583
1638
  : null,
1584
1639
  e.timestamp != null && e.endTimestamp != null
1585
- ? `until ${fmtUtc(e.endTimestamp)}`
1640
+ ? `until ${fmtShip(e.endTimestamp)}`
1586
1641
  : null,
1587
1642
  deficit && e.timeToEmpty != null
1588
- ? `battery depleted by ${fmtUtc(e.timeToEmpty)}`
1643
+ ? `battery depleted by ${fmtShip(e.timeToEmpty)}`
1589
1644
  : null,
1590
1645
  e.type === "surplus" ? "run opportunistic loads" : null,
1591
1646
  ]
@@ -671,15 +671,17 @@ class PassageOutlook extends HTMLElement {
671
671
  mode === "here"
672
672
  ? "No cached conditions yet"
673
673
  : "No cached briefing for this route";
674
- const button = document.createElement("button");
675
- button.textContent = "Update now";
676
- button.disabled = this._status?.online !== true;
677
- // Here mode refreshes the conditions payload; the route-select
678
- // flow would target the selected route instead
679
- button.addEventListener("click", () =>
680
- mode === "here" ? this.refreshHereBriefing() : this.refreshBriefing(),
681
- );
682
- strip.append(text, button);
674
+ strip.append(text);
675
+ // Route mode has no other refresh affordance on screen; here mode
676
+ // carries its own Update now in the conditions card head (work
677
+ // doc #7) — a second button on the strip read as a duplicate
678
+ if (mode !== "here") {
679
+ const button = document.createElement("button");
680
+ button.textContent = "Update now";
681
+ button.disabled = this._status?.online !== true;
682
+ button.addEventListener("click", () => this.refreshBriefing());
683
+ strip.append(button);
684
+ }
683
685
  this._view.before(strip);
684
686
  }
685
687
 
@@ -26,6 +26,7 @@ const GLYPHS = {
26
26
  sail: "⛵",
27
27
  maneuver: "⇄",
28
28
  convective: "⚡",
29
+ rain: "🌧",
29
30
  sea: "🌊",
30
31
  zone: "⚑",
31
32
  space: "✦",
@@ -259,11 +259,12 @@ strong.sev {
259
259
  return;
260
260
  }
261
261
  el.innerHTML = "";
262
- const nowCloud =
263
- this._payload?.waypoints?.[0]?.forecasts?.[0]?.surface?.cloudCover ??
264
- null;
262
+ const nowSurface =
263
+ this._payload?.waypoints?.[0]?.forecasts?.[0]?.surface ?? null;
265
264
  const segments = skySegments(this._payload ?? null, {
266
- cloudCover: nowCloud,
265
+ cloudCover: nowSurface?.cloudCover ?? null,
266
+ precip: nowSurface?.precip ?? null,
267
+ precipProbability: nowSurface?.precipProbability ?? null,
267
268
  });
268
269
  for (const segment of segments) {
269
270
  const span = document.createElement("span");
@@ -18,9 +18,9 @@
18
18
  /* Comfort tiers (Sereno physics, SPEC §5.2) — night-lean defaults */
19
19
  --comfort-champagne: #33616b;
20
20
  --comfort-easy: #4a7555;
21
- --comfort-coffee: #8a5318;
21
+ --comfort-sporty: #8a5318;
22
22
  --comfort-rough: #a05218;
23
- --comfort-sick: #8f3333;
23
+ --comfort-miserable: #8f3333;
24
24
  --comfort-unknown: #333333;
25
25
 
26
26
  --color-green: #4a7555;
@@ -39,9 +39,9 @@
39
39
  :root[data-mode="day"] {
40
40
  --comfort-champagne: #66c6db;
41
41
  --comfort-easy: #8dfcbb;
42
- --comfort-coffee: #fca847;
42
+ --comfort-sporty: #fca847;
43
43
  --comfort-rough: #ff8847;
44
- --comfort-sick: #ff5e5e;
44
+ --comfort-miserable: #ff5e5e;
45
45
  --comfort-unknown: #666666;
46
46
 
47
47
  --color-green: #8dfcbb;
@@ -39,7 +39,7 @@ import {
39
39
  suggestSailState,
40
40
  travelDirection,
41
41
  } from "./sereno-physics.mjs";
42
- import { detectManeuvers } from "./tack-gybe.js";
42
+ import { detectManeuvers, wrap180 } from "./tack-gybe.js";
43
43
  import tzLookup from "./vendor/tz-lookup/tz-lookup.mjs";
44
44
 
45
45
  /**
@@ -389,6 +389,16 @@ export function weatherAt(forecasts, timestamp) {
389
389
  sa.cloudCover == null || sb.cloudCover == null
390
390
  ? null
391
391
  : mix(sa.cloudCover, sb.cloudCover),
392
+ // Rain fields follow the same honesty rule: unknown on either
393
+ // side of the span interpolates to null, never invented
394
+ precip:
395
+ sa.precip == null || sb.precip == null
396
+ ? null
397
+ : mix(sa.precip, sb.precip),
398
+ precipProbability:
399
+ sa.precipProbability == null || sb.precipProbability == null
400
+ ? null
401
+ : mix(sa.precipProbability, sb.precipProbability),
392
402
  },
393
403
  marine: {
394
404
  hsCombined: mix(ma.hsCombined, mb.hsCombined),
@@ -492,6 +502,22 @@ const CONVECTIVE_K_INDEX = 30;
492
502
  */
493
503
  const CONVECTIVE_K_INDEX_MARGINAL = 28;
494
504
 
505
+ /**
506
+ * Hourly precipitation rate at which a rain-shower hour is captured
507
+ * (mm/h). Tropical rain rarely arrives without squalls, so the crew
508
+ * reads a shower band as probable squalls; below the bar an hour is a
509
+ * stray drop, not weather.
510
+ */
511
+ const RAIN_MM_PER_H = 0.5;
512
+
513
+ /**
514
+ * Precipitation probability at which a rain-shower hour is captured
515
+ * (%). The probability field extends detection into the later forecast
516
+ * days, where some models publish it long after they stop being
517
+ * confident about hourly rates.
518
+ */
519
+ const RAIN_PROBABILITY_PERCENT = 50;
520
+
495
521
  /**
496
522
  * Maximum gap in hours between two hourly anomalies that still counts
497
523
  * as one continuing episode (covers hourly forecast steps and the
@@ -876,6 +902,7 @@ export function simulateRun({
876
902
  const positions = [];
877
903
  const seaStateAnomalies = [];
878
904
  const upperAirAnomalies = [];
905
+ const rainHours = [];
879
906
  const timeEvents = [];
880
907
  let arrivedHours = null;
881
908
  let distanceMadeGoodNm = 0;
@@ -1110,6 +1137,24 @@ export function simulateRun({
1110
1137
  });
1111
1138
  }
1112
1139
 
1140
+ // Rain-shower hours (probable squalls): either the model's hourly
1141
+ // rate crosses the shower bar or its probability does — the two
1142
+ // fields have independent availability across the horizon
1143
+ const precip = weather?.surface?.precip;
1144
+ const precipProbability = weather?.surface?.precipProbability;
1145
+ if (
1146
+ (precip != null && precip >= RAIN_MM_PER_H) ||
1147
+ (precipProbability != null &&
1148
+ precipProbability >= RAIN_PROBABILITY_PERCENT)
1149
+ ) {
1150
+ rainHours.push({
1151
+ hoursFromNow: Math.round(hours * 10) / 10,
1152
+ timestamp: t.toISOString(),
1153
+ precip,
1154
+ precipProbability,
1155
+ });
1156
+ }
1157
+
1113
1158
  // Waypoint progress
1114
1159
  if (arrivedNow) {
1115
1160
  if (targetIndex === track.length - 1) {
@@ -1175,6 +1220,7 @@ export function simulateRun({
1175
1220
  positions,
1176
1221
  seaStateAnomalies,
1177
1222
  upperAirAnomalies,
1223
+ rainHours,
1178
1224
  timeEvents,
1179
1225
  hazardAlerts: hazardAlerts(notes, positions),
1180
1226
  };
@@ -1484,6 +1530,15 @@ export function simulatePassage({
1484
1530
  conditions: {
1485
1531
  twsKnots: row.twsKnots ?? null,
1486
1532
  awsKnots: row.awsKnots ?? null,
1533
+ // Signed TWA at the change point (positive = wind
1534
+ // from starboard, same convention as the maneuvers):
1535
+ // the timeline names the point of sail, tiles and
1536
+ // widgets read the precise angle
1537
+ twaDeg:
1538
+ typeof row.headingDeg === "number" &&
1539
+ typeof row.twdDeg === "number"
1540
+ ? Math.round(wrap180(row.twdDeg - row.headingDeg) * 10) / 10
1541
+ : null,
1487
1542
  hsMeters: row.hsMeters ?? null,
1488
1543
  tpSeconds: row.tpSeconds ?? null,
1489
1544
  comfortLevel: row.comfortLevel ?? null,
@@ -1509,6 +1564,7 @@ export function simulatePassage({
1509
1564
  hazardAlerts: nominal.hazardAlerts,
1510
1565
  seaStateAnomalies: nominal.seaStateAnomalies,
1511
1566
  upperAirAnomalies: nominal.upperAirAnomalies,
1567
+ rainHours: nominal.rainHours,
1512
1568
  energy: energySummary(energyHourly, startTime),
1513
1569
  runs: runs.map((run, i) => ({
1514
1570
  twsFactor: ETA_FACTORS[i],
@@ -1687,6 +1743,19 @@ export function filterExceptions(simulationResult, simParams = {}) {
1687
1743
  (episode.kIndex != null && episode.kIndex >= CONVECTIVE_K_INDEX)
1688
1744
  ),
1689
1745
  })),
1746
+ // Rain-shower episodes (probable squalls): contiguous wet hours
1747
+ // merge into one entry carrying the episode's peak rate and
1748
+ // probability; always info-level — rain informs, CAPE warns
1749
+ rainEpisodes: mergeEpisodes(
1750
+ simulationResult.rainHours ?? [],
1751
+ (episode, r) => {
1752
+ episode.precip = maxOf(episode.precip, r.precip);
1753
+ episode.precipProbability = maxOf(
1754
+ episode.precipProbability,
1755
+ r.precipProbability,
1756
+ );
1757
+ },
1758
+ ),
1690
1759
  },
1691
1760
  };
1692
1761
  }
@@ -7,7 +7,7 @@
7
7
  * consumes the same file with `import()`.
8
8
  *
9
9
  * The comfort model rates a passage hour on the Sereno scale
10
- * (Champagne > Easy > Coffee > Rough > Sick) from two independent
10
+ * (Champagne > Easy > Sporty > Rough > Miserable) from two independent
11
11
  * vectors, the worse of which sets the level:
12
12
  *
13
13
  * 1. **Wind over the deck** — apparent wind speed bands derived from
@@ -23,8 +23,12 @@
23
23
  * (https://github.com/sailing12388/passage-weather, MIT), adapted for a
24
24
  * monohull: the catamaran beam-roll and bridgedeck-slam factors are
25
25
  * replaced by a heel multiplier and a waterline pitching resonance
26
- * term. Not calibrated against a logged passage until the backtest CLI
27
- * (SPEC §7) tunes k_heel/k_pitch against the vessel's own history.
26
+ * term. This is a modified Sereno scale, not a direct copy — the
27
+ * slatting penalty is this project's own addition, upstream has
28
+ * nothing like it (a catamaran slats far less, and light-air
29
+ * discomfort rarely gets modeled at all). Not calibrated against a
30
+ * logged passage until the backtest CLI (SPEC §7) tunes k_heel/k_pitch
31
+ * against the vessel's own history.
28
32
  *
29
33
  * Conventions:
30
34
  * - wind speeds and boat speeds in knots, angles in radians unless a
@@ -53,7 +57,13 @@ export const KN_TO_MS = 0.514444;
53
57
  * Comfort tiers, best first. The index in this array is the severity
54
58
  * rank used when combining vectors.
55
59
  */
56
- export const COMFORT_TIERS = ["champagne", "easy", "coffee", "rough", "sick"];
60
+ export const COMFORT_TIERS = [
61
+ "champagne",
62
+ "easy",
63
+ "sporty",
64
+ "rough",
65
+ "miserable",
66
+ ];
57
67
 
58
68
  /**
59
69
  * One-line meaning per comfort tier, display order best first, with
@@ -74,10 +84,10 @@ export const COMFORT_SCALE_INFO = [
74
84
  text: "Comfortable: moving around below is easy.",
75
85
  },
76
86
  {
77
- tier: "coffee",
87
+ tier: "sporty",
78
88
  maxAws: 23,
79
89
  maxAz: 0.63,
80
- text: "You can still hold a hot coffee without wearing it.",
90
+ text: "Lively enough that a hot coffee needs a firm grip.",
81
91
  },
82
92
  {
83
93
  tier: "rough",
@@ -86,22 +96,22 @@ export const COMFORT_SCALE_INFO = [
86
96
  text: "One hand for the boat — loose objects go flying.",
87
97
  },
88
98
  {
89
- tier: "sick",
99
+ tier: "miserable",
90
100
  maxAws: null,
91
101
  maxAz: null,
92
- text: "Beyond rough: seasickness very likely — think shelter or a course change.",
102
+ text: "Nobody is enjoying this. Seasickness very likely.",
93
103
  },
94
104
  ];
95
105
 
96
106
  /**
97
107
  * Apparent wind speed (knots) at or above which each tier is lost
98
- * (Champagne, Easy, Coffee, Rough). SPEC §5.2 vector 1.
108
+ * (Champagne, Easy, Sporty, Rough). SPEC §5.2 vector 1.
99
109
  */
100
110
  export const AWS_BANDS_KNOTS = [12, 18, 23, 33];
101
111
 
102
112
  /**
103
113
  * RMS vertical acceleration (m/s²) at or above which each tier is lost
104
- * (Champagne, Easy, Coffee, Rough). SPEC §5.2 vector 2, ISO 2631-1.
114
+ * (Champagne, Easy, Sporty, Rough). SPEC §5.2 vector 2, ISO 2631-1.
105
115
  */
106
116
  export const AZ_BANDS_MS2 = [0.15, 0.315, 0.63, 1.25];
107
117
 
@@ -51,6 +51,37 @@ export function tackSide(signedTwaDeg) {
51
51
  return signedTwaDeg >= 0 ? "starboard" : "port";
52
52
  }
53
53
 
54
+ /**
55
+ * Point of sail for a signed TWA, by the standard true-wind-angle
56
+ * bands: close-hauled (≤ 60°), close reach (≤ 80°), beam reach
57
+ * (≤ 100°), broad reach (≤ 150°), running beyond. The sign is
58
+ * deliberately ignored — tack side is `tackSide`'s business — so a
59
+ * timeline detail can name how the boat sits to the wind without
60
+ * duplicating the maneuver label's tack call.
61
+ *
62
+ * @param {number|null} signedTwaDeg
63
+ * @returns {string|null} Band name, or null without usable input
64
+ */
65
+ export function pointOfSail(signedTwaDeg) {
66
+ if (typeof signedTwaDeg !== "number" || !Number.isFinite(signedTwaDeg)) {
67
+ return null;
68
+ }
69
+ const abs = Math.abs(signedTwaDeg);
70
+ if (abs <= 60) {
71
+ return "close-hauled";
72
+ }
73
+ if (abs <= 80) {
74
+ return "close reach";
75
+ }
76
+ if (abs <= 100) {
77
+ return "beam reach";
78
+ }
79
+ if (abs <= 150) {
80
+ return "broad reach";
81
+ }
82
+ return "running";
83
+ }
84
+
54
85
  /**
55
86
  * Whether a simulation hour is a sailing step: not motoring, actually
56
87
  * making way (drift-mode hours sit at SOG 0) and the plan's rig up —
@@ -19,9 +19,9 @@
19
19
  "map": [
20
20
  { "value": "champagne", "state": "green" },
21
21
  { "value": "easy", "state": "green" },
22
- { "value": "coffee", "state": "amber" },
22
+ { "value": "sporty", "state": "amber" },
23
23
  { "value": "rough", "state": "amber" },
24
- { "value": "sick", "state": "red" }
24
+ { "value": "miserable", "state": "red" }
25
25
  ],
26
26
  "default": "neutral",
27
27
  "reason": "Comfort tier unknown",
@@ -404,7 +404,7 @@ test("backtest CLI runs against a local history server", async () => {
404
404
 
405
405
  // Matrix rendering
406
406
  const rendered = renderMatrix({
407
- tiers: ["champagne", "easy", "coffee", "rough", "sick"],
407
+ tiers: ["champagne", "easy", "sporty", "rough", "miserable"],
408
408
  matrix: [
409
409
  [3, 1, 0, 0, 0],
410
410
  [0, 4, 0, 0, 0],
@@ -166,7 +166,15 @@ describe("fetchWeatherAlongTrack", () => {
166
166
  mslp: 1013,
167
167
  gust: 18,
168
168
  cloudCover: 25,
169
+ precip: 0,
170
+ precipProbability: 10,
169
171
  });
172
+ // Second step carries the wet hour of the fixture
173
+ assert.equal(payload.waypoints[0].forecasts[1].surface.precip, 2.4);
174
+ assert.equal(
175
+ payload.waypoints[0].forecasts[1].surface.precipProbability,
176
+ 80,
177
+ );
170
178
  assert.deepEqual(step.marine, {
171
179
  hsCombined: 1.2,
172
180
  tpCombined: 7,
@@ -29,6 +29,8 @@ function forecastFixture() {
29
29
  relative_humidity_700hPa: [60, 65],
30
30
  wind_speed_850hPa: [20, 22],
31
31
  cloud_cover: [25, 75],
32
+ precipitation: [0, 2.4],
33
+ precipitation_probability: [10, 80],
32
34
  },
33
35
  };
34
36
  }
@@ -1170,7 +1170,7 @@ describe("plugin", () => {
1170
1170
 
1171
1171
  // Brief meta serves the same view the tile publishes
1172
1172
  const meta = await call("/api/brief-meta");
1173
- assert.equal(meta.payload.comfort, "coffee");
1173
+ assert.equal(meta.payload.comfort, "sporty");
1174
1174
  assert.equal(meta.payload.stale, false);
1175
1175
  assert.equal(meta.payload.ageHours, 0);
1176
1176
 
@@ -33,6 +33,7 @@ function buildPayload({
33
33
  set = 0,
34
34
  steepHour = null,
35
35
  convectiveHour = null,
36
+ rainHour = null,
36
37
  } = {}) {
37
38
  const track = sampleRoutePoints(route);
38
39
  const waypoints = track.map((w) => ({
@@ -47,6 +48,8 @@ function buildPayload({
47
48
  twd,
48
49
  mslp: 1013,
49
50
  gust: tws + 5,
51
+ precip: h === rainHour ? 2.5 : 0,
52
+ precipProbability: h === rainHour ? 80 : 10,
50
53
  },
51
54
  marine: {
52
55
  hsCombined: steep ? 3 : hs,
@@ -175,7 +178,7 @@ describe("simulateRun", () => {
175
178
  // Comfort evaluated every hour
176
179
  for (const block of run.hourly) {
177
180
  assert.ok(
178
- ["champagne", "easy", "coffee", "rough", "sick"].includes(
181
+ ["champagne", "easy", "sporty", "rough", "miserable"].includes(
179
182
  block.comfortLevel,
180
183
  ),
181
184
  );
@@ -293,6 +296,26 @@ describe("simulateRun", () => {
293
296
  assert.ok(run.upperAirAnomalies.some((a) => a.kIndex > 28));
294
297
  });
295
298
 
299
+ test("collects rain-shower hours over the shower bar", async () => {
300
+ const { simulateRun } = await simPromise;
301
+ // START is 06:00Z, so fixture hour 7 is simulated hour 1
302
+ const run = simulateRun({
303
+ payload: buildPayload({ rainHour: 7 }),
304
+ startTime: START,
305
+ maxHours: 12,
306
+ });
307
+ assert.equal(run.rainHours.length, 1);
308
+ assert.equal(run.rainHours[0].precip, 2.5);
309
+ assert.equal(run.rainHours[0].precipProbability, 80);
310
+ // Dry fixture hours stay under the bar
311
+ const dry = simulateRun({
312
+ payload: buildPayload({}),
313
+ startTime: START,
314
+ maxHours: 12,
315
+ });
316
+ assert.equal(dry.rainHours.length, 0);
317
+ });
318
+
296
319
  test("current pushes the boat off the heading line", async () => {
297
320
  const { simulateRun } = await simPromise;
298
321
  const withCurrent = simulateRun({
@@ -419,6 +442,14 @@ describe("simulatePassage", () => {
419
442
  for (const change of exceptions.passageSummary.sailChanges) {
420
443
  if (change.conditions) {
421
444
  assert.equal(change.conditions.cloudCover, null);
445
+ // Signed TWA at the change point (work doc #5): the angle
446
+ // behind the timeline's point-of-sail text — within ±180°
447
+ assert.ok(
448
+ change.conditions.twaDeg == null ||
449
+ (change.conditions.twaDeg > -180 &&
450
+ change.conditions.twaDeg <= 180),
451
+ `twaDeg ${change.conditions.twaDeg} out of range`,
452
+ );
422
453
  }
423
454
  }
424
455
  assert.equal(
@@ -427,6 +458,7 @@ describe("simulatePassage", () => {
427
458
  );
428
459
  assert.ok(exceptions.passageSummary.macroSeaAnomalies.length === 0);
429
460
  assert.ok(exceptions.passageSummary.convectiveWarnings.length === 0);
461
+ assert.ok(exceptions.passageSummary.rainEpisodes.length === 0);
430
462
  assert.ok(Array.isArray(exceptions.passageSummary.hazards));
431
463
  });
432
464
 
@@ -492,6 +524,21 @@ describe("simulatePassage", () => {
492
524
  assert.equal(exceptions.passageSummary.convectiveWarnings[0].cape, 1800);
493
525
  });
494
526
 
527
+ test("rain episodes surface in the passage summary", async () => {
528
+ const { filterExceptions, simulatePassage } = await simPromise;
529
+ const result = simulatePassage({
530
+ payload: buildPayload({ rainHour: 7 }),
531
+ startTime: new Date("2026-06-21T06:00:00Z"),
532
+ });
533
+ const exceptions = filterExceptions(result);
534
+ assert.ok(exceptions.passageSummary.rainEpisodes.length >= 1);
535
+ assert.equal(exceptions.passageSummary.rainEpisodes[0].precip, 2.5);
536
+ assert.equal(
537
+ exceptions.passageSummary.rainEpisodes[0].precipProbability,
538
+ 80,
539
+ );
540
+ });
541
+
495
542
  test("anomalies merge into episodes with peak values, marginal band drops", async () => {
496
543
  const { filterExceptions } = await simPromise;
497
544
  const result = {
@@ -18,9 +18,9 @@ describe("comfort bands (SPEC §5.2)", () => {
18
18
  assert.deepEqual(p.COMFORT_TIERS, [
19
19
  "champagne",
20
20
  "easy",
21
- "coffee",
21
+ "sporty",
22
22
  "rough",
23
- "sick",
23
+ "miserable",
24
24
  ]);
25
25
  assert.deepEqual(p.STEEPNESS_RATIO_THRESHOLD, 3.28);
26
26
  });
@@ -29,18 +29,18 @@ describe("comfort bands (SPEC §5.2)", () => {
29
29
  const { rateApparentWind } = await physics();
30
30
  assert.equal(rateApparentWind(11.9), "champagne");
31
31
  assert.equal(rateApparentWind(12), "easy");
32
- assert.equal(rateApparentWind(18), "coffee");
32
+ assert.equal(rateApparentWind(18), "sporty");
33
33
  assert.equal(rateApparentWind(23), "rough");
34
- assert.equal(rateApparentWind(33), "sick");
34
+ assert.equal(rateApparentWind(33), "miserable");
35
35
  });
36
36
 
37
37
  test("acceleration bands", async () => {
38
38
  const { rateAcceleration } = await physics();
39
39
  assert.equal(rateAcceleration(0.149), "champagne");
40
40
  assert.equal(rateAcceleration(0.15), "easy");
41
- assert.equal(rateAcceleration(0.315), "coffee");
41
+ assert.equal(rateAcceleration(0.315), "sporty");
42
42
  assert.equal(rateAcceleration(0.63), "rough");
43
- assert.equal(rateAcceleration(1.25), "sick");
43
+ assert.equal(rateAcceleration(1.25), "miserable");
44
44
  });
45
45
  });
46
46
 
@@ -241,8 +241,8 @@ describe("serenoComfort", () => {
241
241
  { twsKnots: 6, twaRad: 0.3 },
242
242
  );
243
243
  assert.ok(
244
- ["rough", "sick"].includes(rough.comfort),
245
- `expected rough/sick, got ${rough.comfort}`,
244
+ ["rough", "miserable"].includes(rough.comfort),
245
+ `expected rough/miserable, got ${rough.comfort}`,
246
246
  );
247
247
  // Champagne glass conditions: light following breeze, gentle swell
248
248
  const idyllic = serenoComfort(
@@ -29,7 +29,7 @@ test("bundled example set parses and references published paths", () => {
29
29
  const comfortCheck = tile.checks[0];
30
30
  assert.deepEqual(
31
31
  comfortCheck.map.map((m) => m.value),
32
- ["champagne", "easy", "coffee", "rough", "sick"],
32
+ ["champagne", "easy", "sporty", "rough", "miserable"],
33
33
  );
34
34
  const footerPaths = tile.footer.map((f) => f.path);
35
35
  assert.ok(footerPaths.includes("navigation.briefing.ageHours"));
@@ -220,4 +220,19 @@ describe("tack & gybe detection", () => {
220
220
  assert.equal(events.length, 1);
221
221
  assert.equal(events[0].sailState, "@port");
222
222
  });
223
+
224
+ test("pointOfSail names the true-wind-angle band, sign-blind", async () => {
225
+ const { pointOfSail } = await tgPromise;
226
+ assert.equal(pointOfSail(45), "close-hauled");
227
+ assert.equal(pointOfSail(-60), "close-hauled");
228
+ assert.equal(pointOfSail(70), "close reach");
229
+ assert.equal(pointOfSail(-90), "beam reach");
230
+ assert.equal(pointOfSail(120), "broad reach");
231
+ assert.equal(pointOfSail(-170), "running");
232
+ assert.equal(pointOfSail(180), "running");
233
+ // Without usable input there is nothing to say — old payloads
234
+ // without a conditions TWA degrade to the bare detail line
235
+ assert.equal(pointOfSail(null), null);
236
+ assert.equal(pointOfSail(undefined), null);
237
+ });
223
238
  });
@@ -50,6 +50,9 @@ describe("weatherDataToTimeStep", () => {
50
50
  assert.equal(step.surface.twd, 45);
51
51
  assert.equal(step.surface.mslp, 1013);
52
52
  assert.equal(step.surface.gust, 18);
53
+ // Provider mode carries no precipitation fields — degrade, never invent
54
+ assert.equal(step.surface.precip, null);
55
+ assert.equal(step.surface.precipProbability, null);
53
56
  assert.equal(step.marine.hsCombined, 1.2);
54
57
  assert.equal(step.marine.tpCombined, 7);
55
58
  assert.equal(step.marine.dirCombined, 160);
@@ -154,7 +154,7 @@ test("webapp view models", async (t) => {
154
154
  const blocks = [
155
155
  { hoursFromNow: 0, comfortLevel: "champagne", awsKnots: 10 },
156
156
  { hoursFromNow: 1, comfortLevel: "rough", awsKnots: 20 },
157
- { hoursFromNow: 2, comfortLevel: "sick", awsKnots: 0 },
157
+ { hoursFromNow: 2, comfortLevel: "miserable", awsKnots: 0 },
158
158
  ];
159
159
  const cols = sparklineColumns(blocks);
160
160
  assert.equal(cols.length, 3);
@@ -162,7 +162,7 @@ test("webapp view models", async (t) => {
162
162
  assert.equal(cols[1].heightPct, 100);
163
163
  assert.equal(cols[2].heightPct, 4); // Floor
164
164
  assert.match(cols[0].color, /champagne/);
165
- assert.match(cols[2].color, /sick/);
165
+ assert.match(cols[2].color, /miserable/);
166
166
  assert.ok(cols[1].title.includes("20.0 kn"));
167
167
  },
168
168
  );
@@ -192,7 +192,7 @@ test("webapp view models", async (t) => {
192
192
  await t.test("comfort tiers cover the Sereno scale", () => {
193
193
  assert.deepEqual(
194
194
  COMFORT_TIERS.map((tier) => tier.level),
195
- ["champagne", "easy", "coffee", "rough", "sick"],
195
+ ["champagne", "easy", "sporty", "rough", "miserable"],
196
196
  );
197
197
  assert.match(comfortColor("unknown"), /unknown/);
198
198
  });
@@ -203,7 +203,7 @@ test("webapp view models", async (t) => {
203
203
  comfortBlocks: [
204
204
  {
205
205
  hoursFromNow: 0,
206
- comfortLevel: "coffee",
206
+ comfortLevel: "sporty",
207
207
  awsKnots: 14.2,
208
208
  timestamp: "2026-06-21T06:00:00Z",
209
209
  },
@@ -211,10 +211,10 @@ test("webapp view models", async (t) => {
211
211
  },
212
212
  };
213
213
  const now = tacticalNow(exceptions);
214
- assert.equal(now.comfortLevel, "coffee");
214
+ assert.equal(now.comfortLevel, "sporty");
215
215
  assert.equal(now.awsKnots, 14.2);
216
216
  assert.equal(now.stamp, "06-21 06:00Z");
217
- assert.match(now.color, /coffee/);
217
+ assert.match(now.color, /sporty/);
218
218
 
219
219
  assert.equal(tacticalNow(null).comfortLevel, null);
220
220
  assert.match(tacticalNow(null).color, /unknown/);
@@ -280,7 +280,14 @@ test("webapp view models", async (t) => {
280
280
  timestamp: new Date(
281
281
  new Date("2026-09-27T00:00:00.000Z").getTime() + i * 3600000,
282
282
  ).toISOString(),
283
- surface: { tws: 12, twd: 45, mslp: 1013, gust: 18 },
283
+ surface: {
284
+ tws: 12,
285
+ twd: 45,
286
+ mslp: 1013,
287
+ gust: 18,
288
+ precip: 1.2,
289
+ precipProbability: 65,
290
+ },
284
291
  marine: {
285
292
  hsCombined: 1.2,
286
293
  tpCombined: 7,
@@ -311,6 +318,9 @@ test("webapp view models", async (t) => {
311
318
  assert.equal(now.comfortLevel, rows[0].comfortLevel);
312
319
  // Now cloud cover (work doc #33): from the first forecast step
313
320
  assert.equal(now.cloudCover, null);
321
+ // Now rain fields ride the same step
322
+ assert.equal(now.precipMmH, 1.2);
323
+ assert.equal(now.precipProbability, 65);
314
324
  });
315
325
 
316
326
  await t.test("hereNow and hereHourly handle empty payloads", () => {
@@ -450,6 +460,24 @@ test("webapp view models", async (t) => {
450
460
  // No cloud field (provider mode): no cloud segment, never invented
451
461
  const clearSky = skySegments(payload, { cloudCover: null });
452
462
  assert.ok(!clearSky.some((s) => s.text.startsWith("☁")));
463
+ // Rain rides the line as probability, or as the hourly rate when
464
+ // the model publishes no probability
465
+ const wetSky = skySegments(payload, {
466
+ cloudCover: 40,
467
+ precipProbability: 65,
468
+ });
469
+ assert.ok(
470
+ wetSky.some((s) => s.text === "🌧 65%"),
471
+ wetSky.map((s) => s.text).join(" "),
472
+ );
473
+ const rateSky = skySegments(payload, { precip: 0.8 });
474
+ assert.ok(
475
+ rateSky.some((s) => s.text === "🌧 0.8mm"),
476
+ rateSky.map((s) => s.text).join(" "),
477
+ );
478
+ // Dry hours stay quiet
479
+ const drySky = skySegments(payload, { precip: 0.1, precipProbability: 10 });
480
+ assert.ok(!drySky.some((s) => s.text.startsWith("🌧")));
453
481
  // A payload predating the sky data degrades to empty
454
482
  assert.deepEqual(skySegments({}, {}), []);
455
483
  assert.deepEqual(skySegments(null, {}), []);
@@ -534,6 +562,32 @@ test("webapp view models", async (t) => {
534
562
  },
535
563
  );
536
564
 
565
+ await t.test("mergeTimeline carries rain episodes as info items", () => {
566
+ const timeline = mergeTimeline(
567
+ {
568
+ passageSummary: {
569
+ rainEpisodes: [
570
+ {
571
+ hoursFromNow: 4,
572
+ timestamp: "2026-06-21T10:00:00.000Z",
573
+ untilHoursFromNow: 7,
574
+ untilTimestamp: "2026-06-21T13:00:00.000Z",
575
+ precip: 2.5,
576
+ precipProbability: 80,
577
+ },
578
+ ],
579
+ },
580
+ },
581
+ null,
582
+ );
583
+ const rain = timeline.filter((i) => i.kind === "rain");
584
+ assert.equal(rain.length, 1);
585
+ assert.equal(rain[0].severity, "info");
586
+ assert.equal(rain[0].label, "Rain showers");
587
+ assert.match(rain[0].detail, /2\.5 mm\/h/);
588
+ assert.match(rain[0].detail, /80 %/);
589
+ });
590
+
537
591
  await t.test(
538
592
  "mergeTimeline passes source provenance through (doc #31)",
539
593
  () => {
@@ -708,9 +762,10 @@ test("webapp view models", async (t) => {
708
762
  conditions: {
709
763
  twsKnots: 14.2,
710
764
  awsKnots: 18.4,
765
+ twaDeg: 75,
711
766
  hsMeters: 1.5,
712
767
  tpSeconds: 8,
713
- comfortLevel: "coffee",
768
+ comfortLevel: "sporty",
714
769
  },
715
770
  },
716
771
  {
@@ -722,8 +777,14 @@ test("webapp view models", async (t) => {
722
777
  ],
723
778
  },
724
779
  });
725
- assert.equal(timeline[0].detail, "14.2 kn TWS · Hs 1.5 m · coffee");
726
- // Missing sea state degrades without inventing values
780
+ // The point of sail leads the conditions: how the boat sits to
781
+ // the wind is the first thing the crew reads when rigging
782
+ assert.equal(
783
+ timeline[0].detail,
784
+ "close reach · 14.2 kn TWS · Hs 1.5 m · sporty",
785
+ );
786
+ // Missing sea state degrades without inventing values; a
787
+ // pre-TWA payload just loses the point of sail, not the line
727
788
  assert.equal(timeline[1].detail, "8.0 kn TWS · champagne");
728
789
 
729
790
  // The slatting regime names itself: "rough" alone would send
@@ -737,6 +798,7 @@ test("webapp view models", async (t) => {
737
798
  sailState: "GENOA_1",
738
799
  conditions: {
739
800
  twsKnots: 10,
801
+ twaDeg: -170,
740
802
  hsMeters: 1.7,
741
803
  comfortLevel: "rough",
742
804
  slatting: true,
@@ -745,7 +807,10 @@ test("webapp view models", async (t) => {
745
807
  ],
746
808
  },
747
809
  });
748
- assert.equal(slatting[0].detail, "10.0 kn TWS · Hs 1.7 m · slatting");
810
+ assert.equal(
811
+ slatting[0].detail,
812
+ "running · 10.0 kn TWS · Hs 1.7 m · slatting",
813
+ );
749
814
  });
750
815
 
751
816
  await t.test("mergeTimeline carries the conditions tier for the tab", () => {
@@ -766,7 +831,7 @@ test("webapp view models", async (t) => {
766
831
  hoursFromNow: 10,
767
832
  lat: -18.9,
768
833
  lon: 174.5,
769
- comfortLevel: "coffee",
834
+ comfortLevel: "sporty",
770
835
  slatting: false,
771
836
  },
772
837
  {
@@ -792,18 +857,18 @@ test("webapp view models", async (t) => {
792
857
  hoursFromNow: 19,
793
858
  timestamp: "2026-06-22T01:00:00.000Z",
794
859
  sailState: "MAIN_2_REEF",
795
- conditions: { twsKnots: 22, comfortLevel: "sick" },
860
+ conditions: { twsKnots: 22, comfortLevel: "miserable" },
796
861
  },
797
862
  ],
798
863
  },
799
864
  });
800
865
  const sea = timeline.find((item) => item.kind === "sea");
801
- assert.equal(sea.comfortLevel, "coffee");
866
+ assert.equal(sea.comfortLevel, "sporty");
802
867
  assert.equal(sea.slatting, false);
803
868
  // The sail change's own enriched conditions block wins over the
804
869
  // nearest track hour — it is the reefing logic's tier
805
870
  const sail = timeline.find((item) => item.kind === "sail");
806
- assert.equal(sail.comfortLevel, "sick");
871
+ assert.equal(sail.comfortLevel, "miserable");
807
872
  // No track: the tier degrades to unknown, not invented
808
873
  const bare = mergeTimeline({
809
874
  passageSummary: {