fix(yachts): ft↔m round-trip is lossless (4dp + canonical helpers)

Three copies of the imperial/metric conversion logic existed:
  - src/components/yachts/yacht-dimensions.ts   (canonical, used by
    read-side `formatYachtDimensionsBothUnits`)
  - src/components/yachts/yacht-form.tsx        (create/edit sheet —
    local `ftToM`/`mToFt` with 2dp precision)
  - src/components/yachts/yacht-tabs.tsx        (detail-tab inline
    edit — local arithmetic with 2dp precision)

The 2dp rounding lost precision on the round-trip: `1 ft → 0.30 m →
0.98 ft`. Whenever a rep entered ft, then later touched the m field,
the ft column silently shifted off. Same for sub-meter draft values.

Consolidate both surfaces onto `feetToMeters` / `metersToFeet` from
yacht-dimensions.ts and bump display precision to 4dp. After
trimZero strips trailing zeros the rendered string stays clean
("3.81" not "3.8100") but the round-trip now lands back on the
original value:

  1 ft → 0.3048 m → 1 ft
  12.5 ft → 3.81 m → 12.5 ft
  50 ft → 15.24 m → 50 ft
  0.5 m → 1.6404 ft → 0.5 m

New unit test (`tests/unit/yacht-dimensions.test.ts`) covers the
helpers + the form-shape round-trip, including the canonical
12.5 ft ↔ 3.81 m case from the UAT bug report.

29/29 new tests pass; full vitest 1448/1448.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:25:28 +02:00
parent 1f591ff7ae
commit 8e9efe5ae8
3 changed files with 147 additions and 17 deletions

View File

@@ -23,6 +23,7 @@ import { CountryCombobox } from '@/components/shared/country-combobox';
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
import type { z } from 'zod';
import { createYachtSchema, type CreateYachtInput } from '@/lib/validators/yachts';
@@ -424,11 +425,12 @@ export function YachtForm({
);
}
// 1 ft = 0.3048 m exactly. Round to 2 decimals so the cross-filled value is
// readable but stable; `trimZero` strips trailing `.0` so a whole-number
// conversion like `5 ft → 1.52 m → 1.52` doesn't render as `1.520000`.
const FT_PER_M = 3.28084;
// Cross-fill the sibling unit. Delegates the math to `yacht-dimensions.ts`
// so the form and the read-side `formatYachtDimensionsBothUnits` agree on
// the formula. 4-decimal precision keeps the round-trip lossless within
// display: `1 ft → 0.3048 m → 1.0000 ft` (after trimZero → "1"); the prior
// 2-decimal precision rounded `1 ft → 0.30 m → 0.98 ft` which lost data
// whenever the rep touched both fields on the same yacht.
function trimZero(s: string): string {
if (!s.includes('.')) return s;
return s.replace(/\.?0+$/, '');
@@ -436,16 +438,16 @@ function trimZero(s: string): string {
function ftToM(value: string | null | undefined): string {
if (value == null || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return '';
return trimZero((n * 0.3048).toFixed(2));
const meters = feetToMeters(value);
if (meters === null) return '';
return trimZero(meters.toFixed(4));
}
function mToFt(value: string | null | undefined): string {
if (value == null || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return '';
return trimZero((n * FT_PER_M).toFixed(2));
const feet = metersToFeet(value);
if (feet === null) return '';
return trimZero(feet.toFixed(4));
}
function DimensionPair({

View File

@@ -11,6 +11,7 @@ import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { RemindersInline } from '@/components/reminders/reminders-inline';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants';
@@ -131,12 +132,13 @@ function OverviewTab({
await mutation.mutateAsync({ [primaryField]: next });
return;
}
const FT_PER_M = 3.28084;
const converted = isFt ? n / FT_PER_M : n * FT_PER_M;
const convertedStr = converted
.toFixed(2)
.replace(/\.0+$/, '')
.replace(/(\.\d)0$/, '$1');
// Delegate the math to the canonical helpers in yacht-dimensions.ts
// so this surface, the create-form, and the read-side formatter all
// round-trip identically. 4dp precision keeps `1 ft → 0.3048 m →
// 1.0000 ft` (after trimZero → "1"); 2dp lost data on small values.
const converted = isFt ? feetToMeters(next) : metersToFeet(next);
const convertedStr =
converted === null ? '' : converted.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
await mutation.mutateAsync({
[primaryField]: next,
[counterpart]: convertedStr,