diff --git a/src/components/yachts/yacht-form.tsx b/src/components/yachts/yacht-form.tsx index 00ba5cec..65f63bee 100644 --- a/src/components/yachts/yacht-form.tsx +++ b/src/components/yachts/yacht-form.tsx @@ -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({ diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx index 3f79e209..c1d69c52 100644 --- a/src/components/yachts/yacht-tabs.tsx +++ b/src/components/yachts/yacht-tabs.tsx @@ -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, diff --git a/tests/unit/yacht-dimensions.test.ts b/tests/unit/yacht-dimensions.test.ts new file mode 100644 index 00000000..b3083641 --- /dev/null +++ b/tests/unit/yacht-dimensions.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; + +import { + feetToMeters, + metersToFeet, + formatNumber1dp, + dimInFeet, + dimInMeters, +} from '@/components/yachts/yacht-dimensions'; + +describe('yacht-dimensions: ft ↔ m conversions', () => { + it('feetToMeters: returns null for non-finite inputs', () => { + expect(feetToMeters(null)).toBeNull(); + expect(feetToMeters(undefined)).toBeNull(); + expect(feetToMeters('')).toBeNull(); + expect(feetToMeters('abc')).toBeNull(); + expect(feetToMeters(NaN)).toBeNull(); + }); + + it('metersToFeet: returns null for non-finite inputs', () => { + expect(metersToFeet(null)).toBeNull(); + expect(metersToFeet(undefined)).toBeNull(); + expect(metersToFeet('')).toBeNull(); + expect(metersToFeet('abc')).toBeNull(); + expect(metersToFeet(NaN)).toBeNull(); + }); + + it('feetToMeters: matches 1 ft = 0.3048 m within 1e-4', () => { + // 1 / 3.28084 = 0.304800061... vs the SI definition 0.3048 m + expect(feetToMeters(1)).toBeCloseTo(0.3048, 4); + expect(feetToMeters(12.5)).toBeCloseTo(3.81, 2); + expect(feetToMeters(50)).toBeCloseTo(15.24, 2); + }); + + it('metersToFeet: produces inverse of feetToMeters within float precision', () => { + expect(metersToFeet(0.3048)).toBeCloseTo(1, 4); + expect(metersToFeet(3.81)).toBeCloseTo(12.5, 2); + expect(metersToFeet(15.24)).toBeCloseTo(50, 2); + }); + + describe('round-trip is lossless within helper precision', () => { + const cases = [1, 5, 12.5, 25, 50, 120, 250]; + cases.forEach((ft) => { + it(`${ft} ft → m → ft preserves value`, () => { + const m = feetToMeters(ft)!; + const backToFt = metersToFeet(m)!; + expect(backToFt).toBeCloseTo(ft, 6); + }); + }); + const mCases = [0.5, 1, 3.81, 7.62, 15.24, 36.58]; + mCases.forEach((m) => { + it(`${m} m → ft → m preserves value`, () => { + const ft = metersToFeet(m)!; + const backToM = feetToMeters(ft)!; + expect(backToM).toBeCloseTo(m, 6); + }); + }); + }); + + describe('formatNumber1dp', () => { + it('strips trailing .0', () => { + expect(formatNumber1dp(12)).toBe('12'); + expect(formatNumber1dp(12.5)).toBe('12.5'); + expect(formatNumber1dp(3.812)).toBe('3.8'); + expect(formatNumber1dp(null)).toBeNull(); + }); + }); + + describe('form-shape round-trip (4dp + trimZero, mirrors yacht-form.tsx)', () => { + // The form persists string values, so we model the same shape the + // input fields see. trimZero strips trailing zeros after the dot. + const trimZero = (s: string) => (!s.includes('.') ? s : s.replace(/\.?0+$/, '')); + const ftToM = (v: string): string => { + const m = feetToMeters(v); + return m === null ? '' : trimZero(m.toFixed(4)); + }; + const mToFt = (v: string): string => { + const ft = metersToFeet(v); + return ft === null ? '' : trimZero(ft.toFixed(4)); + }; + + it('round-trip 1 ft → m → ft is lossless', () => { + const m = ftToM('1'); + expect(mToFt(m)).toBe('1'); + }); + it('round-trip 12.5 ft ↔ 3.81 m ↔ 12.5 ft', () => { + const m = ftToM('12.5'); + const ft = mToFt(m); + // 12.5 / 3.28084 = 3.80999961... → "3.81" → 3.81 * 3.28084 = 12.4999... → "12.5" + expect(ft).toBe('12.5'); + }); + it('round-trip 50 ft → 15.24 m → 50 ft', () => { + expect(mToFt(ftToM('50'))).toBe('50'); + }); + it('round-trip 0.5 m → ft → 0.5 m', () => { + expect(ftToM(mToFt('0.5'))).toBe('0.5'); + }); + it('empty string returns empty', () => { + expect(ftToM('')).toBe(''); + expect(mToFt('')).toBe(''); + }); + it('non-numeric returns empty', () => { + expect(ftToM('abc')).toBe(''); + expect(mToFt('abc')).toBe(''); + }); + }); + + describe('dimInFeet / dimInMeters: derive missing unit from sibling', () => { + it('returns ft value when set', () => { + expect(dimInFeet({ ft: 12.5, m: 3.81 })).toBe('12.5'); + }); + it('derives ft from m when ft is null', () => { + expect(dimInFeet({ ft: null, m: 3.81 })).toBe('12.5'); + }); + it('returns m value when set', () => { + expect(dimInMeters({ ft: 12.5, m: 3.81 })).toBe('3.8'); + }); + it('derives m from ft when m is null', () => { + expect(dimInMeters({ ft: 12.5, m: null })).toBe('3.8'); + }); + it('returns null when both null', () => { + expect(dimInFeet({ ft: null, m: null })).toBeNull(); + expect(dimInMeters({ ft: null, m: null })).toBeNull(); + }); + }); +});