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)}
diff --git a/src/app/plans/PracticeItemRow.tsx b/src/app/plans/PracticeItemRow.tsx
index 79583ce..4139dd6 100644
--- a/src/app/plans/PracticeItemRow.tsx
+++ b/src/app/plans/PracticeItemRow.tsx
@@ -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";
@@ -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),
@@ -58,7 +58,7 @@ export default function PracticeItemRow({
⠿
- {track?.name ?? item.trackId}
+ {track?.name ?? primaryTrackId(item)}
{track && (
{track.type}
diff --git a/src/app/plans/plan-validation.test.ts b/src/app/plans/plan-validation.test.ts
index 566501d..b0342b1 100644
--- a/src/app/plans/plan-validation.test.ts
+++ b/src/app/plans/plan-validation.test.ts
@@ -9,7 +9,7 @@ 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([]);
});
@@ -17,7 +17,7 @@ describe("validatePlan", () => {
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" },
@@ -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,
},
@@ -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,
},
@@ -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" },
@@ -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 },
@@ -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" },
@@ -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([]);
});
diff --git a/src/app/plans/plan-validation.ts b/src/app/plans/plan-validation.ts
index ea1fb08..2869cc9 100644
--- a/src/app/plans/plan-validation.ts
+++ b/src/app/plans/plan-validation.ts
@@ -1,4 +1,4 @@
-import type { Plan } from "../plans";
+import { primaryTrackId, type Plan } from "../plans";
export type PlanValidationError = {
trackId: string;
@@ -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" });
}
});
@@ -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" });
}
}
}
diff --git a/src/app/plans/replace-item-track.test.ts b/src/app/plans/replace-item-track.test.ts
index 5e6993b..8115900 100644
--- a/src/app/plans/replace-item-track.test.ts
+++ b/src/app/plans/replace-item-track.test.ts
@@ -3,15 +3,15 @@ import type { PracticeItem } from "../plans";
import { replaceItemTrack } from "./replace-item-track";
const items: PracticeItem[] = [
- { trackId: "a", tasks: [{ type: "sing", count: 2 }], dice: true, teacherNote: "keep me" },
- { trackId: "b", tasks: [], dice: false },
+ { trackChoices: ["a"], tasks: [{ type: "sing", count: 2 }], dice: true, teacherNote: "keep me" },
+ { trackChoices: ["b"], tasks: [], dice: false },
];
describe("replaceItemTrack", () => {
- it("swaps the trackId at the index, preserving tasks/dice/teacherNote", () => {
+ it("swaps the trackChoices at the index, preserving tasks/dice/teacherNote", () => {
const next = replaceItemTrack(items, 0, "c");
expect(next[0]).toEqual({
- trackId: "c",
+ trackChoices: ["c"],
tasks: [{ type: "sing", count: 2 }],
dice: true,
teacherNote: "keep me",
@@ -25,7 +25,7 @@ describe("replaceItemTrack", () => {
it("does not mutate the input", () => {
const next = replaceItemTrack(items, 0, "c");
- expect(items[0].trackId).toBe("a");
+ expect(items[0].trackChoices).toEqual(["a"]);
expect(next).not.toBe(items);
});
});
diff --git a/src/app/plans/replace-item-track.ts b/src/app/plans/replace-item-track.ts
index aca2787..1b8a7b8 100644
--- a/src/app/plans/replace-item-track.ts
+++ b/src/app/plans/replace-item-track.ts
@@ -1,10 +1,13 @@
import type { PracticeItem } from "../plans";
// Swap the track on the practice item at `index`, preserving its tasks, dice, and teacher note.
+// Items hold a single-element pool until the dice feature (#5) adds multi-track editing.
export function replaceItemTrack(
items: PracticeItem[],
index: number,
trackId: string,
): PracticeItem[] {
- return items.map((item, i) => (i === index ? { ...item, trackId } : item));
+ return items.map((item, i) =>
+ i === index ? { ...item, trackChoices: [trackId] } : item,
+ );
}
diff --git a/src/app/seed-data.test.ts b/src/app/seed-data.test.ts
index 79b6b1c..db7101c 100644
--- a/src/app/seed-data.test.ts
+++ b/src/app/seed-data.test.ts
@@ -15,9 +15,11 @@ const scheduleMap = schedule as Record;
describe("committed seed data is internally consistent", () => {
it("every plan item references a track that exists", () => {
- for (const plan of plans as { id: string; items: { trackId: string }[] }[]) {
+ for (const plan of plans as { id: string; items: { trackChoices: string[] }[] }[]) {
for (const item of plan.items) {
- expect(trackIds.has(item.trackId), `plan ${plan.id} item trackId ${item.trackId}`).toBe(true);
+ for (const trackId of item.trackChoices) {
+ expect(trackIds.has(trackId), `plan ${plan.id} item trackId ${trackId}`).toBe(true);
+ }
}
}
});
diff --git a/src/app/teacher-data.test.ts b/src/app/teacher-data.test.ts
index c806f6b..b192bdf 100644
--- a/src/app/teacher-data.test.ts
+++ b/src/app/teacher-data.test.ts
@@ -124,7 +124,7 @@ describe("aggregateTeacherReport", () => {
checklist: [],
items: [
{
- trackId: "twinkle",
+ trackChoices: ["twinkle"],
dice: false,
tasks: [
{ type: "sing", count: 1 },
@@ -162,9 +162,9 @@ describe("aggregateTeacherReport", () => {
schedule: { "2026-04-15": "1", "2026-04-16": "2" },
plans: [
{ id: "1", createdDate: "2026-04-15", checklist: [], items: [
- { trackId: "twinkle", dice: false, tasks: [{ type: "sing", count: 3 }] } ] },
+ { trackChoices: ["twinkle"], dice: false, tasks: [{ type: "sing", count: 3 }] } ] },
{ id: "2", createdDate: "2026-04-16", checklist: [], items: [
- { trackId: "twinkle", dice: false, tasks: [{ type: "sing", count: 3 }] } ] },
+ { trackChoices: ["twinkle"], dice: false, tasks: [{ type: "sing", count: 3 }] } ] },
],
tracks,
doneByDate: { "2026-04-15": ["twinkle-sing-0", "twinkle-sing-1"] },
@@ -183,7 +183,7 @@ describe("aggregateTeacherReport", () => {
schedule: { "2026-04-15": "1" },
plans: [
{ id: "1", createdDate: "2026-04-15", checklist: [], items: [
- { trackId: "scale", dice: false, tasks: [{ type: "playWithoutTrack", count: 2 }] } ] },
+ { trackChoices: ["scale"], dice: false, tasks: [{ type: "playWithoutTrack", count: 2 }] } ] },
],
tracks,
doneByDate: {},
@@ -209,7 +209,7 @@ describe("aggregateTeacherReport", () => {
schedule: { "2026-04-15": "1" },
plans: [
{ id: "1", createdDate: "2026-04-15", checklist: [], items: [
- { trackId: "twinkle", dice: false, tasks: [
+ { trackChoices: ["twinkle"], dice: false, tasks: [
{ type: "playWithoutTrack", count: 2 },
{ type: "playWithoutTrack", count: 6, focus: "bars 5-8" },
] } ] },
@@ -233,7 +233,7 @@ describe("aggregateTeacherReport", () => {
schedule: { "2026-04-14": "1", "2026-04-15": "1", "2026-04-16": "1" },
plans: [
{ id: "1", createdDate: "2026-04-14", checklist: [], items: [
- { trackId: "twinkle", dice: false, tasks: [{ type: "sing", count: 1 }] } ] },
+ { trackChoices: ["twinkle"], dice: false, tasks: [{ type: "sing", count: 1 }] } ] },
],
tracks,
doneByDate: {},
diff --git a/src/app/teacher-data.ts b/src/app/teacher-data.ts
index 4394145..58a704c 100644
--- a/src/app/teacher-data.ts
+++ b/src/app/teacher-data.ts
@@ -1,5 +1,5 @@
import { isClassDay } from "./day-types";
-import type { Plan, PlanSchedule, Track } from "./plans";
+import { primaryTrackId, type Plan, type PlanSchedule, type Track } from "./plans";
import { doneCountForTask } from "./task-keys";
export type TeacherWindow = {
@@ -116,11 +116,12 @@ export function aggregateTeacherReport(input: AggregateInput): TrackReport[] {
if (plan === undefined) continue; // class/sick ids never match a plan id
for (const item of plan.items) {
- if (!targets.has(item.trackId)) {
- targets.set(item.trackId, new Map());
- order.push(item.trackId);
+ const trackId = primaryTrackId(item);
+ if (!targets.has(trackId)) {
+ targets.set(trackId, new Map());
+ order.push(trackId);
}
- const byType = targets.get(item.trackId)!;
+ const byType = targets.get(trackId)!;
for (const task of item.tasks) {
if (task.focus !== undefined) continue; // focus tasks excluded
byType.set(task.type, (byType.get(task.type) ?? 0) + task.count);
diff --git a/src/app/test-support/seed.ts b/src/app/test-support/seed.ts
index d5f4cd8..5425208 100644
--- a/src/app/test-support/seed.ts
+++ b/src/app/test-support/seed.ts
@@ -15,8 +15,8 @@ export const TEST_PLANS: Plan[] = [
id: "1",
createdDate: "2026-04-07",
items: [
- { trackId: "demo-a", tasks: [{ type: "playWithTrack", count: 2 }], dice: false },
- { trackId: "demo-b", tasks: [{ type: "playWithoutTrack", count: 1 }], dice: false },
+ { trackChoices: ["demo-a"], tasks: [{ type: "playWithTrack", count: 2 }], dice: false },
+ { trackChoices: ["demo-b"], tasks: [{ type: "playWithoutTrack", count: 1 }], dice: false },
],
checklist: [],
},