import type { Dive, DiveGasUse, DiveMixture, DiveTankGasUse, GasRole, } from "@/lib/api/dives"; import { type ChartScope, periodLabel, periodRange, stepPeriod, } from "@/lib/chart-period"; import { formatDurationHoursMinutes } from "@/lib/date-time"; import { gasName, hasStagedCylinder, isParallelSet } from "@/lib/dive-mixtures"; import { primaryRecording } from "@/lib/dive-recordings"; // A value `p` of the way through `values` (0 is the lowest, 1 the highest), // interpolating linearly between the two it falls between - R's default and the // one spreadsheets use, so a figure here matches one a diver works out by hand. // Sorts its own copy; callers pass raw series. export function quantile(values: number[], p: number): number { const sorted = [...values].sort((a, b) => a - b); const position = (sorted.length - 1) * p; const below = Math.floor(position); const above = Math.min(sorted.length - 1, below + 1); return sorted[below] + (sorted[above] - sorted[below]) * (position - below); } // Median and the middle half, which is what the chart draws as a box: the // quartiles describe where the bulk of a set of dives sits, and unlike a mean and // a standard deviation they stay put when one dive comes in at twice everyone // else's rate - a misread gauge, or a swim against current, moves a quartile by // at most one rank. export interface Quartiles { q1: number; median: number; q3: number; } export function quartiles(values: number[]): Quartiles { return { q1: quantile(values, 0.25), median: quantile(values, 0.5), q3: quantile(values, 0.75), }; } // The calendar units the chart summarizes dives by, finest first. export const BIN_UNITS = ["day", "week", "month", "quarter", "year"] as const; export type BinUnit = (typeof BIN_UNITS)[number]; // How long each unit runs, near enough to size it on an axis; the bins // themselves follow the calendar. const DAY_MS = 24 * 60 * 60 * 1000; const BIN_UNIT_MS: Record = { day: DAY_MS, week: 7 * DAY_MS, month: 30.44 * DAY_MS, quarter: 91.31 * DAY_MS, year: 365.25 * DAY_MS, }; // The narrowest a bin may be, in viewBox units, before the chart steps up to // the next unit. Wide enough for a box with a median tick inside it to read as // one, and just under the design width's room for a week in a year (about 700 // units over 52 weeks), so a year is read in weeks on a desktop and in months on a // phone. export const BIN_MIN_WIDTH = 12; // The finest unit whose bins get at least `BIN_MIN_WIDTH` of a `plotWidth`-wide // axis spanning `rangeMs`. // // The unit is chosen by room on the axis rather than by the calendar or by the // gaps between dives, and that is the whole design. Each fixed choice fails // somewhere: one calendar unit for every scope is a month of nothing but a // single box or a career of three hundred slivers; dives grouped by the breaks // between them turn a weekend diver's career into a hundred and fifty // overlapping marks and an instructor's season into one box with one median; // and a fixed trailing window of dives makes ten turns inside one slot of a // year's axis and then runs flat for months. Sized to the axis, a week-long // trip is one box in a year and seven in a month, a season is twenty-six boxes // in a year and six in a career, and the gaps between trips are simply empty // bins. It is how a candlestick chart moves from daily to weekly to monthly // candles as it zooms out. export function binUnit(rangeMs: number, plotWidth: number): BinUnit { return ( BIN_UNITS.find( (unit) => (plotWidth * BIN_UNIT_MS[unit]) / rangeMs >= BIN_MIN_WIDTH, ) ?? "year" ); } // The start of the bin holding `time`, in the dive's own wall-clock calendar - // the chart's timestamps are read back with `getUTC*` only (`diveWallClockTime`). // Weeks start on Monday. export function binStart(time: number, unit: BinUnit): number { const date = new Date(time); const year = date.getUTCFullYear(); const month = date.getUTCMonth(); const day = date.getUTCDate(); switch (unit) { case "day": return Date.UTC(year, month, day); case "week": return Date.UTC(year, month, day - ((date.getUTCDay() + 6) % 7)); case "month": return Date.UTC(year, month, 1); case "quarter": return Date.UTC(year, month - (month % 3), 1); case "year": return Date.UTC(year, 0, 1); } } // The start of the bin after the one starting at `start`. export function binNext(start: number, unit: BinUnit): number { const date = new Date(start); const year = date.getUTCFullYear(); const month = date.getUTCMonth(); const day = date.getUTCDate(); switch (unit) { case "day": return Date.UTC(year, month, day + 1); case "week": return Date.UTC(year, month, day + 7); case "month": return Date.UTC(year, month + 1, 1); case "quarter": return Date.UTC(year, month + 3, 1); case "year": return Date.UTC(year + 1, 0, 1); } } // One bin with dives in it: its calendar bounds (`[start, end)`), when its // first and last dive were, how many, and the quartiles of their RMVs. export interface BinSummary extends Quartiles { start: number; end: number; first: number; last: number; count: number; } // The series summarized per `unit`, in order, with empty bins left out. // `times` sorted ascending, as the API returns them, with `values` in step. export function binSummaries( times: number[], values: number[], unit: BinUnit, ): BinSummary[] { const bins: { start: number; indices: number[] }[] = []; times.forEach((time, index) => { const start = binStart(time, unit); const current = bins[bins.length - 1]; if (current?.start === start) { current.indices.push(index); } else { bins.push({ start, indices: [index] }); } }); return bins.map(({ start, indices }) => ({ start, end: binNext(start, unit), first: times[indices[0]], last: times[indices[indices.length - 1]], count: indices.length, ...quartiles(indices.map((index) => values[index])), })); } // The fraction of the series an axis is allowed to leave off each end, so one // misread gauge at twice everyone else's rate does not set the top of the axis // and squash the other two hundred dives into the bottom third of the plot. // Dropping one dive in a hundred from each end only ever touches a series of a // hundred or more - a shorter one keeps its extremes - and the chart pins what // it drops to the edge rather than hiding it. export const DOMAIN_CLIP = 0.01; // The values an axis should be scaled to: the series with its outliers, by the // rule above, left out. Whole ranks rather than an interpolated percentile, so // a series too short to lose a dive loses none of its range either. export function clipOutliers(values: number[]): number[] { const drop = Math.floor(values.length * DOMAIN_CLIP); if (drop === 0) return values; const sorted = [...values].sort((a, b) => a - b); return sorted.slice(drop, sorted.length - drop); } // The three marks sharing the gas chart's plot, each of which the diver can turn // off. A bin's box and its median tick are one mark, not two: the tick is the // figure and the box says how tightly the dives behind it were clustered, so a // chart with one and not the other says less than either alone. Likewise the // baseline's median line and its middle-half band. export const GAS_USE_MARKS = ["dives", "trend", "average"] as const; export type GasUseMark = (typeof GAS_USE_MARKS)[number]; // The half-open `[start, end)` bounds the chart actually plots, for any scope. // // "year"/"month" are just `periodRange`. "all" is the whole series, and the // subtlety is entirely in its `end`: callers filter with `time < end`, so an // `end` of exactly the last dive's timestamp drops that dive - always the // newest one, and every dive tied with it. `+1` is enough to make the bound // inclusive of the last sample while keeping the half-open convention that // `periodRange` and every caller already rely on. // // `times` must be sorted ascending and non-empty; an empty series has no range // to speak of and returns a degenerate `[0, 1)` rather than `NaN` bounds. export function scopeRange( times: number[], anchor: number, scope: ChartScope, ): { start: number; end: number } { if (scope !== "all") return periodRange(anchor, scope); if (times.length === 0) return { start: 0, end: 1 }; return { start: times[0], end: times[times.length - 1] + 1 }; } // The sub-periods to shade behind the plot, so alternate months (within a year) // or alternate years (across a career) read as separate blocks. // // This is the chart's only vertical structure. Without it the x labels float // under a plot whose horizontal position means nothing in particular, and a // cluster of dots is impossible to place in the year without tracing down to the // axis. Shading is deliberately the mechanism rather than gridlines: a diving // season is an *interval*, not an instant, so a band is the honest mark for it - // and a chart that already carries dots, the bins and the baseline's band and // line does not need twelve more lines drawn through it. // // A month is too short to subdivide usefully, so it gets none. Everything is // clamped to the plotted range, which matters for "all" - a career starts // partway through its first year and ends partway through its last. export function bandRanges( scope: ChartScope, range: { start: number; end: number }, ): { start: number; end: number }[] { if (scope === "month") return []; const bands: { start: number; end: number }[] = []; if (scope === "year") { const year = new Date(range.start).getUTCFullYear(); // February, April, ... - the first month unshaded, so January reads against // the card rather than against a band. for (let month = 1; month < 12; month += 2) { bands.push({ start: Date.UTC(year, month, 1), end: Date.UTC(year, month + 1, 1), }); } } else { const firstYear = new Date(range.start).getUTCFullYear(); const lastYear = new Date(range.end).getUTCFullYear(); for (let year = firstYear; year <= lastYear; year++) { if ((year - firstYear) % 2 === 1) { bands.push({ start: Date.UTC(year, 0, 1), end: Date.UTC(year + 1, 0, 1), }); } } } return bands .map((band) => ({ start: Math.max(band.start, range.start), end: Math.min(band.end, range.end), })) .filter((band) => band.end > band.start); } export interface GasUseSummary { // How many dives the figures below are drawn from. dives: number; // The period's median RMV. A median rather than a mean so the headline is // judged against the chart's all-time median baseline like for like, and so // one misread gauge in a period moves it by at most a rank. median: number; // Lowest RMV in the period. Lower is better, so this is the best dive, not an // outlier to be explained away. best: number; // Change in median against the previous period *with dives*, as a percentage // of that period's median - negative being an improvement. Null when there // is nothing to compare against: the "all" scope, or the earliest period. changePercent: number | null; // What that previous period is called, for the label that carries the number. // Null exactly when `changePercent` is. previousLabel: string | null; } // The figures above the chart: this period's median, the best dive in it, and // whether that median is going the right way. // // The chart shows the shape; this answers "am I improving", which is the // question the card exists for and the one a scatter of dots is worst at. It // compares against the previous period *containing dives* rather than the // previous calendar one, for the same reason the arrows step that way (see // `stepPeriod`): diving happens in bursts, and "vs last month" is meaningless // when last month was the off-season. // // Returns null when the period holds no dives, which the "all" scope can only // hit on an empty series. export function summarizeGasUse( times: number[], rmvs: number[], scope: ChartScope, anchor: number, ): GasUseSummary | null { const inPeriod = (range: { start: number; end: number }) => rmvs.filter( (_, index) => times[index] >= range.start && times[index] < range.end, ); const current = scope === "all" ? rmvs : inPeriod(periodRange(anchor, scope)); if (current.length === 0) return null; const median = quantile(current, 0.5); const summary: GasUseSummary = { dives: current.length, median, best: Math.min(...current), changePercent: null, previousLabel: null, }; if (scope === "all") return summary; const previousAnchor = stepPeriod(anchor, scope, -1, times); if (previousAnchor === null) return summary; const previousRange = periodRange(previousAnchor, scope); const previous = inPeriod(previousRange); // `stepPeriod` returned a dive's own timestamp, so the period it sits in has // at least that one dive and this cannot divide by zero. const previousMedian = quantile(previous, 0.5); return { ...summary, changePercent: ((median - previousMedian) / previousMedian) * 100, previousLabel: periodLabel(previousRange.start, scope), }; } // How long a break in diving has to be before the line joining one bin's median // to the next is drawn dashed across it. Two months is comfortably longer than // the gap between dives on a trip, or between trips in an active season, and // comfortably shorter than an off-season. // // A solid connector across that gap is a lie - a week in April and a week in // October joined by a long segment reads as "my consumption was steady all // summer" when the truth is "there is no data here". Dashed, it still carries // the eye from one trip's figure to the next without claiming the months // between. export const TREND_GAP_MS = 60 * 24 * 60 * 60 * 1000; // Total pressure drop across a set of cylinders, in bar, with a row that is missing // either pressure contributing nothing. // // **Both callers have already established that every cylinder has both pressures** // before they ask - the flagged-parallel block refuses the set outright above, and the // nudge tests for it in the `&&` that short-circuits this call - so the skip never // actually fires. It is here because the types cannot carry that narrowing across a // function boundary, and skipping beats `?? 0`, which would silently read a missing // pressure as a full cylinder if a third caller ever did ask. // // Mirrors `compute_parallel_gas_use`'s `sum(drops) <= 0` refusal, which is the one // guard where the *total* matters and a single zero-drop row does not: a carried-but- // untouched cylinder contributes zero litres and the dive still has a figure. function totalPressureDrop(mixtures: readonly DiveMixture[]): number { return mixtures.reduce( (sum, mixture) => mixture.start_pressure != null && mixture.end_pressure != null ? sum + (mixture.start_pressure - mixture.end_pressure) : sum, 0, ); } // Why a dive has no `gas_use`, phrased for the user, or `null` when there's // nothing worth saying (the figure is present, or the dive logs no tank at all // and so was never a candidate). // // The API returns `gas_use: null` for every un-derivable dive without saying // why - correctly, since the reasons are a UI concern and the conditions are // all plainly visible in the dive itself. This is the counterpart that turns // that silence into something actionable: a diver who filled in pressures and // still sees no number needs to know it's the missing average depth, not a bug. // Deliberate mirror of the guard clauses in the API's `compute_gas_use()`, // `compute_multi_tank_gas_use()` *and* `compute_parallel_gas_use()` - if any of // the three functions' conditions change, this list has to change with it (one // `grep gas_use` finds the set). The parallel one carries a guard the others do not: // `avg_depth` is an input to its *multi-cylinder* derivation. // // `volume` is nullable, so a cylinder can record a gas and no vessel, and each // function refuses one at its own point in its own order. Each branch below // therefore carries a size sentence in the position its function checks it - after // the pressures for one cylinder, before them for a flagged pair, and instead of the // attribution sentence where the attributed cylinders are the unsized ones. // // Only meaningful on a dive from the *detail* endpoint: the list response // carries neither `mixtures` nor `gas_use`, so every dive in it would look // un-derivable. Guarded rather than assumed, since both fields are typed as // present on the shared `Dive` interface. export function gasUseUnavailableReason(dive: Dive): string | null { if (dive.gas_use) return null; const mixtures = dive.mixtures ?? []; if (mixtures.length === 0) return null; if (mixtures.length > 1) { // **The flagged-parallel block comes first, and its position is load-bearing.** // A set the diver has flagged `parallel` throughout is summed by // `compute_parallel_gas_use` against the dive's own submerged time and average // depth, with no profile and no attribution anywhere in it - so every // attribution sentence below is false of it, and the no-profile branch that // fires before any of them would tell a diver who needs an average depth to // go and import a dive-computer file. The reasons here are the additive // branch's own guards, in its order, with the two-missing case asked first. // // That combined ask is not tidiness. The new-dive form carries `usage` over // from the last dive, so all-parallel-with-nothing-else-filled-in is the // *opening* state of a sidemount diver's every subsequent dive; asking for // the depth and then, once it is typed, for the pressures would make the // commonest state of this form a sequence of refusals. Same shape and same // reason as the single-cylinder combined ask further down. if (isParallelSet(mixtures)) { const missingDepth = dive.avg_depth == null || dive.avg_depth <= 0; const missingPressures = mixtures.some( (mixture) => mixture.start_pressure == null || mixture.end_pressure == null, ); if (missingDepth && missingPressures) { return "Add an average depth and every cylinder's start and end pressure to see your gas consumption."; } // Singular and specifically not max depth, exactly as the one-cylinder // branch has it: a dive spends a moment at its deepest point, so using it // would overstate the ambient pressure the litres are normalized against. // This is the first time `avg_depth` is asked for on a multi-cylinder dive // at all - see the comment on the plural pressures ask below, which says // why the attribution path must never ask for it. if (missingDepth) { return "Add an average depth to see your gas consumption."; } // In `compute_parallel_gas_use`'s own position, which puts it *above* the // pressures rather than below them as the single-cylinder branch has it. The // two orders are the two functions' orders, and neither is arbitrary: a // one-cylinder dive is refused for its size only once the gauges are in, // while the additive path refuses an unsized cylinder before it looks at any // pressure at all. // // *Every* cylinder, on the same terms as the pressures below: one cylinder // with no recorded size contributes no litres to a sum whose denominator // still covers the whole dive, which reports an RMV too low rather than none // at all. Same wording as the pressure ask beside it, because it is the same // refusal about a different input. if (mixtures.some((mixture) => mixture.volume == null)) { return "Add every cylinder's size to see your gas consumption."; } // *Every* cylinder, not "each": one missing pressure anywhere refuses the // whole set. A pair whose second cylinder is bare cannot be summed // honestly - its litres would be missing from the numerator while the // whole dive stayed in the denominator, reporting an RMV that is too low. if (missingPressures) { return "Add every cylinder's start and end pressure to see your gas consumption."; } // Pressures on every cylinder, none of them showing a drop. A zero-drop row // on its own is fine - a carried-but-untouched cylinder contributes zero // litres and the API sums it happily - so this is only the case where the // *total* is zero. "Add up", not "divide up": nothing is being apportioned // on this path. if (totalPressureDrop(mixtures) <= 0) { return "No cylinder on this dive records a drop between its start and end pressure, so there is no gas used to add up."; } // Everything the additive branch needs is present, so the API has a figure // and this function was not called for this dive. Nothing is left to // explain, and a sentence written for a state the UI cannot produce would // be untestable wording - the same rule this branch already applies to the // duplicate-`gas_number` refusal below. Falling through instead would hand // a flagged pair the no-profile message, which is the one thing this block // exists to prevent. return null; } // **The nudge**, and it precedes the no-profile branch deliberately. This is // where a sidemount diver finds out the flag exists, and 18 of the 19 // multi-gas dives in the corpus are hand-logged with no profile and no source // file at all - so ordered after that branch it would never render for the // population it is for. // // Ordered before it *ungated* it would capture that whole population, whose // commonest shape is back gas plus a staged deco bottle with no bottle // pressures, and re-create the tell-them-one-thing-then-refuse-again pattern // the no-profile branch was placed first to stop. So two gates: // // 1. **Every cylinder carries both pressures, and the total drop is // positive.** This screens for the staged-bottle *shape* - a bottle with // no pressures is that population's tell - and not for full // rescuability. The additive branch also wants an average depth, and that // is deliberately left out: a missing depth says nothing about whether a // pair was breathed in parallel, and widening the gate with it would drop // that diver onto the no-profile message instead, with no path to the // figure and no discovery of the flag. A pair with pressures and no depth // is therefore nudged, flags Parallel, and meets the missing-depth ask // above as one follow-up. Both steps are actionable and the second ends in // the figure, which is what separates it from the recorded anti-pattern - // that one ends in a refusal. // 2. **No cylinder flagged `staged`.** A parallel pair plus an explicitly // staged bottle is refused by design, and a diver who used the control // exactly right must not be nudged on every render. The `staged` flag is // that set's tell, so the gate keys on it and nothing wider: suppressing // on *any* explicit `usage` would also silence the half-flagged pair - one // row Parallel, one still Not recorded - which is the likeliest path into // the feature, and drop that diver onto "import a dive-computer file". A // half-flagged pair keeps the nudge, whose condition-stating wording // already covers it. // // This also places the nudge ahead of the attribution-shortfall sentence, so // an imported dive with pressures on every cylinder and no flags gets the // nudge rather than "needs an import whose gas switches account for every // cylinder". Deliberate: both are true there, the nudge names the action // available in the app right now, and gate 1 makes the case unreachable in // the current corpus anyway - all 19 multi-gas dives lack a second-cylinder // pressure. // // **The wording states the condition rather than issuing an instruction**, // and that is a safety property, not a style choice. A diver with a genuinely // staged pair that does carry both pressures would otherwise be walked into a // flag whose figure divides the bottle's litres by the whole dive's average // depth - the exact misattribution the multi-cylinder refusal exists to // prevent. const everyCylinderHasPressures = mixtures.every( (mixture) => mixture.start_pressure != null && mixture.end_pressure != null, ); if ( everyCylinderHasPressures && totalPressureDrop(mixtures) > 0 && !hasStagedCylinder(mixtures) ) { return "If these cylinders were breathed alternately at the same depth - a sidemount pair or independent doubles - marking each of them Parallel adds their gas up into one figure. A cylinder breathed at its own depth is not that, and should be left alone."; } // Several cylinders *can* yield figures, from // `compute_multi_tank_gas_use`. It gives up for five distinct reasons, and // this is the browser's reading of which one applies. In the API's own // order: // // 1. The profile carries no gas-switch attribution at all. Refuses. // 2. Two mixtures claim one `gas_number` (or two attribution entries do), // which makes every join ambiguous. Refuses the whole dive rather than // just those two. // 3. A cylinder the attribution never names. **Skipped only when its own // pressures show no drop** - the commonest case in the corpus, a deco // bottle with no transmitter. One that demonstrably *was* breathed // refuses the dive: the time it was breathed for is sitting inside some // other tank's stretch, so the surviving figures are wrong rather than // merely partial, and the coverage fraction cannot show it. // 4. A cylinder failing the same pressure arithmetic `compute_gas_use` // applies is skipped. If *every* cylinder is skipped, nothing survives // and the dive comes back empty. // 5. A tank whose per-tank RMV comes out past `MAX_PLAUSIBLE_RMV` refuses // the dive - case 3's fault arriving by another route, a switch recorded // late rather than not at all. // // Three of those five (1, the refusing half of 3, and 5) are attribution // faults and share one sentence, which is why it must not claim the import // has no gas switches: on 3 and 5 it demonstrably does, and the profile // chart is drawing their markers ten lines up the same page. It says the // switches don't account for every cylinder instead, which is true of all // three - including the no-switches case, vacuously. // // Case 2 gets no sentence: not reachable through this app, since // `gas_number` is carried and never edited and a hand-added cylinder has // none, so a phrase for it would be untestable wording for a state the UI // can't produce. Case 4 is the two sentences below. // // Before any of that: no profile, no attribution, whatever the diver types. // `gas_attribution` is a column *on* `dive_profile`, so a dive without one // cannot reach the multi-tank derivation at all - and this is the ordinary // case rather than an edge, being 18 of the 19 multi-gas dives in the // corpus, every one of them logged by hand. Without this branch they were // told their import's gas switches fall short of covering every cylinder, // about an import they never made; and the "add your pressures" branch // below would have sent the ones missing pressures off to fill in fields // that change nothing, to be met with a different refusal. // // **The primary recording's profile, and no other.** The API joins the // attribution through `ordinal == 0`: gas consumption comes from the dive's // own diver-editable cylinders joined to a profile's switches by // `gas_number`, and a second computer's labels are mapped onto that one list // when its file is attached - so there is one attribution per dive, and it // belongs to the record the dive's figures were seeded from. Asking "does // *any* recording have a profile" here would say the attribution exists on a // dive whose primary is the hand-logged half of a merge. // // `recordings` is on the detail response, and only the detail response - but // the `mixtures` guard above has already returned for a list dive, so a // missing profile here means the dive genuinely has none. if (primaryRecording(dive)?.profile == null) { return "Gas consumption for a multi-tank dive is worked out from the gas switches in an imported dive-computer file, and this dive doesn't have one."; } // Attribution is tested next because it is the outer gate: the API reaches // the pressure arithmetic only after the attribution exists. The browser // can't see that column, so the inference runs backwards - a cylinder whose // pressures would have produced a figure, on a dive that produced none, // means the attribution is what was missing. const breathed = mixtures.filter( (mixture) => mixture.start_pressure != null && mixture.end_pressure != null && mixture.start_pressure > mixture.end_pressure, ); // Narrowed to the cylinders that could produce a figure *if* the attribution // covered them. A cylinder with a drop and no recorded size is dropped by // `_tank_arithmetic` however good the attribution is - it was counted as // breathed and then produced no litres - so blaming the import's gas switches // for a dive whose sizes are all missing names the one input that is not the // problem, while the profile chart above draws the switches it says are absent. const measurable = breathed.filter((mixture) => mixture.volume != null); if (measurable.length > 0) { return "Gas consumption for a multi-tank dive needs an import whose gas switches account for every cylinder on the dive."; } // Breathed cylinders, none of them sized. "Each", matching the pressure ask at // the foot of this branch rather than the flagged pair's "every": this path // divides the dive up per cylinder instead of adding it up, so there is no // whole-set refusal to state. if (breathed.length > 0) { return "Add each cylinder's size to see your gas consumption."; } // Pressures recorded on some cylinder, but no drop on any of them - the // multi-cylinder form of the "carried but never breathed" case below. // Nothing to add and nothing to divide up, so neither sentence around this // one fits. const anyPressures = mixtures.some( (mixture) => mixture.start_pressure != null && mixture.end_pressure != null, ); if (anyPressures) { return "No cylinder on this dive records a drop between its start and end pressure, so there is no gas used to divide up."; } // Plural, and no mention of average depth: the multi-tank path takes its // depth per cylinder from the profile, so `dive.avg_depth` is not one of its // inputs and asking for it would send a diver to fill in a field that // changes nothing. That reasoning is about *this* path and does not // generalize - the flagged-parallel block at the top of this branch // asks for an average depth on a multi-cylinder dive, because the additive // derivation genuinely divides by it. Which derivation is in play is what // decides whether the field is worth asking for. return "Add each cylinder's start and end pressure to see your gas consumption."; } const [mixture] = mixtures; const missingDepth = dive.avg_depth == null || dive.avg_depth <= 0; const missingPressures = mixture.start_pressure == null || mixture.end_pressure == null; if (missingDepth && missingPressures) { return "Add an average depth and this tank's start and end pressure to see your gas consumption."; } if (missingDepth) { // Specifically not max depth: a dive spends a moment at its deepest point, // so using it would understate consumption badly. return "Add an average depth to see your gas consumption."; } if (missingPressures) { return "Add this tank's start and end pressure to see your gas consumption."; } // In `compute_gas_use`'s own position: after the depth and pressure checks and // *before* the drop check, which is why a dive with a real drop and no size lands // here rather than on the sentence below. It is the last of the four a diver meets, // and the only one they can reach with every gauge reading filled in. // // Not folded into the combined ask above, unlike depth-and-pressures. That pairing // exists because a form carrying `usage` over from the last dive opens with both // blank, so asking twice would be the commonest state of the form; a blank size is // not that state. `DEFAULT_MIXTURE` proposes 11.1 L on every cylinder added by hand, // so the only way to arrive here is an import from a file that recorded a gas and no // vessel - where the depth and the pressures came from the same file and are already // filled in. // // "Size", not "volume": the box is labelled Volume (L) and a diver reads that as the // gas in the cylinder as often as the cylinder itself, which is the confusion that // makes an AL80 "80 cubic feet" and 11.1 L at once. if (mixture.volume == null) { return "Add this tank's size to see your gas consumption."; } // Both pressures recorded but no drop between them. The database already // rejects an end pressure above the start, so this is the equal case: a tank // that was carried but never breathed, or a typo. return "This tank's start and end pressure are the same, so it records no gas used."; } // One line of the per-tank consumption table: a cylinder the dive logs, and the // share of the gas the API attributed to it. export interface TankGasUseRow { // React key. Built from the cylinder's identity rather than its gas number, // which is neither unique nor always present. key: string; // The cylinder's 1-based position, written bare - the number the Tanks card // names the same cylinder by - so the two can be read against each other. A tank matching no mixture has no position to state and carries // the device's `Gas N` instead. label: string; // `gasName`'s output, so this badge names the gas exactly as the Tanks card // does - null for a cylinder whose fractions can't be named, and for a tank // with no mixture to name it from. gas: string | null; role: GasRole | null; // Whether this cylinder records both pressures. Only consulted when `use` is // null, where it separates the two reasons a row can carry no figures - see // below - and it is the one of the two the browser can see for itself. hasPressures: boolean; // Null for a cylinder with no figures. That is a real state worth a row // rather than an omission: a pony bottle carried and never breathed belongs // in this table saying so, and dropping it would leave the table quietly // shorter than the Tanks card above it. // // **Two server states collapse into this null**, and a caller labelling the // row must not name only the first. Either the attribution never mentioned // the cylinder - the corpus's deco bottle with no transmitter - or it did, // and `_tank_arithmetic` declined it: no pressures, no drop between them, or // a degenerate stretch (`seconds <= 0`, `mean_depth_cm <= 0`, which a gas // switched to at the surface at the end of a dive really produces). In that // second case the coverage note below the table reports the missing seconds, // so a row reading "not attributed" would be contradicted three lines down. use: DiveTankGasUse | null; } /** * The per-cylinder consumption rows for a dive, in the order the dive lists its * cylinders, or an empty array when the API attributed nothing per tank. * * The join is `DiveMixture.gas_number` ↔ `DiveTankGasUse.gas_number`, and it * applies the same rule the API does rather than trusting that the API applied * it. `compute_multi_tank_gas_use` refuses a whole dive whose mixtures share a * gas number, so such a duplicate arrives with `gas_use: null`. A flagged * parallel set is summed without consulting `gas_number` at all, so a duplicate * on one of those dives reaches this function with a real `gas_use` behind it - * and lands in the arm below, harmlessly, since `tanks` is empty and every row * comes back unattributed anyway. The guard is belt-and-braces, and deliberately * so. It is the cheap half of a pair whose expensive half is a table showing one * cylinder's litres twice under a total that counted them once, and the two * sides can drift: the day the form stops carrying `gas_number` untouched, or a * cached response predates the API's guard, this is what stands between a * duplicate and a table that visibly doesn't add up. * * So a gas number naming more than one cylinder names none of them: both rows * come back unattributed. **The rule is applied to both sides of the join**, not * just the mixtures — two tanks sharing a number leave neither matched, and each * lands in a row of its own. An asymmetry here would have been the same bug in * mirror image, and a worse one: a lookup keeping the last writer drops the * earlier tank from the table while its litres stay inside the total. * * A tank matching no mixture is appended rather than dropped, for the reason * that governs all of the above: its litres are already inside the dive-wide * total, so hiding the row would leave a total the visible rows don't add up to. * Every tank reaches exactly one row, matched or appended, which is what makes * that guarantee checkable. */ export function tankGasUseRows(dive: Dive): TankGasUseRow[] { const tanks = dive.gas_use?.tanks ?? []; if (tanks.length === 0) return []; const mixtures = dive.mixtures ?? []; const occurrences = new Map(); for (const mixture of mixtures) { if (mixture.gas_number == null) continue; const number = mixture.gas_number; occurrences.set(number, (occurrences.get(number) ?? 0) + 1); } // The same count on the tank side, and for the same reason. A `Map` keyed by // gas number silently keeps the last writer, so two tanks sharing a number // would leave the earlier one matched to nothing, skipped by the append loop // below - which tests the number, not the tank - and therefore invisible, // while its litres stayed inside the dive-wide total. That is the mixture-side // failure exactly, arriving from the other end, so it gets the same answer: // a number naming two tanks names neither, and both fall through to rows of // their own. const tankOccurrences = new Map(); for (const tank of tanks) { tankOccurrences.set( tank.gas_number, (tankOccurrences.get(tank.gas_number) ?? 0) + 1, ); } const byNumber = new Map(); for (const tank of tanks) { if (tankOccurrences.get(tank.gas_number) === 1) { byNumber.set(tank.gas_number, tank); } } // Tanks, not gas numbers. Identity is what "this tank already has a row" // actually means, and keying it by number is what let a duplicate suppress a // tank that was never rendered. const claimed = new Set(); const rows: TankGasUseRow[] = mixtures.map((mixture, index) => { const number = mixture.gas_number; const use = number != null && occurrences.get(number) === 1 ? (byNumber.get(number) ?? null) : null; if (use) claimed.add(use); return { // Namespaced by which of the two it is: `mixture-${id ?? index}` collides // when some mixtures carry ids and others don't (`[{id: 1}, {}]` keys both // to `mixture-1`). Saved mixtures always have ids, so this is unreachable // today - but "gives every row a distinct key" is a claim a test makes // here, and it should be true for the reason it states. key: mixture.id != null ? `mixture-id-${mixture.id}` : `mixture-at-${index}`, // The Tanks card's number for the cylinder - its 1-based place in the // list, and deliberately not its gas number, which the reader counts // from 0. Bare, because the column is headed `#`. label: `${index + 1}`, gas: gasName(mixture.oxygen, mixture.helium), role: mixture.role ?? null, hasPressures: mixture.start_pressure != null && mixture.end_pressure != null, use, }; }); tanks.forEach((tank, index) => { if (claimed.has(tank)) return; rows.push({ // Position in `tanks`, not the gas number, which duplicates share - two // tanks labelled gas 1 would otherwise render under one React key. key: `tank-${index}`, // The device's own label is all there is to call it by. "Gas 3" rather // than a bare "3", so it can't be misread as the Tanks card's `#3` - the // whole point of this row is that it matches none of them. // This is why the rows above lost the word and this one keeps it: the // distinction was never "tank" versus "gas", it was numbered-by-position // versus named-by-the-device, and only one of those needs saying now that // the column is headed `#`. label: `Gas ${tank.gas_number}`, gas: null, role: null, // No mixture behind this row, so nothing to have recorded pressures. Never // read - `use` is set - but it must not claim otherwise. hasPressures: false, use: tank, }); }); return rows; } // Below this, the unattributed remainder is rounding rather than a gap worth a // sentence. A minute is also the resolution the note is phrased at, so anything // shorter would print a range whose two ends read the same. const UNATTRIBUTED_NOTE_SECONDS = 60; /** * How much of the dive the per-tank figures actually account for, phrased for * the diver, or `null` when they account for effectively all of it. * * Attribution is an inference from the profile, not a recorded fact, and it can * leave a remainder: a file that records gas switches but not which cylinder the * diver entered the water on has nothing to assign the descent to. Figures * covering 38 of 42 minutes are still worth showing - they are right about the * 38 - but presenting them as the whole dive would understate every one of them. * * **The denominator is attributed to the dive computer in so many words**, and * has to be. It is `DiveGasUse.duration` - the profile's span, not the dive's own * `submerged_time` - and it routinely runs longer than the dive time the same page * prints at the top: a computer keeps sampling * after the diver surfaces, by five minutes on dive #493. An unattributed "of the * 1h 12min recorded" under a header reading "1h 7min" reads as one of the two * being wrong, when both are right about different things. */ export function gasAttributionNote(gasUse: DiveGasUse): string | null { const attributed = gasUse.attributed_seconds; const total = gasUse.duration; if (attributed == null || total == null) return null; if (!Number.isFinite(attributed) || !Number.isFinite(total)) return null; // A remainder can only be measured against a span. Guards the degenerate // profile as well as the impossible over-attribution that would phrase itself // as covering more of the dive than the dive contains. if (total <= 0 || attributed >= total) return null; if (total - attributed < UNATTRIBUTED_NOTE_SECONDS) return null; return `These figures cover ${formatDurationHoursMinutes(attributed)} of the ${formatDurationHoursMinutes(total)} the dive computer recorded - the rest couldn't be assigned to a cylinder.`; }