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 tests/outcomeConfig.test.ts tests/dailyWork.test.ts",
"test": "node --experimental-strip-types --test tests/sessionDate.test.ts tests/phaseGroups.test.ts tests/outcomeConfig.test.ts tests/dailyWork.test.ts tests/trainerSince.test.ts",
"preview": "vite preview",
"deploy": "wrangler deploy"
},
Expand Down
27 changes: 27 additions & 0 deletions shared/trainerSince.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const TRAINER_SINCE_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;

export function localCalendarMonth(date = new Date()): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
}

export function isTrainerSince(value: string, currentMonth = localCalendarMonth()): boolean {
return TRAINER_SINCE_PATTERN.test(value) && value <= currentMonth;
}

export function trainerSinceFromIso(value: string): string {
return value.slice(0, 7);
}

export function formatTrainerSince(value: string, locale = 'en-US'): string | null {
const match = TRAINER_SINCE_PATTERN.exec(value);
if (!match) return null;

const year = Number(match[1]);
const monthIndex = Number(match[2]) - 1;
return new Date(year, monthIndex, 1, 12).toLocaleDateString(locale, {
month: 'long',
year: 'numeric',
});
}
3 changes: 3 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export interface AccountResponse {
instructorId: string;
name: string;
profilePhotoUrl: string | null;
trainerSince: string;
createdAt: string;
}

Expand All @@ -123,11 +124,13 @@ export interface AccountUpdateResponse {
instructorId: string;
name: string;
profilePhotoUrl: string | null;
trainerSince: string;
}

export function updateAccount(patch: {
name?: string;
profilePhotoKey?: string | null;
trainerSince?: string;
}): Promise<AccountUpdateResponse> {
return request('/api/account', { method: 'PATCH', body: patch });
}
Expand Down
15 changes: 12 additions & 3 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useSyncExternalStore } from 'react';
import { trainerSinceFromIso } from '../../shared/trainerSince';
import * as api from './api';

export interface Session {
token: string;
instructorId: string;
name: string;
profilePhotoUrl: string | null;
trainerSince: string;
createdAt: string;
}

Expand All @@ -14,7 +16,11 @@ const SESSION_KEY = 'abbys-dog-chej:session';
function loadSession(): Session | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
return raw ? (JSON.parse(raw) as Session) : null;
if (!raw) return null;
const stored = JSON.parse(raw) as Session;
stored.trainerSince ||=
typeof stored.createdAt === 'string' ? trainerSinceFromIso(stored.createdAt) : '';
return stored;
} catch {
return null;
}
Expand Down Expand Up @@ -65,6 +71,7 @@ export async function login(name: string, passcode: string): Promise<void> {
instructorId: res.instructorId,
name: res.name,
profilePhotoUrl: res.profilePhotoUrl,
trainerSince: res.trainerSince,
createdAt: res.createdAt,
};
persistSession();
Expand All @@ -78,6 +85,7 @@ export async function createAccount(name: string, passcode: string): Promise<voi
instructorId: res.instructorId,
name: res.name,
profilePhotoUrl: res.profilePhotoUrl,
trainerSince: res.trainerSince,
createdAt: res.createdAt,
};
persistSession();
Expand All @@ -87,10 +95,11 @@ export async function createAccount(name: string, passcode: string): Promise<voi
export async function updateAccount(patch: {
name?: string;
profilePhotoKey?: string | null;
trainerSince?: string;
}): Promise<void> {
const res = await api.updateAccount(patch);
if (!session) return;
session = { ...session, name: res.name, profilePhotoUrl: res.profilePhotoUrl };
session = { ...session, name: res.name, profilePhotoUrl: res.profilePhotoUrl, trainerSince: res.trainerSince };
persistSession();
notify();
}
Expand All @@ -110,7 +119,7 @@ export async function refreshAccount(): Promise<void> {
// api.ts's 401 handler and the generation guard in store.ts's
// hydrateFromServer.
if (!session || session.token !== token) return;
session = { ...session, name: res.name, profilePhotoUrl: res.profilePhotoUrl };
session = { ...session, name: res.name, profilePhotoUrl: res.profilePhotoUrl, trainerSince: res.trainerSince };
persistSession();
notify();
}
Expand Down
62 changes: 61 additions & 1 deletion src/pages/AccountSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { localCalendarMonth } from '../../shared/trainerSince';
import { PhotoCropDialog } from '../components/PhotoCropDialog';
import { PencilIcon } from '../components/icons';
import { ApiError, uploadPhoto } from '../lib/api';
Expand All @@ -13,6 +14,13 @@ export function AccountSettings() {
const [savingName, setSavingName] = useState(false);
const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null);
const [photoError, setPhotoError] = useState<string | null>(null);
const [trainerSince, setTrainerSince] = useState('');
const [savingTrainerSince, setSavingTrainerSince] = useState(false);
const [trainerSinceError, setTrainerSinceError] = useState<string | null>(null);

useEffect(() => {
if (session) setTrainerSince(session.trainerSince);
}, [session]);

if (!session) return null;

Expand Down Expand Up @@ -50,6 +58,22 @@ export function AccountSettings() {
}
}

async function handleTrainerSinceSubmit(e: React.FormEvent) {
e.preventDefault();
if (!session || !trainerSince || trainerSince === session.trainerSince) return;
setSavingTrainerSince(true);
setTrainerSinceError(null);
try {
await updateAccount({ trainerSince });
} catch (err) {
setTrainerSinceError(
err instanceof ApiError ? err.message : "Couldn't save the trainer start date.",
);
} finally {
setSavingTrainerSince(false);
}
}

return (
<div className="max-w-lg mx-auto p-4 space-y-6">
<Link to="/" className="text-sm text-sky-500 hover:underline">
Expand Down Expand Up @@ -107,6 +131,42 @@ export function AccountSettings() {
</div>
</div>

<form
onSubmit={handleTrainerSinceSubmit}
className="space-y-3 rounded-xl border border-gray-200 p-4 dark:border-gray-700"
>
<div>
<label
htmlFor="trainer-since"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
Trainer since
</label>
<p className="mt-1 text-xs text-gray-500">
Used for your profile tenure. This can be different from when the account was created.
</p>
</div>
<div className="flex items-center gap-3">
<input
id="trainer-since"
type="month"
required
max={localCalendarMonth()}
value={trainerSince}
onChange={(e) => setTrainerSince(e.target.value)}
className="rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:text-gray-100"
/>
<button
type="submit"
disabled={savingTrainerSince || trainerSince === session.trainerSince}
className="rounded-md bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{savingTrainerSince ? 'Saving...' : 'Save'}
</button>
</div>
{trainerSinceError && <p className="text-xs text-red-500">{trainerSinceError}</p>}
</form>

{pendingPhotoFile && (
<PhotoCropDialog
file={pendingPhotoFile}
Expand Down
14 changes: 2 additions & 12 deletions src/pages/TrainerHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { calendarDateAtLocalNoon } from '../../shared/sessionDate';
import { formatTrainerSince } from '../../shared/trainerSince';
import { useState } from 'react';
import { DailyWorkBadge } from '../components/DailyWorkStatus';
import { dailyWorkSurfaceClass } from '../lib/dailyWork';
Expand Down Expand Up @@ -55,17 +56,6 @@ function formatLastWorked(dateIso: string | null): string {
return `Last worked ${calendarDateAtLocalNoon(dateIso).toLocaleDateString()}`;
}

// "Trainer since" is a cosmetic touch, not a system-of-record fact, so a
// missing or unparsable date (e.g. a session persisted in localStorage
// before this field existed) just quietly omits the line rather than
// showing "Invalid Date" or crashing the page.
function formatTrainerSince(createdAt: string | undefined): string | null {
if (!createdAt) return null;
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}

function SuccessRateCard({ rate }: { rate: SuccessRate }) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
Expand Down Expand Up @@ -133,7 +123,7 @@ export function TrainerHistory() {
const dailySessionCounts = useDailySessionCounts();
if (!session) return null;

const trainerSince = formatTrainerSince(session.createdAt);
const trainerSince = formatTrainerSince(session.trainerSince);
const activeSuccessRate = refinedRate ? stats.successRateRefined : stats.successRateOverall;

return (
Expand Down
32 changes: 32 additions & 0 deletions tests/trainerSince.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
formatTrainerSince,
isTrainerSince,
localCalendarMonth,
trainerSinceFromIso,
} from '../shared/trainerSince.ts';

test('trainer-since values use valid month precision', () => {
const currentMonth = '2026-07';
assert.equal(isTrainerSince('2026-07', currentMonth), true);
assert.equal(isTrainerSince('1999-12', currentMonth), true);
assert.equal(isTrainerSince('2026-08', currentMonth), false);
assert.equal(isTrainerSince('2026-00', currentMonth), false);
assert.equal(isTrainerSince('2026-13', currentMonth), false);
assert.equal(isTrainerSince('2026-1', currentMonth), false);
assert.equal(isTrainerSince('not-a-date', currentMonth), false);
});

test('the current local month is derived without a UTC boundary shift', () => {
assert.equal(localCalendarMonth(new Date(2026, 6, 31, 23, 59)), '2026-07');
});

test('account creation timestamps backfill to their calendar month', () => {
assert.equal(trainerSinceFromIso('2026-07-22T17:00:00.000Z'), '2026-07');
});

test('trainer-since months format without UTC boundary shifts', () => {
assert.equal(formatTrainerSince('2026-07'), 'July 2026');
assert.equal(formatTrainerSince('2026-13'), null);
});
15 changes: 15 additions & 0 deletions worker/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ CREATE TABLE IF NOT EXISTS instructor_data (
updated_at TEXT NOT NULL
);

-- Profile metadata lives separately from account creation so tenure can be
-- corrected without rewriting account audit timestamps. The INSERT backfills
-- existing instructors once and is safe to rerun on every deploy.
CREATE TABLE IF NOT EXISTS instructor_profiles (
instructor_id TEXT PRIMARY KEY REFERENCES instructors (id),
trainer_since TEXT NOT NULL CHECK (
length(trainer_since) = 7
AND substr(trainer_since, 5, 1) = '-'
AND substr(trainer_since, 6, 2) BETWEEN '01' AND '12'
)
);

INSERT OR IGNORE INTO instructor_profiles (instructor_id, trainer_since)
SELECT id, substr(created_at, 1, 7) FROM instructors;

-- The authoritative record of a pass-back transfer (#32/#34), written with a
-- single-row INSERT rather than living only inside two opaque blob columns.
-- Before this table existed, the transfer relation was hostage to whole-blob
Expand Down
Loading
Loading