From da391b183026fdbc377d8ae29f138568a6ae9aff Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 17:22:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(b3-1):=20interest=20dimensions=20dual-sour?= =?UTF-8?q?ce=20=E2=80=94=20yacht=20dims=20for=20the=20recommender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/superpowers/audits/alpha-uat-master.md Bucket 3 #1. When a yacht is linked to the interest the rep can flip a per-interest toggle so the berth recommender reads dimensions off the yacht record instead of the rep-entered desired_* columns. - Migration 0087 + interests.useYachtDimensions boolean (default false). - Validator (createInterestSchema) accepts the new field; service insert + update paths spread it through automatically. - berth-recommender.service.loadInterestInput dual-source resolution: when toggle=true AND yachtId is set AND the yacht has at least one measurement on file, the recommender uses the yacht's length / width / draft instead of the desired_* values. Falls back to the desired columns whenever any precondition fails (no yacht link, toggle off, or the yacht carries no measurements). Returned InterestInput gains a `dimensionsSource: 'interest' | 'yacht'` trace field. - Interest form: under the "Berth size desired" section, when a yacht is linked, a checkbox surfaces — "Use the linked yacht's dimensions for the recommender". When checked, the three dimension inputs grey out (DimensionInput gains a `disabled` prop) so the rep can't accidentally edit the now-overridden values. Hint text spells out the fallback behaviour. Verified: tsc clean, 1493/1493 vitest, migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/interests/interest-form.tsx | 5 +++ .../0087_interest_use_yacht_dims.sql | 11 +++++ src/lib/db/schema/interests.ts | 6 +++ src/lib/services/berth-recommender.service.ts | 43 +++++++++++++++++++ src/lib/validators/interests.ts | 5 +++ 5 files changed, 70 insertions(+) create mode 100644 src/lib/db/migrations/0087_interest_use_yacht_dims.sql diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index 61612135..93b84557 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -860,6 +860,9 @@ interface DimensionInputProps { unit: 'ft' | 'm'; ftValue: string | number | undefined; onChangeFt: (next: string | undefined) => void; + /** B3 #1: greyed out when the interest is set to read dimensions + * off the linked yacht instead of these fields. */ + disabled?: boolean; } /** @@ -878,6 +881,7 @@ function DimensionInput({ unit, ftValue, onChangeFt, + disabled, }: DimensionInputProps) { const focusedRef = useRef(false); const [display, setDisplay] = useState(() => computeDisplay(ftValue, unit)); @@ -904,6 +908,7 @@ function DimensionInput({ min={0} placeholder={placeholder} value={display} + disabled={disabled} onFocus={() => { focusedRef.current = true; }} diff --git a/src/lib/db/migrations/0087_interest_use_yacht_dims.sql b/src/lib/db/migrations/0087_interest_use_yacht_dims.sql new file mode 100644 index 00000000..a2a6f73d --- /dev/null +++ b/src/lib/db/migrations/0087_interest_use_yacht_dims.sql @@ -0,0 +1,11 @@ +-- 0087_interest_use_yacht_dims.sql +-- Adds a per-interest toggle so the berth recommender can read the +-- linked yacht's dimensions instead of the rep-entered `desired_*` +-- values. Defaults to false so existing data continues to use the +-- desired-dims path the recommender has always used. +-- +-- Per docs/superpowers/audits/alpha-uat-master.md Bucket 3 #1: +-- "Interest dimensions: dual-source model". + +ALTER TABLE interests + ADD COLUMN IF NOT EXISTS use_yacht_dimensions boolean NOT NULL DEFAULT false; diff --git a/src/lib/db/schema/interests.ts b/src/lib/db/schema/interests.ts index 3134097a..80401fda 100644 --- a/src/lib/db/schema/interests.ts +++ b/src/lib/db/schema/interests.ts @@ -102,6 +102,12 @@ export const interests = pgTable( desiredLengthUnit: text('desired_length_unit').notNull().default('ft'), desiredWidthUnit: text('desired_width_unit').notNull().default('ft'), desiredDraftUnit: text('desired_draft_unit').notNull().default('ft'), + /** Per-interest source-of-truth toggle for the berth recommender. + * When true AND a yacht is linked, the recommender uses the yacht's + * length/width/draft instead of the rep-entered desired_* columns + * on this row. Defaults false so existing data continues to use the + * desired-dims path. Migration 0087. */ + useYachtDimensions: boolean('use_yacht_dimensions').notNull().default(false), archivedAt: timestamp('archived_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/src/lib/services/berth-recommender.service.ts b/src/lib/services/berth-recommender.service.ts index bc5d2b84..5b685d56 100644 --- a/src/lib/services/berth-recommender.service.ts +++ b/src/lib/services/berth-recommender.service.ts @@ -34,6 +34,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { interests } from '@/lib/db/schema/interests'; +import { yachts } from '@/lib/db/schema/yachts'; import { CodedError } from '@/lib/errors'; // ─── Settings ────────────────────────────────────────────────────────────── @@ -380,6 +381,11 @@ interface InterestInput { desiredWidthFt: number | null; desiredDraftFt: number | null; portId: string; + /** Audit/debug aid — which dimension set the predicates were built + * from. Set by `loadInterestInput` based on the dual-source toggle + * + yacht link. Not consumed by SQL; surfaced in tests and trace + * logs only. */ + dimensionsSource: 'interest' | 'yacht'; } async function loadInterestInput(interestId: string): Promise { @@ -389,6 +395,8 @@ async function loadInterestInput(interestId: string): Promise