diff --git a/package.json b/package.json index 3b6a913..9fa6ac7 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/shared/trainerSince.ts b/shared/trainerSince.ts new file mode 100644 index 0000000..d474bd1 --- /dev/null +++ b/shared/trainerSince.ts @@ -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', + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 6930c2a..ce892ed 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -104,6 +104,7 @@ export interface AccountResponse { instructorId: string; name: string; profilePhotoUrl: string | null; + trainerSince: string; createdAt: string; } @@ -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 { return request('/api/account', { method: 'PATCH', body: patch }); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e30fda5..8c72454 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from 'react'; +import { trainerSinceFromIso } from '../../shared/trainerSince'; import * as api from './api'; export interface Session { @@ -6,6 +7,7 @@ export interface Session { instructorId: string; name: string; profilePhotoUrl: string | null; + trainerSince: string; createdAt: string; } @@ -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; } @@ -65,6 +71,7 @@ export async function login(name: string, passcode: string): Promise { instructorId: res.instructorId, name: res.name, profilePhotoUrl: res.profilePhotoUrl, + trainerSince: res.trainerSince, createdAt: res.createdAt, }; persistSession(); @@ -78,6 +85,7 @@ export async function createAccount(name: string, passcode: string): Promise { 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(); } @@ -110,7 +119,7 @@ export async function refreshAccount(): Promise { // 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(); } diff --git a/src/pages/AccountSettings.tsx b/src/pages/AccountSettings.tsx index 4757056..2f62b61 100644 --- a/src/pages/AccountSettings.tsx +++ b/src/pages/AccountSettings.tsx @@ -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'; @@ -13,6 +14,13 @@ export function AccountSettings() { const [savingName, setSavingName] = useState(false); const [pendingPhotoFile, setPendingPhotoFile] = useState(null); const [photoError, setPhotoError] = useState(null); + const [trainerSince, setTrainerSince] = useState(''); + const [savingTrainerSince, setSavingTrainerSince] = useState(false); + const [trainerSinceError, setTrainerSinceError] = useState(null); + + useEffect(() => { + if (session) setTrainerSince(session.trainerSince); + }, [session]); if (!session) return null; @@ -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 (
@@ -107,6 +131,42 @@ export function AccountSettings() {
+
+
+ +

+ Used for your profile tenure. This can be different from when the account was created. +

+
+
+ 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" + /> + +
+ {trainerSinceError &&

{trainerSinceError}

} +
+ {pendingPhotoFile && ( @@ -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 ( diff --git a/tests/trainerSince.test.ts b/tests/trainerSince.test.ts new file mode 100644 index 0000000..2782bad --- /dev/null +++ b/tests/trainerSince.test.ts @@ -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); +}); diff --git a/worker/schema.sql b/worker/schema.sql index f9fa0cc..9278dc4 100644 --- a/worker/schema.sql +++ b/worker/schema.sql @@ -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 diff --git a/worker/src/index.ts b/worker/src/index.ts index 89e97b1..9d947ac 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,4 +1,5 @@ import { legacySessionDate } from '../../shared/sessionDate'; +import { isTrainerSince, trainerSinceFromIso } from '../../shared/trainerSince'; import { generateId, generateToken, hashPasscode, sessionExpiry, verifyPasscode } from './auth'; import type { Env } from './types'; @@ -261,6 +262,9 @@ async function handleCreateInstructor(request: Request, env: Env): Promise { if (!name || !passcode) return errorResponse(request, env, 'name and passcode are required', 400); const instructor = await env.DB.prepare( - 'SELECT id, passcode_hash, passcode_salt, profile_photo_key, created_at FROM instructors WHERE name = ? COLLATE NOCASE', + `SELECT i.id, i.passcode_hash, i.passcode_salt, i.profile_photo_key, i.created_at, + COALESCE(p.trainer_since, substr(i.created_at, 1, 7)) AS trainer_since + FROM instructors i + LEFT JOIN instructor_profiles p ON p.instructor_id = i.id + WHERE i.name = ? COLLATE NOCASE`, ) .bind(name) .first<{ @@ -293,6 +309,7 @@ async function handleLogin(request: Request, env: Env): Promise { passcode_hash: string; passcode_salt: string; profile_photo_key: string | null; + trainer_since: string; created_at: string; }>(); if (!instructor) return errorResponse(request, env, 'Instructor not found', 404); @@ -316,6 +333,7 @@ async function handleLogin(request: Request, env: Env): Promise { instructorId: instructor.id, name, profilePhotoUrl: photoUrlForKey(request, instructor.profile_photo_key), + trainerSince: instructor.trainer_since, createdAt: instructor.created_at, }, 200, @@ -665,15 +683,26 @@ async function handleGetAccount(request: Request, env: Env): Promise { const auth = await requireAuth(request, env); if (auth instanceof Response) return auth; - const row = await env.DB.prepare('SELECT name, profile_photo_key FROM instructors WHERE id = ?') + const row = await env.DB.prepare( + `SELECT i.name, i.profile_photo_key, + COALESCE(p.trainer_since, substr(i.created_at, 1, 7)) AS trainer_since + FROM instructors i + LEFT JOIN instructor_profiles p ON p.instructor_id = i.id + WHERE i.id = ?`, + ) .bind(auth) - .first<{ name: string; profile_photo_key: string | null }>(); + .first<{ name: string; profile_photo_key: string | null; trainer_since: string }>(); if (!row) return errorResponse(request, env, 'Instructor not found', 404); return json( request, env, - { instructorId: auth, name: row.name, profilePhotoUrl: photoUrlForKey(request, row.profile_photo_key) }, + { + instructorId: auth, + name: row.name, + profilePhotoUrl: photoUrlForKey(request, row.profile_photo_key), + trainerSince: row.trainer_since, + }, 200, ); } @@ -682,7 +711,11 @@ async function handleUpdateAccount(request: Request, env: Env): Promise(); + const body = await request.json<{ + name?: string; + profilePhotoKey?: string | null; + trainerSince?: string; + }>(); const updates: string[] = []; const values: unknown[] = []; @@ -712,21 +745,49 @@ async function handleUpdateAccount(request: Request, env: Env): Promise 0) { + await env.DB.prepare(`UPDATE instructors SET ${updates.join(', ')} WHERE id = ?`) + .bind(...values, auth) + .run(); + } - const row = await env.DB.prepare('SELECT name, profile_photo_key FROM instructors WHERE id = ?') + if (body.trainerSince !== undefined) { + await env.DB.prepare( + `INSERT INTO instructor_profiles (instructor_id, trainer_since) VALUES (?, ?) + ON CONFLICT(instructor_id) DO UPDATE SET trainer_since = excluded.trainer_since`, + ) + .bind(auth, body.trainerSince) + .run(); + } + + const row = await env.DB.prepare( + `SELECT i.name, i.profile_photo_key, + COALESCE(p.trainer_since, substr(i.created_at, 1, 7)) AS trainer_since + FROM instructors i + LEFT JOIN instructor_profiles p ON p.instructor_id = i.id + WHERE i.id = ?`, + ) .bind(auth) - .first<{ name: string; profile_photo_key: string | null }>(); + .first<{ name: string; profile_photo_key: string | null; trainer_since: string }>(); if (!row) return errorResponse(request, env, 'Instructor not found', 404); return json( request, env, - { instructorId: auth, name: row.name, profilePhotoUrl: photoUrlForKey(request, row.profile_photo_key) }, + { + instructorId: auth, + name: row.name, + profilePhotoUrl: photoUrlForKey(request, row.profile_photo_key), + trainerSince: row.trainer_since, + }, 200, ); }