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
- 🎓 Graduated on {new Date(dog.graduatedDate).toLocaleDateString()} — progress is frozen -
+ {dog.graduated && ( ++ 🎓 Graduated + {dog.graduatedDate && ( + <> on {calendarDateAtLocalNoon(dog.graduatedDate).toLocaleDateString()}> + )} + {' — progress is frozen'} +
+ +{photoError}
} + {editingGraduationDate && ( + + )} {pendingPhotoFile && (