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:
@@ -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;
|
||||
}}
|
||||
|
||||
11
src/lib/db/migrations/0087_interest_use_yacht_dims.sql
Normal file
11
src/lib/db/migrations/0087_interest_use_yacht_dims.sql
Normal 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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user