From 8654273d200de5b3c3f4bd818c37bdcb5985b237 Mon Sep 17 00:00:00 2001
From: otmooper12
Date: Wed, 22 Jul 2026 10:02:57 -0400
Subject: [PATCH] Add editable graduation dates
---
shared/sessionDate.ts | 21 +++++-
src/data/db.ts | 4 +-
src/data/store.ts | 30 ++++++++-
src/pages/DogProfile.tsx | 121 ++++++++++++++++++++++++++++++++---
src/pages/TrainerHistory.tsx | 3 +-
src/types.ts | 2 +
tests/sessionDate.test.ts | 26 ++++++++
7 files changed, 190 insertions(+), 17 deletions(-)
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 && (
+
+ )}
{pendingPhotoFile && (
) : (