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
21 changes: 20 additions & 1 deletion shared/sessionDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/data/db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { legacySessionDate } from '../../shared/sessionDate';
import { legacySessionDate, storedLocalCalendarDate } from '../../shared/sessionDate';
import type {
Dog,
DogChecklistCompletion,
Expand Down Expand Up @@ -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 ?? [],
Expand Down
30 changes: 27 additions & 3 deletions src/data/store.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
121 changes: 111 additions & 10 deletions src/pages/DogProfile.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,6 +30,7 @@ import {
toggleDogExcludedFromStats,
toggleDogMilestoneCompletion,
toggleReportRedFlag,
updateDogGraduationDate,
transferDogToInstructor,
updateDog,
updateReport,
Expand Down Expand Up @@ -417,6 +424,9 @@ export function DogProfile() {
const [transferName, setTransferName] = useState('');
const [transferBusy, setTransferBusy] = useState(false);
const [transferError, setTransferError] = useState<string | null>(null);
const [editingGraduationDate, setEditingGraduationDate] = useState(false);
const [graduationDateDraft, setGraduationDateDraft] = useState(localSessionDate);
const [graduationDateError, setGraduationDateError] = useState<string | null>(null);

const reports = useMemo(() => {
return allReports.filter((r) => {
Expand Down Expand Up @@ -500,28 +510,63 @@ 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() {
if (!dog) return;
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) {
Expand Down Expand Up @@ -666,14 +711,70 @@ export function DogProfile() {
Released on {new Date(dog.releasedDate).toLocaleDateString()}
</p>
)}
{dog.graduated && dog.graduatedDate && (
<p className="text-xs text-emerald-600 dark:text-emerald-400">
🎓 Graduated on {new Date(dog.graduatedDate).toLocaleDateString()} — progress is frozen
</p>
{dog.graduated && (
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-emerald-600 dark:text-emerald-400">
🎓 Graduated
{dog.graduatedDate && (
<> on {calendarDateAtLocalNoon(dog.graduatedDate).toLocaleDateString()}</>
)}
{' — progress is frozen'}
</p>
<button
type="button"
onClick={openGraduationDateEditor}
className="text-xs text-sky-600 hover:underline dark:text-sky-400"
>
Edit date
</button>
</div>
)}
{photoError && <p className="text-xs text-red-500">{photoError}</p>}
</div>
</div>
{editingGraduationDate && (
<form
onSubmit={handleGraduationDateSubmit}
className="space-y-2 rounded-lg border border-emerald-200 bg-emerald-50/50 p-3 dark:border-emerald-900 dark:bg-emerald-950/20"
>
<label
htmlFor="graduation-date"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{dog.graduated ? 'Correct graduation date' : 'Graduation date'}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
id="graduation-date"
type="date"
value={graduationDateDraft}
onChange={(e) => 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"
/>
<button
type="submit"
className="min-h-10 rounded-md bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
{dog.graduated ? 'Save date' : 'Confirm Graduation'}
</button>
<button
type="button"
onClick={() => {
setEditingGraduationDate(false);
setGraduationDateError(null);
}}
className="min-h-10 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600"
>
Cancel
</button>
</div>
{graduationDateError && (
<p className="text-sm text-red-500">{graduationDateError}</p>
)}
</form>
)}
{pendingPhotoFile && (
<PhotoCropDialog
file={pendingPhotoFile}
Expand Down Expand Up @@ -723,7 +824,7 @@ export function DogProfile() {
</button>
) : (
<button
onClick={handleMarkGraduated}
onClick={openGraduationDateEditor}
disabled={dog.released}
title={dog.released ? 'Reactivate this dog first' : undefined}
className={`rounded-md border border-emerald-300 px-3 py-1.5 text-sm font-medium text-emerald-600 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950 ${
Expand Down
3 changes: 2 additions & 1 deletion src/pages/TrainerHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { calendarDateAtLocalNoon } from '../../shared/sessionDate';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Expand Down Expand Up @@ -250,7 +251,7 @@ export function TrainerHistory() {
<span className="font-medium text-gray-900 dark:text-gray-100">{dog.name}</span>
<span className="text-xs text-emerald-600 dark:text-emerald-400">
{dog.graduatedDate
? `Graduated ${new Date(dog.graduatedDate).toLocaleDateString()}`
? `Graduated ${calendarDateAtLocalNoon(dog.graduatedDate).toLocaleDateString()}`
: 'Graduated'}
</span>
</Link>
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface Dog {
// graduationStatus so later edits to the shared skill/milestone templates
// never retroactively change what a graduated dog displays.
graduated: boolean;
// Domain date for graduation (YYYY-MM-DD), separate from updatedDate's
// audit timestamp.
graduatedDate: string | null;
// Lets a trainer omit a specific dog (a pass-back, a health release, etc.)
// from the "refined" success-rate calculation on Trainer History without
Expand Down
26 changes: 26 additions & 0 deletions tests/sessionDate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
calendarDateAtLocalNoon,
isFutureSessionDate,
isValidCalendarDate,
legacySessionDate,
localSessionDate,
storedLocalCalendarDate,
} from '../shared/sessionDate.ts';

test('localSessionDate preserves the local calendar date near midnight', () => {
Expand Down Expand Up @@ -51,3 +54,26 @@ test('new-report future-date validation precedes photo and location side effects
assert.ok(validation < photoUpload);
assert.ok(validation < locationWrite);
});

test('stored lifecycle timestamps normalize to canonical date-only values', () => {
const lateLocalGraduation = new Date(2026, 6, 10, 23, 30).toISOString();
assert.equal(storedLocalCalendarDate(lateLocalGraduation), '2026-07-10');
assert.equal(
storedLocalCalendarDate('2026-07-10'),
'2026-07-10',
);
});

test('calendar date validation rejects malformed and impossible dates', () => {
assert.equal(isValidCalendarDate('2026-07-22'), true);
assert.equal(isValidCalendarDate('2024-02-29'), true);
assert.equal(isValidCalendarDate('2026-02-29'), false);
assert.equal(isValidCalendarDate('2026-7-2'), false);
});

test('date-only display uses local noon without shifting the calendar day', () => {
assert.equal(
localSessionDate(calendarDateAtLocalNoon('2026-07-10')),
'2026-07-10',
);
});
Loading