feat(b3-1): interest dimensions dual-source — yacht dims for the recommender

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:22:57 +02:00
parent 8998f68c0f
commit da391b1830
5 changed files with 70 additions and 0 deletions

View File

@@ -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<string>(() => computeDisplay(ftValue, unit));
@@ -904,6 +908,7 @@ function DimensionInput({
min={0}
placeholder={placeholder}
value={display}
disabled={disabled}
onFocus={() => {
focusedRef.current = true;
}}

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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<InterestInput | null> {
@@ -389,6 +395,8 @@ async function loadInterestInput(interestId: string): Promise<InterestInput | nu
desiredWidthFt: interests.desiredWidthFt,
desiredDraftFt: interests.desiredDraftFt,
portId: interests.portId,
useYachtDimensions: interests.useYachtDimensions,
yachtId: interests.yachtId,
})
.from(interests)
.where(eq(interests.id, interestId))
@@ -399,11 +407,46 @@ async function loadInterestInput(interestId: string): Promise<InterestInput | nu
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
};
// Dual-source resolution: when the toggle is on AND a yacht is
// linked AND that yacht carries dimension data, the recommender
// uses the yacht's dims. Falls back to the rep-entered desired*
// columns whenever any of those preconditions fail.
if (row.useYachtDimensions && row.yachtId) {
const [yachtRow] = await db
.select({
lengthFt: yachts.lengthFt,
widthFt: yachts.widthFt,
draftFt: yachts.draftFt,
})
.from(yachts)
.where(eq(yachts.id, row.yachtId))
.limit(1);
if (yachtRow) {
const yLen = toNum(yachtRow.lengthFt);
const yWid = toNum(yachtRow.widthFt);
const yDrft = toNum(yachtRow.draftFt);
// Only switch to yacht dims when at least one yacht measurement
// is present — otherwise we'd silently downgrade an interest
// with desired dims set to "no dims at all".
if (yLen !== null || yWid !== null || yDrft !== null) {
return {
desiredLengthFt: yLen,
desiredWidthFt: yWid,
desiredDraftFt: yDrft,
portId: row.portId,
dimensionsSource: 'yacht',
};
}
}
}
return {
desiredLengthFt: toNum(row.desiredLengthFt),
desiredWidthFt: toNum(row.desiredWidthFt),
desiredDraftFt: toNum(row.desiredDraftFt),
portId: row.portId,
dimensionsSource: 'interest',
};
}

View File

@@ -84,6 +84,11 @@ export const createInterestSchema = z.object({
desiredLengthUnit: desiredUnitSchema,
desiredWidthUnit: desiredUnitSchema,
desiredDraftUnit: desiredUnitSchema,
/** Toggle: when true and a yacht is linked, the berth recommender
* reads the yacht's dimensions instead of the desired_* columns
* above. Per migration 0087. Defaults false everywhere it isn't
* explicitly set. */
useYachtDimensions: z.boolean().optional(),
});
// ─── Update ──────────────────────────────────────────────────────────────────