From f42b4ec87c34535868f2043290b4ce8cae4cfc3e Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 26 May 2026 23:07:17 +0200 Subject: [PATCH] Auto-generate session name when user creates without typing one Click "Create session" with the input empty and a name of the form `YYMMDD-session-NNN` is generated automatically: today's date as two-digit year/month/day, then a zero-padded counter that starts at 000 and increments past the highest existing match for the same day. Added: - `computeNextDefaultName(existing, now?)` pure helper exported from `@engine/services/sessions`. - `SessionService.nextDefaultName(now?)` method that wraps it against the current repo. - Both create call sites (CreateFirstSession empty state + SessionMenu's New session form) fall back to `service.nextDefaultName()` when the trimmed input is empty. - 5 new unit tests covering today-only counting, max-not-count increment, and trimmed/wrong-shape filtering. Co-Authored-By: Claude Opus 4.7 --- src/app/sessions/CreateFirstSession.tsx | 4 ++- src/app/sessions/SessionMenu.tsx | 4 ++- src/engine/services/index.ts | 1 + src/engine/services/sessions.test.ts | 46 +++++++++++++++++++++++++ src/engine/services/sessions.ts | 36 +++++++++++++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/app/sessions/CreateFirstSession.tsx b/src/app/sessions/CreateFirstSession.tsx index 1c278ec..a66183d 100644 --- a/src/app/sessions/CreateFirstSession.tsx +++ b/src/app/sessions/CreateFirstSession.tsx @@ -33,7 +33,9 @@ export function CreateFirstSession() { const handleCreate = useCallback(() => { setError(null); try { - const created = service.create(name); + const trimmed = name.trim(); + const effective = trimmed.length === 0 ? service.nextDefaultName() : name; + const created = service.create(effective); navigateTo({ sessionId: created.id, mode: "review" }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); diff --git a/src/app/sessions/SessionMenu.tsx b/src/app/sessions/SessionMenu.tsx index 0eaa461..e7ee672 100644 --- a/src/app/sessions/SessionMenu.tsx +++ b/src/app/sessions/SessionMenu.tsx @@ -104,7 +104,9 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session const handleCreate = useCallback(() => { setError(null); try { - const created = service.create(newName); + const trimmed = newName.trim(); + const effective = trimmed.length === 0 ? service.nextDefaultName() : newName; + const created = service.create(effective); setNewName(""); setCreating(false); setOpen(false); diff --git a/src/engine/services/index.ts b/src/engine/services/index.ts index 6e09266..97be97f 100644 --- a/src/engine/services/index.ts +++ b/src/engine/services/index.ts @@ -16,6 +16,7 @@ export { ACTIVE_SESSION_KEY, attachSessionPersister, clearAllSessionData, + computeNextDefaultName, createSessionService, DuplicateSessionNameError, engineSnapshotKey, diff --git a/src/engine/services/sessions.test.ts b/src/engine/services/sessions.test.ts index 307ba79..adba4ad 100644 --- a/src/engine/services/sessions.test.ts +++ b/src/engine/services/sessions.test.ts @@ -11,6 +11,7 @@ import { import { ACTIVE_SESSION_KEY, attachSessionPersister, + computeNextDefaultName, createSessionService, DuplicateSessionNameError, engineSnapshotKey, @@ -50,6 +51,51 @@ describe("engineSnapshotKey", () => { }); }); +describe("computeNextDefaultName", () => { + const now = new Date(2026, 4, 27); // 2026-05-27 → "260527" + + it("returns 000 for the first session of the day", () => { + expect(computeNextDefaultName([], now)).toBe("260527-session-000"); + }); + + it("counts only sessions whose name matches today's prefix", () => { + const existing = [ + { name: "260527-session-000" } as Session, + { name: "260527-session-001" } as Session, + { name: "Random other name" } as Session, + { name: "260526-session-007" } as Session, // yesterday — ignored + ]; + expect(computeNextDefaultName(existing, now)).toBe("260527-session-002"); + }); + + it("increments past the highest, not the count", () => { + const existing = [ + { name: "260527-session-005" } as Session, + { name: "260527-session-002" } as Session, + ]; + expect(computeNextDefaultName(existing, now)).toBe("260527-session-006"); + }); + + it("ignores names with the wrong shape and accepts trimmed matches", () => { + const existing = [ + { name: " 260527-session-003 " } as Session, // whitespace OK + { name: "260527-session-12" } as Session, // wrong digit count + { name: "260527-sess-005" } as Session, // wrong infix + ]; + expect(computeNextDefaultName(existing, now)).toBe("260527-session-004"); + }); +}); + +describe("SessionService.nextDefaultName", () => { + it("delegates to computeNextDefaultName against the current repo", () => { + const s = freshService(); + const now = new Date(2026, 4, 27); + s.service.create({ name: "260527-session-000" }); + s.service.create({ name: "260527-session-001" }); + expect(s.service.nextDefaultName(now)).toBe("260527-session-002"); + }); +}); + describe("SessionService — lifecycle", () => { let s: ReturnType; beforeEach(() => { diff --git a/src/engine/services/sessions.ts b/src/engine/services/sessions.ts index aad625a..929608c 100644 --- a/src/engine/services/sessions.ts +++ b/src/engine/services/sessions.ts @@ -68,6 +68,12 @@ export interface SessionService { getActive(): SessionId | null; /** Record an "I just opened this" timestamp on the session. */ touch(id: SessionId): Session | null; + /** + * Suggest a default session name when the user hasn't typed one. + * Returns `YYMMDD-session-NNN` where `NNN` is the next free counter + * for today (starting at `000`). Pure: does not mutate the repo. + */ + nextDefaultName(now?: Date): string; } function nowIso(now?: string): string { @@ -189,9 +195,39 @@ export function createSessionService( if (!existing) return null; return repo.update({ ...existing, lastOpenedAt: nowIso() }); }, + nextDefaultName(now = new Date()) { + return computeNextDefaultName(repo.list(), now); + }, }; } +/** + * Pure helper exported for tests + callers that want to preview the + * name without going through the service instance. Format: + * `YYMMDD-session-NNN`. `NNN` increments only against today's + * existing sessions; tomorrow's counter starts fresh at `000`. + */ +export function computeNextDefaultName( + existing: readonly Session[], + now: Date = new Date(), +): string { + const yy = String(now.getFullYear() % 100).padStart(2, "0"); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const prefix = `${yy}${mm}${dd}-session-`; + const re = new RegExp(`^${prefix}(\\d{3})$`); + let max = -1; + for (const s of existing) { + const m = re.exec(s.name.trim()); + if (m) { + const n = parseInt(m[1]!, 10); + if (n > max) max = n; + } + } + const next = String(max + 1).padStart(3, "0"); + return `${prefix}${next}`; +} + // --------------------------------------------------------------------------- // Cross-session persistence (the session index + active-session pointer). // Per-session engine snapshots are handled by `attachPersister` against