diff --git a/package.json b/package.json index 055eb02..f05b864 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", - "test": "node --experimental-strip-types --test tests/sessionDate.test.ts tests/phaseGroups.test.ts", + "test": "node --experimental-strip-types --test tests/sessionDate.test.ts tests/phaseGroups.test.ts tests/outcomeConfig.test.ts", "preview": "vite preview", "deploy": "wrangler deploy" }, diff --git a/src/data/db.ts b/src/data/db.ts index d81c465..a95d795 100644 --- a/src/data/db.ts +++ b/src/data/db.ts @@ -14,6 +14,7 @@ import type { } from '../types'; import { buildDefaultChecklist } from './defaultChecklist'; import { buildDefaultMilestones } from './defaultMilestones'; +import { backfillAllowedOutcomes, dogHasTerminalFailure } from '../lib/outcomeConfig'; export interface Database { folders: Folder[]; @@ -100,6 +101,8 @@ function migrateLegacyMilestones(legacy: LegacyMilestone[]): { title: m.title, sortOrder: milestoneTemplates.length, isFinalOutcomeMilestone: false, + isTerminalOutcomeMilestone: false, + allowedOutcomes: backfillAllowedOutcomes(), repeatable: false, createdDate: m.createdDate, updatedDate: m.updatedDate, @@ -147,6 +150,7 @@ function backfillDogs(dogs: Dog[]): Dog[] { ...dog, released: dog.released ?? false, releasedDate: dog.releasedDate ?? null, + releasedByTerminalOutcome: dog.releasedByTerminalOutcome ?? false, graduated: dog.graduated ?? false, graduatedDate: dog.graduatedDate ? storedLocalCalendarDate(dog.graduatedDate) : null, excludedFromStats: dog.excludedFromStats ?? false, @@ -157,12 +161,20 @@ function backfillDogs(dogs: Dog[]): Dog[] { ); } -// Templates predating the final-outcome flag, or the repeatable flag (#33), -// won't have those stored. +// Templates predating configurable outcomes or the repeatable flag (#33) +// won't have those stored. An absent/invalid list preserves the legacy +// behavior by allowing every outcome. function backfillMilestoneTemplates(templates: MilestoneTemplate[]): MilestoneTemplate[] { - return templates.map((template) => ({ + return templates.map((template, index) => ({ ...template, isFinalOutcomeMilestone: template.isFinalOutcomeMilestone ?? false, + isTerminalOutcomeMilestone: + (template.isTerminalOutcomeMilestone ?? template.isFinalOutcomeMilestone ?? false) && + !templates.slice(0, index).some( + (earlier) => + earlier.isTerminalOutcomeMilestone ?? earlier.isFinalOutcomeMilestone ?? false, + ), + allowedOutcomes: backfillAllowedOutcomes(template.allowedOutcomes), repeatable: template.repeatable ?? false, })); } @@ -229,6 +241,14 @@ export function normalizeDatabase( database.dogMilestoneCompletions = backfillDogMilestoneCompletions( database.dogMilestoneCompletions ?? [], ); + database.dogs.forEach((dog) => { + if ( + dog.released && + dogHasTerminalFailure(dog.id, database.dogMilestoneCompletions, database.milestoneTemplates) + ) { + dog.releasedByTerminalOutcome = true; + } + }); // Accounts predating repeatable milestones (#33) won't have this field // at all — no migration needed here, since a milestone can only ever // have accumulated ledger rows after being flagged repeatable, which diff --git a/src/data/defaultMilestones.ts b/src/data/defaultMilestones.ts index 43f8b5d..fa14d76 100644 --- a/src/data/defaultMilestones.ts +++ b/src/data/defaultMilestones.ts @@ -1,4 +1,4 @@ -import type { MilestoneTemplate } from '../types'; +import { FINAL_OUTCOMES, type MilestoneTemplate } from '../types'; interface MilestoneSeed { phase: MilestoneTemplate['phase']; @@ -41,10 +41,12 @@ export function buildDefaultMilestones(): MilestoneTemplate[] { ...seed, // Abby's terminal Phase 4 evaluation — the one milestone whose result // decides whether a dog is placement-ready, needs additional - // objectives, or is released. See MilestoneTemplate.isFinalOutcomeMilestone. + // objectives, or is released. See MilestoneTemplate.isTerminalOutcomeMilestone. isFinalOutcomeMilestone: seed.title === 'Advanced Final Blindfold', + isTerminalOutcomeMilestone: seed.title === 'Advanced Final Blindfold', // #33 names these two as the milestones that need to be retakeable — // the final evaluation itself, and traffic training at any phase. + allowedOutcomes: [...FINAL_OUTCOMES], repeatable: seed.title === 'Advanced Final Blindfold' || seed.title.startsWith('Traffic Training'), createdDate: now, updatedDate: now, diff --git a/src/data/store.ts b/src/data/store.ts index bf0ee53..8eb871f 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -38,6 +38,13 @@ import { buildDefaultMilestones } from './defaultMilestones'; import { logError, logEvent } from '../lib/diagnostics'; import { ApiError, fetchData, putData, transferDog, uploadPhoto } from '../lib/api'; import { dataUrlToBlob } from '../lib/compressImage'; +import { + backfillAllowedOutcomes, + canonicalAllowedOutcomes, + countTerminalOutcomes, + dogHasTerminalFailure, + isMilestoneOutcomeAllowed, +} from '../lib/outcomeConfig'; let db: Database = emptyDatabase(); let currentInstructorId: string | null = null; @@ -649,6 +656,7 @@ export function createDog( graduationStatus: 'Not Started', released: false, releasedDate: null, + releasedByTerminalOutcome: false, graduated: false, graduatedDate: null, excludedFromStats: false, @@ -738,6 +746,7 @@ export function releaseDog(id: string): boolean { if (dog.graduated) return false; dog.released = true; dog.releasedDate = now(); + dog.releasedByTerminalOutcome = false; dog.updatedDate = now(); const persisted = notify(); logEvent('Dog released', id); @@ -749,6 +758,7 @@ export function reactivateDog(id: string): boolean { if (!dog) return false; dog.released = false; dog.releasedDate = null; + dog.releasedByTerminalOutcome = false; dog.updatedDate = now(); const persisted = notify(); logEvent('Dog reactivated', id); @@ -1259,6 +1269,8 @@ export function createMilestoneTemplate(phase: Phase, title: string): MilestoneT title, sortOrder: siblingCount, isFinalOutcomeMilestone: false, + isTerminalOutcomeMilestone: false, + allowedOutcomes: backfillAllowedOutcomes(), repeatable: false, createdDate: now(), updatedDate: now(), @@ -1278,15 +1290,41 @@ export function renameMilestoneTemplate(id: string, title: string): boolean { return notify(); } -// Marks (or unmarks) a milestone as the terminal evaluation whose result -// decides a dog's outcome — e.g. Abby's "Advanced Final Blindfold". Nothing -// stops more than one milestone carrying this at once; it's the trainer's -// own curriculum to configure, same as everything else in this file. +function reconcileTerminalOutcomeReleases(): void { + db.dogs.forEach((dog) => { + const shouldBeReleased = dogHasTerminalFailure( + dog.id, + db.dogMilestoneCompletions, + db.milestoneTemplates, + ); + if (shouldBeReleased && !dog.graduated) { + const wasReleased = dog.released; + dog.released = true; + dog.releasedDate ??= now(); + if (!wasReleased) dog.releasedByTerminalOutcome = true; + dog.updatedDate = now(); + } else if (dog.releasedByTerminalOutcome) { + dog.released = false; + dog.releasedDate = null; + dog.releasedByTerminalOutcome = false; + dog.updatedDate = now(); + } + }); +} + +// Enables a generic outcome prompt on any milestone. Terminal analytics and +// auto-release are configured separately, with at most one terminal prompt. export function toggleMilestoneFinalOutcomeFlag(id: string): boolean { const template = db.milestoneTemplates.find((m) => m.id === id); if (!template) return false; template.isFinalOutcomeMilestone = !template.isFinalOutcomeMilestone; template.updatedDate = now(); + if (template.isFinalOutcomeMilestone) { + template.allowedOutcomes = backfillAllowedOutcomes(template.allowedOutcomes); + } else if (template.isTerminalOutcomeMilestone) { + template.isTerminalOutcomeMilestone = false; + reconcileTerminalOutcomeReleases(); + } const persisted = notify(); logEvent( 'Milestone final-outcome flag toggled', @@ -1295,6 +1333,26 @@ export function toggleMilestoneFinalOutcomeFlag(id: string): boolean { return persisted; } +// Selects the one prompt that drives aggregate analytics and auto-release. +// Other prompted milestones remain generic outcome records. +export function toggleMilestoneTerminalOutcome(id: string): boolean { + const template = db.milestoneTemplates.find((m) => m.id === id); + if (!template?.isFinalOutcomeMilestone) return false; + const turningOn = !template.isTerminalOutcomeMilestone; + db.milestoneTemplates.forEach((milestone) => { + milestone.isTerminalOutcomeMilestone = false; + }); + template.isTerminalOutcomeMilestone = turningOn; + template.updatedDate = now(); + reconcileTerminalOutcomeReleases(); + const persisted = notify(); + logEvent( + 'Milestone terminal outcome toggled', + `${id} -> ${template.isTerminalOutcomeMilestone}`, + ); + return persisted; +} + // Marks (or unmarks) a milestone as repeatable (#33). Turning it on runs a // one-time, real migration: any dog that already had a decided outcome on // this milestone from before it was repeatable gets that decision preserved @@ -1305,6 +1363,23 @@ export function toggleMilestoneFinalOutcomeFlag(id: string): boolean { // those fall back to today's date and are flagged // migratedFromLegacyCompletion so the UI can say "date unknown (migrated)" // instead of presenting a fabricated date as fact. + +// Changes which choices are offered for future decisions. Existing dog +// completions and repeatable-attempt history are deliberately untouched. +export function setMilestoneAllowedOutcomes( + id: string, + outcomes: readonly FinalOutcome[], +): boolean { + const template = db.milestoneTemplates.find((m) => m.id === id); + const allowedOutcomes = canonicalAllowedOutcomes(outcomes); + if (!template || allowedOutcomes.length === 0) return false; + template.allowedOutcomes = allowedOutcomes; + template.updatedDate = now(); + const persisted = notify(); + logEvent('Milestone allowed outcomes updated', `${id} -> ${allowedOutcomes.join(', ')}`); + return persisted; +} + export function toggleMilestoneRepeatable(id: string): boolean { const template = db.milestoneTemplates.find((m) => m.id === id); if (!template) return false; @@ -1457,16 +1532,25 @@ function applyMilestoneOutcomeState( // release/reactivate in the caller's single atomic write/sync, and the // same "graduated dogs can't be released" guard still applies. const dog = db.dogs.find((d) => d.id === dogId); - if (outcome === 'Fail' && dog && !dog.graduated) { + const template = db.milestoneTemplates.find((m) => m.id === milestoneTemplateId); + if (outcome === 'Fail' && template?.isTerminalOutcomeMilestone && dog && !dog.graduated) { + const wasReleased = dog.released; dog.released = true; - dog.releasedDate = now(); + dog.releasedDate ??= now(); + if (!wasReleased) dog.releasedByTerminalOutcome = true; dog.updatedDate = now(); - } else if (previousOutcome === 'Fail' && outcome !== 'Fail' && dog && dog.released) { + } else if ( + previousOutcome === 'Fail' && + template?.isTerminalOutcomeMilestone && + dog?.releasedByTerminalOutcome && + !dogHasTerminalFailure(dogId, db.dogMilestoneCompletions, db.milestoneTemplates) + ) { // The release was a side effect of the prior Fail outcome — moving off // Fail (a correction, a new non-Fail attempt, or an undo) must undo it, // or the dog is left released while the UI shows a different outcome. dog.released = false; dog.releasedDate = null; + dog.releasedByTerminalOutcome = false; dog.updatedDate = now(); } } @@ -1484,6 +1568,9 @@ export function setMilestoneOutcome( milestoneTemplateId: string, outcome: FinalOutcome | null, ): boolean { + const template = db.milestoneTemplates.find((m) => m.id === milestoneTemplateId); + if (!template || template.repeatable) return false; + if (outcome !== null && !isMilestoneOutcomeAllowed(template, outcome)) return false; applyMilestoneOutcomeState(dogId, milestoneTemplateId, outcome); const persisted = notify(); logEvent( @@ -1504,6 +1591,8 @@ export function recordMilestoneOutcomeAttempt( outcome: FinalOutcome, notes: string | null = null, ): boolean { + const template = db.milestoneTemplates.find((m) => m.id === milestoneTemplateId); + if (!template?.repeatable || !isMilestoneOutcomeAllowed(template, outcome)) return false; db.milestoneOutcomeAttempts.push({ id: uid(), dogId, @@ -1693,25 +1782,22 @@ export function useTrainerHistoryStats(): TrainerHistoryStats { const successRateOverall = computeSuccessRate(dogs); const successRateRefined = computeSuccessRate(dogs.filter((d) => !d.excludedFromStats)); - // Only completions on milestones *currently* flagged isFinalOutcomeMilestone - // count — otherwise outcomes recorded against a since-unflagged milestone - // (e.g. the trainer re-pointed the flag at a different milestone) would - // keep polluting a bar the UI labels as "the" final-outcome milestone. - const finalOutcomeMilestoneIds = new Set( - milestoneTemplates.filter((m) => m.isFinalOutcomeMilestone).map((m) => m.id), + // Aggregate only the single explicitly designated terminal milestone. + // Generic prompted milestones remain visible on dog records but cannot + // double-count a dog in the final-outcome analytics. + const terminalOutcomeMilestoneIds = new Set( + milestoneTemplates.filter((m) => m.isTerminalOutcomeMilestone).map((m) => m.id), ); - const finalOutcomeCounts = dogMilestoneCompletions.reduce( - (acc, c) => { - if (!finalOutcomeMilestoneIds.has(c.milestoneTemplateId)) return acc; - if (c.outcome === 'Placement Ready') acc.placementReady += 1; - else if (c.outcome === 'Additional Objectives') acc.additionalObjectives += 1; - else if (c.outcome === 'Fail') acc.fail += 1; - return acc; - }, - { placementReady: 0, additionalObjectives: 0, fail: 0, total: 0 }, + const terminalCounts = countTerminalOutcomes( + dogMilestoneCompletions, + milestoneTemplates, ); - finalOutcomeCounts.total = - finalOutcomeCounts.placementReady + finalOutcomeCounts.additionalObjectives + finalOutcomeCounts.fail; + const finalOutcomeCounts: FinalOutcomeCounts = { + placementReady: terminalCounts['Placement Ready'], + additionalObjectives: terminalCounts['Additional Objectives'], + fail: terminalCounts.Fail, + total: terminalCounts['Placement Ready'] + terminalCounts['Additional Objectives'] + terminalCounts.Fail, + }; // Every historical attempt, not just the latest per dog (contrast with // finalOutcomeCounts above) — same milestone filter, different source @@ -1720,7 +1806,7 @@ export function useTrainerHistoryStats(): TrainerHistoryStats { const attemptDogIds = new Set(); const attemptCounts = milestoneOutcomeAttempts.reduce( (acc, a) => { - if (!finalOutcomeMilestoneIds.has(a.milestoneTemplateId)) return acc; + if (!terminalOutcomeMilestoneIds.has(a.milestoneTemplateId)) return acc; attemptDogIds.add(a.dogId); if (a.outcome === 'Placement Ready') acc.placementReady += 1; else if (a.outcome === 'Additional Objectives') acc.additionalObjectives += 1; diff --git a/src/lib/outcomeConfig.ts b/src/lib/outcomeConfig.ts new file mode 100644 index 0000000..3680288 --- /dev/null +++ b/src/lib/outcomeConfig.ts @@ -0,0 +1,68 @@ +import { FINAL_OUTCOMES, type FinalOutcome, type MilestoneTemplate } from '../types.ts'; + +export function canonicalAllowedOutcomes( + outcomes: readonly FinalOutcome[], +): FinalOutcome[] { + return FINAL_OUTCOMES.filter((outcome) => outcomes.includes(outcome)); +} + +export function backfillAllowedOutcomes( + outcomes?: readonly FinalOutcome[], +): FinalOutcome[] { + const canonical = canonicalAllowedOutcomes(outcomes ?? []); + return canonical.length > 0 ? canonical : [...FINAL_OUTCOMES]; +} + +export function isMilestoneOutcomeAllowed( + template: Pick, + outcome: FinalOutcome, +): boolean { + return template.isFinalOutcomeMilestone && template.allowedOutcomes.includes(outcome); +} + +export function terminalOutcomeMilestoneId( + templates: readonly Pick[], +): string | null { + return templates.find((template) => template.isTerminalOutcomeMilestone)?.id ?? null; +} + +interface OutcomeRecord { + dogId: string; + milestoneTemplateId: string; + outcome: FinalOutcome | null; +} + +export function countTerminalOutcomes( + records: readonly OutcomeRecord[], + templates: readonly Pick[], +): Record { + const terminalId = terminalOutcomeMilestoneId(templates); + const counts: Record = { + 'Placement Ready': 0, + 'Additional Objectives': 0, + Fail: 0, + }; + if (!terminalId) return counts; + + records.forEach((record) => { + if (record.milestoneTemplateId === terminalId && record.outcome) { + counts[record.outcome] += 1; + } + }); + return counts; +} + +export function dogHasTerminalFailure( + dogId: string, + completions: readonly OutcomeRecord[], + templates: readonly Pick[], +): boolean { + const terminalId = terminalOutcomeMilestoneId(templates); + if (!terminalId) return false; + return completions.some( + (completion) => + completion.dogId === dogId && + completion.milestoneTemplateId === terminalId && + completion.outcome === 'Fail', + ); +} diff --git a/src/pages/DogProfile.tsx b/src/pages/DogProfile.tsx index 2920058..5d0e215 100644 --- a/src/pages/DogProfile.tsx +++ b/src/pages/DogProfile.tsx @@ -5,7 +5,7 @@ import { localSessionDate, storedLocalCalendarDate, } from '../../shared/sessionDate'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { MoveDialog } from '../components/MoveDialog'; import { PencilIcon, TrashIcon } from '../components/icons'; @@ -51,7 +51,6 @@ import { } from '../data/store'; import { DISTRACTION_SEVERITIES, - FINAL_OUTCOMES, PHASES, type DistractionSeverity, type DistractionTemplate, @@ -275,7 +274,13 @@ function RepeatableMilestoneOutcome({ }) { const attempts = useMilestoneAttempts(dogId, milestone.id); const [recording, setRecording] = useState(false); - const [outcome, setOutcome] = useState('Placement Ready'); + const [outcome, setOutcome] = useState(milestone.allowedOutcomes[0] ?? 'Placement Ready'); + + useEffect(() => { + if (!milestone.allowedOutcomes.includes(outcome)) { + setOutcome(milestone.allowedOutcomes[0] ?? 'Placement Ready'); + } + }, [milestone.allowedOutcomes, outcome]); const [notes, setNotes] = useState(''); function handleRecord(e: React.FormEvent) { @@ -291,7 +296,7 @@ function RepeatableMilestoneOutcome({ recordMilestoneOutcomeAttempt(dogId, milestone.id, outcome, notes.trim() || null); setRecording(false); setNotes(''); - setOutcome('Placement Ready'); + setOutcome(milestone.allowedOutcomes[0] ?? 'Placement Ready'); } function handleUndo() { @@ -357,7 +362,7 @@ function RepeatableMilestoneOutcome({ onChange={(e) => setOutcome(e.target.value as FinalOutcome)} className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-transparent px-2 py-1 text-sm" > - {FINAL_OUTCOMES.map((o) => ( + {milestone.allowedOutcomes.map((o) => ( @@ -391,6 +396,32 @@ function RepeatableMilestoneOutcome({ ); } +function PreservedMilestoneAttemptHistory({ + dogId, + milestone, +}: { + dogId: string; + milestone: MilestoneTemplate; +}) { + const attempts = useMilestoneAttempts(dogId, milestone.id); + if (attempts.length === 0) return null; + + return ( +
    + {attempts.map((attempt) => ( +
  • + {OUTCOME_ICONS[attempt.outcome]} {attempt.outcome} —{' '} + {attempt.migratedFromLegacyCompletion + ? 'date unknown (migrated)' + : new Date(attempt.attemptDate).toLocaleDateString()} + {attempt.notes && <> — {attempt.notes}} +
  • + ))} +
+ ); +} + + export function DogProfile() { const { dogId } = useParams<{ dogId: string }>(); const navigate = useNavigate(); @@ -1034,7 +1065,12 @@ export function DogProfile() { className="rounded-md border border-gray-300 dark:border-gray-600 bg-transparent px-2 py-1" > - {FINAL_OUTCOMES.map((outcome) => ( + {completion?.outcome && !m.allowedOutcomes.includes(completion.outcome) && ( + + )} + {m.allowedOutcomes.map((outcome) => ( @@ -1046,6 +1082,10 @@ export function DogProfile() { )} + ); } @@ -1072,6 +1112,15 @@ export function DogProfile() { )} + {completion?.outcome && ( + + Preserved outcome: {OUTCOME_ICONS[completion.outcome]} {completion.outcome} + + )} + ); })} diff --git a/src/pages/ManageTemplates.tsx b/src/pages/ManageTemplates.tsx index 01766d1..ac254d1 100644 --- a/src/pages/ManageTemplates.tsx +++ b/src/pages/ManageTemplates.tsx @@ -14,13 +14,15 @@ import { reorderChecklistItems, reorderDistractionTemplates, reorderMilestoneTemplates, + setMilestoneAllowedOutcomes, toggleMilestoneFinalOutcomeFlag, toggleMilestoneRepeatable, + toggleMilestoneTerminalOutcome, useChecklistItems, useDistractionTemplates, useMilestoneTemplates, } from '../data/store'; -import { PHASES, type Phase } from '../types'; +import { FINAL_OUTCOMES, PHASES, type FinalOutcome, type Phase } from '../types'; export function ManageTemplates() { const [phase, setPhase] = useState('Phase 1'); @@ -117,6 +119,65 @@ export function ManageTemplates() { )} /> +
+ {milestones + .filter((item) => item.isFinalOutcomeMilestone) + .map((item) => ( +
+
+ + {item.title} outcome prompt + + + +
+
+ {FINAL_OUTCOMES.map((outcome: FinalOutcome) => { + const checked = item.allowedOutcomes.includes(outcome); + return ( + + ); + })} +
+

+ These choices apply to future prompts only. Existing dog outcomes and attempt history are preserved. +

+
+ ))} +
+

Flag one milestone (e.g. "Advanced Final Blindfold") as the final outcome — dog profiles get a Placement Ready / Additional Objectives / Fail picker for it instead of diff --git a/src/types.ts b/src/types.ts index b510919..f51d8c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,10 @@ export interface Dog { graduationStatus: GraduationStatus; released: boolean; releasedDate: string | null; + // Distinguishes a terminal-outcome side effect from a deliberate manual + // release so configuration/outcome reconciliation never undoes the latter. + releasedByTerminalOutcome: boolean; + // Distinct from a live graduationStatus of 'Graduated' reached by simply // completing everything that currently exists — this is the explicit, // deliberate "Mark Graduated" action (#31), and it freezes graduationProgress/ @@ -193,13 +197,19 @@ export interface MilestoneTemplate { phase: Phase; title: string; sortOrder: number; - // Marks this milestone as the terminal evaluation whose result decides a - // dog's outcome (e.g. Abby's "Advanced Final Blindfold") — at most one - // milestone typically carries this per curriculum, but nothing enforces - // that; it's the trainer's own curriculum to configure. A flagged - // milestone gets an outcome picker (Placement Ready / Additional - // Objectives / Fail) on the dog profile instead of a plain checkbox. + // Enables a generic outcome prompt for this milestone. Any number of + // milestones may collect outcomes; isTerminalOutcomeMilestone separately + // selects the single prompt used for aggregate analytics and auto-release. isFinalOutcomeMilestone: boolean; + // Outcomes offered for future decisions while the prompt is enabled. + // Existing completions and attempts may retain a value removed from this + // list; configuration changes never rewrite dog history. + allowedOutcomes: FinalOutcome[]; + // The single outcome prompt used for aggregate trainer analytics and + // automatic release behavior. Other milestones may still collect generic + // outcomes without being treated as the dog's terminal evaluation. + isTerminalOutcomeMilestone: boolean; + // Only meaningful alongside isFinalOutcomeMilestone (#33): most milestones // are a one-time decision, overwritten in place if corrected — a plain // checkbox/select. A repeatable one (the final test itself, traffic @@ -228,9 +238,9 @@ export interface DogMilestoneCompletion { notes: string | null; photo: string | null; // Only meaningful for a completion of a milestone flagged - // isFinalOutcomeMilestone. 'Fail' auto-releases the dog; the other two - // outcomes never have a side effect beyond recording the result and (for - // Placement Ready) completing the milestone itself. Always mirrors the + // isFinalOutcomeMilestone. 'Fail' auto-releases only when the template is + // also the terminal outcome milestone; generic prompts only record their + // result. Placement Ready completes the milestone. Always mirrors the // *latest* MilestoneOutcomeAttempt for a repeatable milestone (#33) — this // is deliberately still the single field every other reader (graduation // progress, Trainer History's stats) uses, so "current outcome" behavior diff --git a/tests/outcomeConfig.test.ts b/tests/outcomeConfig.test.ts new file mode 100644 index 0000000..54c0de4 --- /dev/null +++ b/tests/outcomeConfig.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + backfillAllowedOutcomes, + canonicalAllowedOutcomes, + countTerminalOutcomes, + dogHasTerminalFailure, + isMilestoneOutcomeAllowed, +} from '../src/lib/outcomeConfig.ts'; +import { FINAL_OUTCOMES, type MilestoneTemplate } from '../src/types.ts'; + +function milestone(overrides: Partial = {}): MilestoneTemplate { + return { + id: 'milestone-1', + phase: 'Phase 4', + title: 'Final evaluation', + sortOrder: 0, + isFinalOutcomeMilestone: true, + isTerminalOutcomeMilestone: true, + allowedOutcomes: [...FINAL_OUTCOMES], + repeatable: true, + createdDate: '2026-01-01T00:00:00.000Z', + updatedDate: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +test('legacy milestones default to every outcome', () => { + assert.deepEqual(backfillAllowedOutcomes(), FINAL_OUTCOMES); + assert.deepEqual(backfillAllowedOutcomes([]), FINAL_OUTCOMES); +}); + +test('configured outcomes are deduplicated and kept in canonical order', () => { + assert.deepEqual( + canonicalAllowedOutcomes(['Fail', 'Placement Ready', 'Fail']), + ['Placement Ready', 'Fail'], + ); +}); + +test('future outcomes require both an enabled prompt and an allowed choice', () => { + const configured = milestone({ allowedOutcomes: ['Additional Objectives', 'Fail'] }); + assert.equal(isMilestoneOutcomeAllowed(configured, 'Placement Ready'), false); + assert.equal(isMilestoneOutcomeAllowed(configured, 'Fail'), true); + assert.equal( + isMilestoneOutcomeAllowed( + { ...configured, isFinalOutcomeMilestone: false }, + 'Fail', + ), + false, + ); +}); + +test('changing the allowed list does not mutate recorded attempts', () => { + const attempts = [ + { outcome: 'Fail', notes: 'First evaluation' }, + { outcome: 'Placement Ready', notes: 'Passed retake' }, + ] as const; + const snapshot = structuredClone(attempts); + + canonicalAllowedOutcomes(['Additional Objectives']); + + assert.deepEqual(attempts, snapshot); +}); + +test('generic prompt outcomes do not affect terminal analytics or release', () => { + const terminal = milestone({ id: 'terminal' }); + const generic = milestone({ + id: 'generic', + isTerminalOutcomeMilestone: false, + }); + const records = [ + { dogId: 'dog-1', milestoneTemplateId: generic.id, outcome: 'Fail' as const }, + { + dogId: 'dog-1', + milestoneTemplateId: terminal.id, + outcome: 'Placement Ready' as const, + }, + ]; + + assert.deepEqual(countTerminalOutcomes(records, [generic, terminal]), { + 'Placement Ready': 1, + 'Additional Objectives': 0, + Fail: 0, + }); + assert.equal(dogHasTerminalFailure('dog-1', records, [generic, terminal]), false); + assert.equal( + dogHasTerminalFailure('dog-1', [{ ...records[1], outcome: 'Fail' }], [generic, terminal]), + true, + ); +});