Skip to content
Open
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
4 changes: 2 additions & 2 deletions data/plans.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
"description": "Welcome! This is a sample practice plan — edit it, or create your own from the Plans tab.",
"items": [
{
"trackId": "demo-song",
"trackChoices": ["demo-song"],
"tasks": [{ "type": "playWithTrack", "count": 2 }],
"dice": false
},
{
"trackId": "a-major-scale",
"trackChoices": ["a-major-scale"],
"tasks": [{ "type": "playWithoutTrack", "count": 1 }],
"dice": true
}
Expand Down
15 changes: 8 additions & 7 deletions src/app/PracticeTasksList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import DiceRoller from "./DiceRoller";
import TaskList, { TaskTypeDef } from "./TaskList";
import TrackNote from "./TrackNote";
import type { PracticeItem, Track } from "./plans";
import { primaryTrackId, type PracticeItem, type Track } from "./plans";
import type { Student } from "./students";

interface Props {
Expand All @@ -19,11 +19,12 @@ export default function PracticeTasksList({ items, tracks, taskTypes, doneTasks,
return (
<ul className="space-y-4">
{items.map((item) => {
const track = tracks.find((t) => t.id === item.trackId);
const trackId = primaryTrackId(item);
const track = tracks.find((t) => t.id === trackId);
return (
<li key={item.trackId} className="rounded-lg border bg-white p-4">
<li key={trackId} className="rounded-lg border bg-white p-4">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">{track?.name ?? item.trackId}</span>
<span className="font-semibold">{track?.name ?? trackId}</span>
{track?.type === "video" && (
<span className="text-xs bg-zinc-100 text-zinc-500 rounded px-1.5 py-0.5">
video
Expand All @@ -36,7 +37,7 @@ export default function PracticeTasksList({ items, tracks, taskTypes, doneTasks,
</p>
)}
<TaskList
trackId={item.trackId}
trackId={trackId}
tasks={item.tasks}
taskTypes={taskTypes}
initialDone={doneTasks}
Expand All @@ -50,8 +51,8 @@ export default function PracticeTasksList({ items, tracks, taskTypes, doneTasks,
)}
{activeStudent.kind === "primary" && (
<TrackNote
trackId={item.trackId}
initialNote={notesByTrack[item.trackId] ?? ""}
trackId={trackId}
initialNote={notesByTrack[trackId] ?? ""}
/>
)}
</li>
Expand Down
40 changes: 38 additions & 2 deletions src/app/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,42 @@ describe("addTrack + getTrackBlobUrl", () => {
});
});

describe("getPlans — legacy trackId normalization", () => {
it("upgrades pre-#4 items that still carry a single trackId to trackChoices", async () => {
setupScheduleData({});
// Shape stored before #4: a bare trackId, no trackChoices.
setupPlansData([
{
id: "0",
createdDate: "2026-04-10",
items: [{ trackId: "twinkle", tasks: [{ type: "sing", count: 1 }], dice: false }],
checklist: [],
},
]);
const { getPlans } = await loadActions();
const item = (await getPlans())[0].items[0];
expect(item.trackChoices).toEqual(["twinkle"]);
expect("trackId" in item).toBe(false);
// Other fields are preserved through the upgrade.
expect(item.tasks).toEqual([{ type: "sing", count: 1 }]);
expect(item.dice).toBe(false);
});

it("leaves already-migrated trackChoices items untouched", async () => {
setupScheduleData({});
setupPlansData([
{
id: "0",
createdDate: "2026-04-10",
items: [{ trackChoices: ["a", "b"], tasks: [], dice: true }],
checklist: [],
},
]);
const { getPlans } = await loadActions();
expect((await getPlans())[0].items[0].trackChoices).toEqual(["a", "b"]);
});
});

describe("savePlan — inline reference tracks", () => {
it("persists new reference tracks to the catalog alongside the plan", async () => {
setupScheduleData({});
Expand All @@ -379,7 +415,7 @@ describe("savePlan — inline reference tracks", () => {
id: "0",
createdDate: "2026-04-10",
description: "with custom practice",
items: [{ trackId: "a-major-scale", tasks: [], dice: false }],
items: [{ trackChoices: ["a-major-scale"], tasks: [], dice: false }],
checklist: [],
},
[{ id: "a-major-scale", name: "A Major Scale", type: "reference", file: null }],
Expand All @@ -391,7 +427,7 @@ describe("savePlan — inline reference tracks", () => {
type: "reference",
file: null,
});
expect((await getPlans())[0].items[0].trackId).toBe("a-major-scale");
expect((await getPlans())[0].items[0].trackChoices).toEqual(["a-major-scale"]);
// Media-less => no blob entry written.
expect(JSON.parse(readFileSync(blobsFile, "utf-8"))).toEqual({
scale: "https://blob.example/scale.mp3",
Expand Down
19 changes: 18 additions & 1 deletion src/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,25 @@ function nextPlanId(plans: Plan[]): string {

// --- Public API ---

// Upgrade legacy practice items that still carry a single `trackId` (the shape
// stored in Redis before #4) to the `trackChoices` pool shape. Keeps prod data
// working without a manual Redis migration.
function normalizePlans(plans: Plan[]): Plan[] {
return plans.map((plan) => ({
...plan,
items: plan.items.map((item) => {
if (Array.isArray(item.trackChoices)) return item;
const legacy = item as unknown as { trackId?: string };
const { trackId, ...rest } = legacy;
return { ...(rest as object), trackChoices: trackId ? [trackId] : [] } as typeof item;
}),
Comment on lines +126 to +131
}));
}

export async function getPlans(): Promise<Plan[]> {
return readJson<Plan[]>(PLANS_KEY, PLANS_PATH, plansSeed as unknown as Plan[]);
return normalizePlans(
await readJson<Plan[]>(PLANS_KEY, PLANS_PATH, plansSeed as unknown as Plan[]),
);
}

function ensureTodayInSchedule(
Expand Down
15 changes: 14 additions & 1 deletion src/app/plans.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { TEST_PRIMARY as PRIMARY, TEST_LINKED as LINKED } from "./test-support/people";
import { primaryTrackId } from "./plans";

async function loadPlans() {
const mod = await import("./plans");
Expand All @@ -10,12 +11,24 @@ beforeEach(() => {
vi.resetModules();
});

describe("primaryTrackId", () => {
it("returns the only track for a single-element pool", () => {
expect(primaryTrackId({ trackChoices: ["twinkle"], tasks: [], dice: false })).toBe("twinkle");
});

it("returns the first track of a multi-track pool (pre-dice active track)", () => {
expect(
primaryTrackId({ trackChoices: ["twinkle", "minuet"], tasks: [], dice: false }),
).toBe("twinkle");
});
});

describe("getPlanForDate — happy path", () => {
it("returns the plan for a scheduled date", async () => {
vi.doMock("./actions", () => ({
getSchedule: async () => ({ "2026-04-07": "0" }),
getPlans: async () => [
{ id: "0", createdDate: "2026-04-07", items: [{ trackId: "x", tasks: [], dice: false }], checklist: [] },
{ id: "0", createdDate: "2026-04-07", items: [{ trackChoices: ["x"], tasks: [], dice: false }], checklist: [] },
],
}));
const { getPlanForDate } = await loadPlans();
Expand Down
9 changes: 8 additions & 1 deletion src/app/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ export type PlanTask = {
};

export type PracticeItem = {
trackId: string;
trackChoices: string[];
tasks: PlanTask[];
dice: boolean;
teacherNote?: string;
};

// The single track a practice item currently resolves to. Today an item always
// has exactly one active track (the first/only one in its pool). When the dice
// feature lands (#5), this is the seam that returns the rolled track instead.
export function primaryTrackId(item: PracticeItem): string {
return item.trackChoices[0];
}
Comment on lines +20 to +22

export type Plan = {
id: string;
createdDate: string;
Expand Down
2 changes: 1 addition & 1 deletion src/app/plans/NewPlanButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function NewPlanButton({
createdDate: today,
description: sourcePlan.description,
items: sourcePlan.items.map((it) => ({
trackId: it.trackId,
trackChoices: [...it.trackChoices],
tasks: it.tasks.map((t) => ({ ...t })),
dice: it.dice,
teacherNote: it.teacherNote,
Expand Down
20 changes: 11 additions & 9 deletions src/app/plans/PlanEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import type { Plan, Track } from "../plans";
import { primaryTrackId, type Plan, type Track } from "../plans";
import { savePlan } from "../actions";
import { validatePlan } from "./plan-validation";
import { nextAvailableTrackId } from "../track-id";
Expand Down Expand Up @@ -77,7 +77,9 @@ export default function PlanEditor({
if (picker?.mode === "replace") {
update({ items: replaceItemTrack(draft.items, picker.index, trackId) });
} else {
update({ items: [...draft.items, { trackId, tasks: [], dice: false }] });
update({
items: [...draft.items, { trackChoices: [trackId], tasks: [], dice: false }],
});
}
setPicker(null);
}
Expand Down Expand Up @@ -122,8 +124,8 @@ export default function PlanEditor({
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = draft.items.findIndex((i) => i.trackId === active.id);
const newIdx = draft.items.findIndex((i) => i.trackId === over.id);
const oldIdx = draft.items.findIndex((i) => primaryTrackId(i) === active.id);
const newIdx = draft.items.findIndex((i) => primaryTrackId(i) === over.id);
if (oldIdx === -1 || newIdx === -1) return;
const next = [...draft.items];
const [moved] = next.splice(oldIdx, 1);
Expand Down Expand Up @@ -165,16 +167,16 @@ export default function PlanEditor({
onDragEnd={handleDragEnd}
>
<SortableContext
items={draft.items.map((i) => i.trackId)}
items={draft.items.map((i) => primaryTrackId(i))}
strategy={verticalListSortingStrategy}
>
{draft.items.map((item, idx) => (
<PracticeItemRow
key={item.trackId}
key={primaryTrackId(item)}
item={item}
track={trackList.find((t) => t.id === item.trackId)}
track={trackList.find((t) => t.id === primaryTrackId(item))}
Comment on lines 173 to +177
disabled={disabled}
invalidTaskIndices={invalidByTrack.get(item.trackId) ?? new Set()}
invalidTaskIndices={invalidByTrack.get(primaryTrackId(item)) ?? new Set()}
onChange={(next) =>
update({
items: draft.items.map((it, i) => (i === idx ? next : it)),
Expand Down Expand Up @@ -235,7 +237,7 @@ export default function PlanEditor({
{picker && (
<TrackPicker
tracks={trackList}
inPlan={draft.items.map((i) => i.trackId)}
inPlan={draft.items.map((i) => primaryTrackId(i))}
heading={picker.mode === "replace" ? "Change track" : "Add to plan"}
onClose={() => setPicker(null)}
onPick={(t) => applyTrackToPlan(t.id)}
Expand Down
6 changes: 3 additions & 3 deletions src/app/plans/PracticeItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { PracticeItem, PlanTask, Track } from "../plans";
import { primaryTrackId, type PracticeItem, type PlanTask, type Track } from "../plans";
import TaskChip from "./TaskChip";
import TeacherNoteField from "./TeacherNoteField";
import { sortTasks } from "./task-order";
Expand Down Expand Up @@ -37,7 +37,7 @@ export default function PracticeItemRow({
transform,
transition,
isDragging,
} = useSortable({ id: item.trackId, disabled });
} = useSortable({ id: primaryTrackId(item), disabled });

const style = {
transform: CSS.Transform.toString(transform),
Expand All @@ -58,7 +58,7 @@ export default function PracticeItemRow({
</button>
<div className="font-semibold flex-1 flex items-center gap-2">
<span>{track?.name ?? item.trackId}</span>
<span>{track?.name ?? primaryTrackId(item)}</span>
{track && (
<span className="text-xs bg-zinc-100 text-zinc-500 rounded px-1.5 py-0.5 font-normal">
{track.type}
Expand Down
18 changes: 9 additions & 9 deletions src/app/plans/plan-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ function plan(items: Plan["items"]): Plan {
describe("validatePlan", () => {
it("returns no errors for a plan with only regular tasks", () => {
const p = plan([
{ trackId: "scale", tasks: [{ type: "playWithoutTrack", count: 1 }], dice: false },
{ trackChoices: ["scale"], tasks: [{ type: "playWithoutTrack", count: 1 }], dice: false },
]);
expect(validatePlan(p)).toEqual([]);
});

it("returns no errors for a plan with a valid focus task", () => {
const p = plan([
{
trackId: "may-song",
trackChoices: ["may-song"],
tasks: [
{ type: "playWithoutTrack", count: 1 },
{ type: "playWithoutTrack", count: 6, focus: "bars 5-8" },
Expand All @@ -31,7 +31,7 @@ describe("validatePlan", () => {
it("flags an empty focus string", () => {
const p = plan([
{
trackId: "x",
trackChoices: ["x"],
tasks: [{ type: "sing", count: 1, focus: "" }],
dice: false,
},
Expand All @@ -44,7 +44,7 @@ describe("validatePlan", () => {
it("flags a whitespace-only focus string", () => {
const p = plan([
{
trackId: "x",
trackChoices: ["x"],
tasks: [{ type: "sing", count: 1, focus: " " }],
dice: false,
},
Expand All @@ -57,7 +57,7 @@ describe("validatePlan", () => {
it("flags duplicate (type, focus) pairs on the same track — both offending indices", () => {
const p = plan([
{
trackId: "x",
trackChoices: ["x"],
tasks: [
{ type: "playWithoutTrack", count: 6, focus: "bars 5-8" },
{ type: "playWithoutTrack", count: 4, focus: "bars 5-8" },
Expand All @@ -74,7 +74,7 @@ describe("validatePlan", () => {
it("flags duplicate regular tasks of the same type on the same track", () => {
const p = plan([
{
trackId: "x",
trackChoices: ["x"],
tasks: [
{ type: "sing", count: 1 },
{ type: "sing", count: 2 },
Expand All @@ -91,7 +91,7 @@ describe("validatePlan", () => {
it("does not flag regular and focus tasks with the same type on the same track", () => {
const p = plan([
{
trackId: "x",
trackChoices: ["x"],
tasks: [
{ type: "playWithoutTrack", count: 1 },
{ type: "playWithoutTrack", count: 6, focus: "bars 5-8" },
Expand All @@ -104,8 +104,8 @@ describe("validatePlan", () => {

it("scopes duplicate detection per track — same (type, focus) across different tracks is fine", () => {
const p = plan([
{ trackId: "a", tasks: [{ type: "sing", count: 1 }], dice: false },
{ trackId: "b", tasks: [{ type: "sing", count: 1 }], dice: false },
{ trackChoices: ["a"], tasks: [{ type: "sing", count: 1 }], dice: false },
{ trackChoices: ["b"], tasks: [{ type: "sing", count: 1 }], dice: false },
]);
expect(validatePlan(p)).toEqual([]);
});
Expand Down
7 changes: 4 additions & 3 deletions src/app/plans/plan-validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Plan } from "../plans";
import { primaryTrackId, type Plan } from "../plans";

export type PlanValidationError = {
trackId: string;
Expand All @@ -9,9 +9,10 @@ export type PlanValidationError = {
export function validatePlan(plan: Plan): PlanValidationError[] {
const errors: PlanValidationError[] = [];
for (const item of plan.items) {
const trackId = primaryTrackId(item);
item.tasks.forEach((task, taskIndex) => {
if (task.focus !== undefined && task.focus.trim() === "") {
errors.push({ trackId: item.trackId, taskIndex, reason: "empty-focus" });
errors.push({ trackId, taskIndex, reason: "empty-focus" });
}
});

Expand All @@ -25,7 +26,7 @@ export function validatePlan(plan: Plan): PlanValidationError[] {
for (const indices of seen.values()) {
if (indices.length > 1) {
for (const taskIndex of indices) {
errors.push({ trackId: item.trackId, taskIndex, reason: "duplicate" });
errors.push({ trackId, taskIndex, reason: "duplicate" });
}
}
}
Expand Down
Loading