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 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:07:17 +02:00
parent 430c0e124c
commit f42b4ec87c
5 changed files with 89 additions and 2 deletions

View File

@@ -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));

View File

@@ -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);

View File

@@ -16,6 +16,7 @@ export {
ACTIVE_SESSION_KEY,
attachSessionPersister,
clearAllSessionData,
computeNextDefaultName,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,

View File

@@ -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<typeof freshService>;
beforeEach(() => {

View File

@@ -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