diff --git a/shared/sessionDate.ts b/shared/sessionDate.ts index 0361779..0a8579b 100644 --- a/shared/sessionDate.ts +++ b/shared/sessionDate.ts @@ -2,11 +2,30 @@ export function localSessionDate(date = new Date()): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } +export function storedDateOnly(value: string): string { + return value.slice(0, 10); +} + +export function storedLocalCalendarDate(value: string): string { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + return localSessionDate(new Date(value)); +} + +export function isValidCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + return localSessionDate(new Date(year, month - 1, day, 12)) === value; +} + +export function calendarDateAtLocalNoon(value: string): Date { + return new Date(`${storedDateOnly(value)}T12:00:00`); +} + // A legacy ISO timestamp cannot reveal the trainer's historical timezone. // UTC is therefore the only deterministic migration that both the browser // and Worker can reproduce for owner and shared/pass-back views. export function legacySessionDate(createdDate: string): string { - return createdDate.slice(0, 10); + return storedDateOnly(createdDate); } export function isFutureSessionDate( diff --git a/src/data/db.ts b/src/data/db.ts index 67648ee..d81c465 100644 --- a/src/data/db.ts +++ b/src/data/db.ts @@ -1,4 +1,4 @@ -import { legacySessionDate } from '../../shared/sessionDate'; +import { legacySessionDate, storedLocalCalendarDate } from '../../shared/sessionDate'; import type { Dog, DogChecklistCompletion, @@ -148,7 +148,7 @@ function backfillDogs(dogs: Dog[]): Dog[] { released: dog.released ?? false, releasedDate: dog.releasedDate ?? null, graduated: dog.graduated ?? false, - graduatedDate: dog.graduatedDate ?? null, + graduatedDate: dog.graduatedDate ? storedLocalCalendarDate(dog.graduatedDate) : null, excludedFromStats: dog.excludedFromStats ?? false, passBackSource: dog.passBackSource ?? null, passBackCopies: dog.passBackCopies ?? [], diff --git a/src/data/store.ts b/src/data/store.ts index 37ca315..bf0ee53 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -1,4 +1,8 @@ -import { localSessionDate } from '../../shared/sessionDate'; +import { + isFutureSessionDate, + isValidCalendarDate, + localSessionDate, +} from '../../shared/sessionDate'; import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import type { Dog, @@ -770,9 +774,13 @@ export function toggleDogExcludedFromStats(id: string): boolean { // the `dog.graduated` guard in refreshDogProgress/refreshAllDogsProgress), so // adding a new skill/milestone template later never makes an already- // graduated dog look incomplete again. -export function markDogGraduated(id: string): boolean { +export function markDogGraduated( + id: string, + graduationDate = localSessionDate(), +): boolean { const dog = db.dogs.find((d) => d.id === id); if (!dog) return false; + if (!isValidCalendarDate(graduationDate) || isFutureSessionDate(graduationDate)) return false; // Released and Graduated are mutually exclusive outcomes for a dog — a // released dog must be reactivated first (see releaseDog's own guard for // the reverse direction). @@ -825,7 +833,7 @@ export function markDogGraduated(id: string): boolean { }); dog.graduated = true; - dog.graduatedDate = completedAt; + dog.graduatedDate = graduationDate; dog.graduationStatus = 'Graduated'; dog.graduationProgress = 100; dog.updatedDate = completedAt; @@ -834,6 +842,22 @@ export function markDogGraduated(id: string): boolean { return persisted; } +export function updateDogGraduationDate(id: string, graduationDate: string): boolean { + const dog = db.dogs.find((d) => d.id === id); + if ( + !dog?.graduated || + !isValidCalendarDate(graduationDate) || + isFutureSessionDate(graduationDate) + ) { + return false; + } + dog.graduatedDate = graduationDate; + dog.updatedDate = now(); + const persisted = notify(); + logEvent('Dog graduation date updated', `${id} -> ${graduationDate}`); + return persisted; +} + // Cheap undo: only lifts the freeze and lets progress recompute live from // whatever's actually completed — it deliberately does not uncheck anything // markDogGraduated checked off, since "remove graduated status" and "undo diff --git a/src/pages/DogProfile.tsx b/src/pages/DogProfile.tsx index 25f94ca..2920058 100644 --- a/src/pages/DogProfile.tsx +++ b/src/pages/DogProfile.tsx @@ -1,4 +1,10 @@ -import { isFutureSessionDate, localSessionDate } from '../../shared/sessionDate'; +import { + calendarDateAtLocalNoon, + isFutureSessionDate, + isValidCalendarDate, + localSessionDate, + storedLocalCalendarDate, +} from '../../shared/sessionDate'; import { useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { MoveDialog } from '../components/MoveDialog'; @@ -24,6 +30,7 @@ import { toggleDogExcludedFromStats, toggleDogMilestoneCompletion, toggleReportRedFlag, + updateDogGraduationDate, transferDogToInstructor, updateDog, updateReport, @@ -417,6 +424,9 @@ export function DogProfile() { const [transferName, setTransferName] = useState(''); const [transferBusy, setTransferBusy] = useState(false); const [transferError, setTransferError] = useState(null); + const [editingGraduationDate, setEditingGraduationDate] = useState(false); + const [graduationDateDraft, setGraduationDateDraft] = useState(localSessionDate); + const [graduationDateError, setGraduationDateError] = useState(null); const reports = useMemo(() => { return allReports.filter((r) => { @@ -500,20 +510,53 @@ export function DogProfile() { reactivateDog(dog.id); } - function handleMarkGraduated() { + function openGraduationDateEditor() { if (!dog) return; if (dog.released) { alert(`Reactivate ${dog.name} before marking them Graduated.`); return; } + setGraduationDateDraft( + dog.graduatedDate ? storedLocalCalendarDate(dog.graduatedDate) : localSessionDate(), + ); + setGraduationDateError(null); + setEditingGraduationDate(true); + } + + function handleGraduationDateSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!dog) return; + setGraduationDateError(null); + if (!isValidCalendarDate(graduationDateDraft)) { + setGraduationDateError('Choose a valid graduation date.'); + return; + } + if (isFutureSessionDate(graduationDateDraft)) { + setGraduationDateError('Graduation dates cannot be in the future.'); + return; + } + if (dog.released && !dog.graduated) { + setGraduationDateError(`Reactivate ${dog.name} before marking them Graduated.`); + return; + } if ( + !dog.graduated && !confirm( - `Mark ${dog.name} as Graduated? This checks off every current skill and milestone and freezes their progress at 100%, even if the shared skill/milestone list changes later.`, + `Mark ${dog.name} as Graduated on ${calendarDateAtLocalNoon(graduationDateDraft).toLocaleDateString()}? This checks off every current skill and milestone and freezes progress at 100%.`, ) ) { return; } - markDogGraduated(dog.id); + const persisted = dog.graduated + ? updateDogGraduationDate(dog.id, graduationDateDraft) + : markDogGraduated(dog.id, graduationDateDraft); + if (!persisted) { + setGraduationDateError( + "This graduation date didn't save — your browser's storage is likely full.", + ); + return; + } + setEditingGraduationDate(false); } function handleRemoveGraduatedStatus() { @@ -521,7 +564,9 @@ export function DogProfile() { if (!confirm(`Remove ${dog.name}'s Graduated status? Their progress will recalculate live again.`)) { return; } - removeDogGraduatedStatus(dog.id); + if (removeDogGraduatedStatus(dog.id)) { + setEditingGraduationDate(false); + } } function handleTransferSubmit(e: React.FormEvent) { @@ -666,14 +711,70 @@ export function DogProfile() { Released on {new Date(dog.releasedDate).toLocaleDateString()}

)} - {dog.graduated && dog.graduatedDate && ( -

- 🎓 Graduated on {new Date(dog.graduatedDate).toLocaleDateString()} — progress is frozen -

+ {dog.graduated && ( +
+

+ 🎓 Graduated + {dog.graduatedDate && ( + <> on {calendarDateAtLocalNoon(dog.graduatedDate).toLocaleDateString()} + )} + {' — progress is frozen'} +

+ +
)} {photoError &&

{photoError}

} + {editingGraduationDate && ( +
+ +
+ setGraduationDateDraft(e.target.value)} + max={localSessionDate()} + required + className="min-h-10 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" + /> + + +
+ {graduationDateError && ( +

{graduationDateError}

+ )} +
+ )} {pendingPhotoFile && ( ) : (