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';
|
unit: 'ft' | 'm';
|
||||||
ftValue: string | number | undefined;
|
ftValue: string | number | undefined;
|
||||||
onChangeFt: (next: string | undefined) => void;
|
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,
|
unit,
|
||||||
ftValue,
|
ftValue,
|
||||||
onChangeFt,
|
onChangeFt,
|
||||||
|
disabled,
|
||||||
}: DimensionInputProps) {
|
}: DimensionInputProps) {
|
||||||
const focusedRef = useRef(false);
|
const focusedRef = useRef(false);
|
||||||
const [display, setDisplay] = useState<string>(() => computeDisplay(ftValue, unit));
|
const [display, setDisplay] = useState<string>(() => computeDisplay(ftValue, unit));
|
||||||
@@ -904,6 +908,7 @@ function DimensionInput({
|
|||||||
min={0}
|
min={0}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={display}
|
value={display}
|
||||||
|
disabled={disabled}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
focusedRef.current = true;
|
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'),
|
desiredLengthUnit: text('desired_length_unit').notNull().default('ft'),
|
||||||
desiredWidthUnit: text('desired_width_unit').notNull().default('ft'),
|
desiredWidthUnit: text('desired_width_unit').notNull().default('ft'),
|
||||||
desiredDraftUnit: text('desired_draft_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 }),
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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 { db } from '@/lib/db';
|
||||||
import { systemSettings } from '@/lib/db/schema/system';
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { CodedError } from '@/lib/errors';
|
import { CodedError } from '@/lib/errors';
|
||||||
|
|
||||||
// ─── Settings ──────────────────────────────────────────────────────────────
|
// ─── Settings ──────────────────────────────────────────────────────────────
|
||||||
@@ -380,6 +381,11 @@ interface InterestInput {
|
|||||||
desiredWidthFt: number | null;
|
desiredWidthFt: number | null;
|
||||||
desiredDraftFt: number | null;
|
desiredDraftFt: number | null;
|
||||||
portId: string;
|
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> {
|
async function loadInterestInput(interestId: string): Promise<InterestInput | null> {
|
||||||
@@ -389,6 +395,8 @@ async function loadInterestInput(interestId: string): Promise<InterestInput | nu
|
|||||||
desiredWidthFt: interests.desiredWidthFt,
|
desiredWidthFt: interests.desiredWidthFt,
|
||||||
desiredDraftFt: interests.desiredDraftFt,
|
desiredDraftFt: interests.desiredDraftFt,
|
||||||
portId: interests.portId,
|
portId: interests.portId,
|
||||||
|
useYachtDimensions: interests.useYachtDimensions,
|
||||||
|
yachtId: interests.yachtId,
|
||||||
})
|
})
|
||||||
.from(interests)
|
.from(interests)
|
||||||
.where(eq(interests.id, interestId))
|
.where(eq(interests.id, interestId))
|
||||||
@@ -399,11 +407,46 @@ async function loadInterestInput(interestId: string): Promise<InterestInput | nu
|
|||||||
const n = parseFloat(v);
|
const n = parseFloat(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
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 {
|
return {
|
||||||
desiredLengthFt: toNum(row.desiredLengthFt),
|
desiredLengthFt: toNum(row.desiredLengthFt),
|
||||||
desiredWidthFt: toNum(row.desiredWidthFt),
|
desiredWidthFt: toNum(row.desiredWidthFt),
|
||||||
desiredDraftFt: toNum(row.desiredDraftFt),
|
desiredDraftFt: toNum(row.desiredDraftFt),
|
||||||
portId: row.portId,
|
portId: row.portId,
|
||||||
|
dimensionsSource: 'interest',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export const createInterestSchema = z.object({
|
|||||||
desiredLengthUnit: desiredUnitSchema,
|
desiredLengthUnit: desiredUnitSchema,
|
||||||
desiredWidthUnit: desiredUnitSchema,
|
desiredWidthUnit: desiredUnitSchema,
|
||||||
desiredDraftUnit: 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 ──────────────────────────────────────────────────────────────────
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user