Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
26 changes: 23 additions & 3 deletions src/data/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}));
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/data/defaultMilestones.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MilestoneTemplate } from '../types';
import { FINAL_OUTCOMES, type MilestoneTemplate } from '../types';

interface MilestoneSeed {
phase: MilestoneTemplate['phase'];
Expand Down Expand Up @@ -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,
Expand Down
136 changes: 111 additions & 25 deletions src/data/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -649,6 +656,7 @@ export function createDog(
graduationStatus: 'Not Started',
released: false,
releasedDate: null,
releasedByTerminalOutcome: false,
graduated: false,
graduatedDate: null,
excludedFromStats: false,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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(),
Expand All @@ -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',
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
}
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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<FinalOutcomeCounts>(
(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
Expand All @@ -1720,7 +1806,7 @@ export function useTrainerHistoryStats(): TrainerHistoryStats {
const attemptDogIds = new Set<string>();
const attemptCounts = milestoneOutcomeAttempts.reduce<FinalOutcomeCounts>(
(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;
Expand Down
Loading
Loading