Skip to content

Workout Plan Builder โ€‹

A good training session is not just a list of exercises, it is an order. You warm up before you load the bar. You work the big compound lifts while you are fresh, then move to the smaller isolation work. You finish with a cooldown. Shuffle those blocks and the session changes, even though every field in it stayed exactly the same. The order is the plan.

The builder below is a single repeatable ordered choice: one metadata tree, one template component, no bespoke engine code behind it. Each row is a block (warm-up, strength exercise, cardio, or cooldown) with its own fields. Move a block up or down and the live summary on the right recomputes immediately, and the convention checks further down flag or clear a violation from the reorder alone, with no field ever edited.

Where these rules come from

The ordering conventions this page checks (warm up first, compound movements before isolation movements, no two high-intensity cardio blocks back to back, cool down last) follow common, published exercise-order guidance. A systematic review and meta-analysis on resistance exercise order backs the general compound-before-isolation convention. One deliberate, published exception to that rule, pre-exhaustion training, is named and cited further down.

Not training advice

This is a library demo, not a coach. The ordering conventions reflect common published convention, not a guarantee of a correct or safe program for any individual. Nothing you build here is saved anywhere: no account, no server, no history. It lives only in this tab's in-memory form state.

The plan, live โ€‹

Add a block of each type with the buttons above the list, then use the up and down arrows to rearrange the plan. Every block is a live occurrence of the choice, not a static row: add a second Cardio block and it gets its own fields with its own validation (plan.cardio[0].duration and plan.cardio[1].duration are separate fields with separate errors). The seeded plan below already satisfies all five ordering conventions described in the next section, and every one of them is exactly one reorder away from breaking, so there is always something worth trying.

Your plan

Performed top to bottom. Reorder blocks with the up and down controls.

7 of 12

The limit for Warm-up has been reached.

The limit for Cooldown / stretch has been reached.

1

๐Ÿ”ฅ Warm-up

min
2

๐Ÿšด Cardio

min
3

๐Ÿ‹๏ธ Strength exercise

4

๐Ÿšด Cardio

min
5

๐Ÿ‹๏ธ Strength exercise

6

๐Ÿ‹๏ธ Strength exercise

7

๐Ÿง˜ Cooldown / stretch

min
IsDirty: false
Touched: false
Valid: true

// form values:
{
  "plan": {
    "warmUp": [
      {
        "order": 1,
        "duration": "10"
      }
    ],
    "cardio": [
      {
        "order": 2,
        "kind": "rowing",
        "intensity": "high",
        "duration": "15"
      },
      {
        "order": 4,
        "kind": "jumpRope",
        "intensity": "high",
        "duration": "12"
      }
    ],
    "strength": [
      {
        "order": 3,
        "name": "Barbell back squat",
        "muscle": "legs",
        "type": "compound",
        "sets": "4",
        "reps": "6"
      },
      {
        "order": 5,
        "name": "Bench press",
        "muscle": "chest",
        "type": "compound",
        "sets": "4",
        "reps": "8"
      },
      {
        "order": 6,
        "name": "Seated leg curl",
        "muscle": "legs",
        "type": "isolation",
        "sets": "3",
        "reps": "12"
      }
    ],
    "cooldown": [
      {
        "order": 7,
        "duration": "8"
      }
    ]
  }
}
      

Nothing typed here leaves this page. There is no backend, no localStorage, and no saved plan history: reload the page and the seeded plan comes back exactly as it started. As above, the ordering rules below are common published convention, not personalized or medical advice for any one person's training.

The five ordering conventions โ€‹

The plan's summary rail checks five rules, every one of them about order rather than content:

  • Warm-up first. If the plan includes a Warm-up block, it must be the first block. A plan with no Warm-up at all is never flagged for it: the rule only fires when a Warm-up exists and sits somewhere other than first.
  • Cooldown last. If the plan includes a Cooldown / stretch block, it must be the last block, with the same logic as above: a plan with no Cooldown is never flagged for it either.
  • Compound before isolation. No compound strength exercise may come after an isolation strength exercise anywhere in the plan, not only in the row directly above or below it.
  • No back-to-back same muscle group. Looking only at the strength exercises in the plan (skipping over any warm-up, cardio, or cooldown block that happens to sit between them), two in a row must not target the same muscle group.
  • No adjacent high-intensity cardio. Two cardio blocks that are both high intensity must not sit directly next to each other.

Every rule above only ever looks at order, never at whether a block exists in the first place, so every one of them is fixable by reordering alone. None of them block saving the plan: a violation shows as an amber warning on the affected row and in the summary rail's convention-checks list, and the plan stays saveable regardless.

One honest exception

The compound-before-isolation rule has a well-known, deliberate exception: pre-exhaustion training, where an isolation exercise is performed first to fatigue a target muscle, immediately followed by a compound movement that recruits it alongside other muscle groups. It is a real, published technique, not an oversight this page is pretending not to notice; see this narrative review of pre-exhaustion training for the evidence behind it. If you are deliberately pre-exhausting a muscle, read the warning this page shows as informational, not as something that needs fixing.

One metadata tree, one template โ€‹

Everything above is declarative. The engine walks the plan metadata tree, classifies it as a repeatable ordered choice, and renders every occurrence through the one neutral template below it. Reordering, adding, removing, and every field's validation all come from that one tree; none of it is wired up by hand per block type.

Metadata walkthrough โ€‹

Since this page sets aside: false, there is no "On this page" outline. Use this short list instead:

The plan, a repeatable ordered choice โ€‹

The whole form is one node: maxOccurs > 1 combined with a choice, in explicit selection mode, rendered in add order with a persisted order the reorder control can write:

ts
name: 'plan',
type: 'block',
fieldOptions: { label: 'Your plan' },
description: 'Performed top to bottom. Reorder blocks with the up and down controls.',
minOccurs: 0,
maxOccurs: PLAN_MAX_OCCURS,
explicitChoiceSelection: true,
displayOrder: 'added',
preserveOrder: true,

Four block types, four shapes โ€‹

Each branch is a genuinely different block: two single-duration branches, a five-field strength branch, and a three-field cardio branch. All four declare children, which is what keeps preserveOrder active for the whole choice:

ts
choice: [
  {
    name: 'warmUp',
    type: 'block',
    maxOccursTotal: 1,
    fieldOptions: { label: 'Warm-up' },
    children: [
      { name: 'duration', type: 'number', fieldOptions: { label: 'Duration' }, suffix: 'min', restriction: POSITIVE_NUMBER_RESTRICTION },
    ],
  },
  {
    name: 'strength',
    type: 'block',
    fieldOptions: { label: 'Strength exercise' },
    children: [
      { name: 'name', type: 'text', colSpan: 'full', fieldOptions: { label: 'Exercise name' }, placeholder: 'e.g. Barbell back squat' },
      { name: 'muscle', type: 'select', fieldOptions: { label: 'Muscle group' }, options: MUSCLE_GROUPS, restriction: { enumeration: MUSCLE_GROUPS.map(option => option.key) } },
      { name: 'type', type: 'select', fieldOptions: { label: 'Movement type' }, options: MOVEMENT_TYPES, restriction: { enumeration: MOVEMENT_TYPES.map(option => option.key) } },
      { name: 'sets', type: 'number', fieldOptions: { label: 'Sets' }, restriction: POSITIVE_NUMBER_RESTRICTION },
      { name: 'reps', type: 'number', fieldOptions: { label: 'Reps' }, restriction: POSITIVE_NUMBER_RESTRICTION },
    ],
  },
  {
    name: 'cardio',
    type: 'block',
    fieldOptions: { label: 'Cardio' },
    children: [
      { name: 'kind', type: 'select', fieldOptions: { label: 'Type' }, options: CARDIO_TYPES, restriction: { enumeration: CARDIO_TYPES.map(option => option.key) } },
      { name: 'intensity', type: 'select', fieldOptions: { label: 'Intensity' }, options: INTENSITY_LEVELS, restriction: { enumeration: INTENSITY_LEVELS.map(option => option.key) } },
      { name: 'duration', type: 'number', fieldOptions: { label: 'Duration' }, suffix: 'min', restriction: POSITIVE_NUMBER_RESTRICTION },
    ],
  },
  {
    name: 'cooldown',
    type: 'block',
    maxOccursTotal: 1,
    fieldOptions: { label: 'Cooldown / stretch' },
    children: [
      { name: 'duration', type: 'number', fieldOptions: { label: 'Duration' }, suffix: 'min', restriction: POSITIVE_NUMBER_RESTRICTION },
    ],
  },
],

Wiring a block into its row โ€‹

The template routes every block, whatever its branch, through the same block-choice-array-item slot. The row component reads its own path and its parent choice's path straight off fieldMetadata, which is what lets one row component serve four differently shaped blocks:

template
<template #block-choice-array-item="{ fieldMetadata, branchKey, index, canRemoveItems, removeItem, globalIndex }">
  <WorkoutBlockRow
    :path="fieldMetadata.path ?? ''"
    :planPath="fieldMetadata.parent?.path ?? ''"
    :branches="(fieldMetadata.parent?.choice ?? []).map((x: any) => ({ name: x.name ?? '' }))"
    :branchKey
    :index
    :globalIndex
    :canRemove="canRemoveItems"
    @remove="removeItem"
  >
    <slot />
  </WorkoutBlockRow>
</template>

The reorder control โ€‹

Moving a block up or down writes the two affected occurrences' order values directly through useFormContext().setFieldValue, the same technique the checkout's split-payment ledger uses. The engine's own rendering picks up the new order and re-sorts with no remount:

ts
// A missing neighbor at order - 1 or order + 1 always means an actual boundary, never a gap,
// since the order sequence stays contiguous.
const upDisabled = computed(() => !blocks.value.some(block => block.order === myOrder.value - 1));
const downDisabled = computed(() => !blocks.value.some(block => block.order === myOrder.value + 1));

function moveUp() {
  const neighbor = blocks.value.find(block => block.order === myOrder.value - 1);
  if (!neighbor)
    return;
  setFieldValue(`${props.planPath}.${neighbor.key}.order`, myOrder.value, false);
  setFieldValue(`${props.path}.order`, myOrder.value - 1, false);
}

function moveDown() {
  const neighbor = blocks.value.find(block => block.order === myOrder.value + 1);
  if (!neighbor)
    return;
  setFieldValue(`${props.planPath}.${neighbor.key}.order`, myOrder.value, false);
  setFieldValue(`${props.path}.order`, myOrder.value + 1, false);
}

The live summary module โ€‹

A small, pure, synchronous module flattens every branch's occurrences into one ordered list and computes the total duration, the intensity profile, and the distinct muscle groups in play. It is read through vee-validate's own form context, not computedProps, so both the rail and each row can call it independently:

ts
/**
 * Flattens the plan choice's branch arrays (e.g. `{ warmUp: [...], strength: [...] }`) into one
 * list of blocks, ordered by each occurrence's persisted `order`. Every numeric leaf
 * (duration, sets, reps) is coerced here, once, since a number input always stores its value
 * as a string. `order` itself is left alone: it is engine-managed and already numeric.
 */
export function collectBlocks(planValues: unknown, branches: WorkoutBranchShape[]): Block[] {
  const container = planValues as Record<string, unknown[] | undefined> | undefined;
  const blocks: Block[] = [];
  for (const branch of branches) {
    const items = container?.[branch.name];
    if (!items)
      continue;
    items.forEach((item, index) => {
      const record = item as Record<string, unknown> | undefined;
      blocks.push({
        key: `${branch.name}[${index}]`,
        branchKey: branch.name,
        order: (record?.order as number | undefined) ?? index + 1,
        duration: toNumber(record?.duration),
        sets: toNumber(record?.sets),
        reps: toNumber(record?.reps),
        name: record?.name as string | undefined,
        muscle: record?.muscle as string | undefined,
        type: record?.type as string | undefined,
        kind: record?.kind as string | undefined,
        intensity: record?.intensity as IntensityLevel | undefined,
      });
    });
  }
  return blocks.sort((a, b) => a.order - b.order);
}

/**
 * Computes the rail's headline figures from an already-collected block list: total timed
 * duration, a per-branch intensity profile (count-only, except cardio which also carries its
 * own intensity word and dot sequence), and the distinct muscle groups in play.
 */
export function summarizePlan(blocks: Block[]): PlanSummary {
  const totalDuration = blocks.reduce((sum, block) => sum + (block.duration ?? 0), 0);

  const profile: IntensityProfileRow[] = [];
  for (const branchKey of PROFILE_ORDER) {
    const branchBlocks = blocks.filter(block => block.branchKey === branchKey);
    if (!branchBlocks.length)
      continue;

    if (branchKey === 'cardio') {
      const dots = branchBlocks.map(block => block.intensity ?? 'unset');
      const sharedIntensity = branchBlocks.every(block => block.intensity === branchBlocks[0].intensity)
        ? branchBlocks[0].intensity
        : undefined;
      profile.push({ branchKey, label: BRANCH_LABELS[branchKey], count: branchBlocks.length, intensity: sharedIntensity, dots });
    }
    else {
      profile.push({ branchKey, label: BRANCH_LABELS[branchKey], count: branchBlocks.length });
    }
  }

  const muscleGroups = [...new Set(
    blocks.filter(block => block.branchKey === 'strength' && block.muscle).map(block => block.muscle!),
  )];

  return { totalDuration, blockCount: blocks.length, profile, muscleGroups, violations: checkConventions(blocks) };
}

The convention checks โ€‹

The five rules described above live in one function, run against the same ordered block list the summary module builds:

ts
/**
 * Checks the five published ordering conventions against the plan's blocks, in their current
 * displayed order. Every rule is order-only: it never looks at whether a block is present, only
 * at where it sits relative to the others that are present. A block type that is missing
 * entirely never causes a violation, since there is nothing to put out of order.
 */
export function checkConventions(blocks: Block[]): Violation[] {
  const sorted = [...blocks].sort((a, b) => a.order - b.order);
  const violations: Violation[] = [];

  if (sorted.length && sorted.some(block => block.branchKey === 'warmUp') && sorted[0].branchKey !== 'warmUp') {
    violations.push({
      rule: 'warmUpFirst',
      label: RULE_LABELS.warmUpFirst,
      reason: 'A Warm-up block is in the plan but is not the first block.',
      positions: [sorted[0].order],
    });
  }

  if (sorted.length && sorted.some(block => block.branchKey === 'cooldown') && sorted[sorted.length - 1].branchKey !== 'cooldown') {
    violations.push({
      rule: 'cooldownLast',
      label: RULE_LABELS.cooldownLast,
      reason: 'A Cooldown / stretch block is in the plan but is not the last block.',
      positions: [sorted[sorted.length - 1].order],
    });
  }

  // A whole-list scan, not an adjacent-only check: once an isolation block has been seen, any
  // compound block later in the list is out of order, however far apart they sit.
  let seenIsolation = false;
  for (const block of sorted) {
    if (block.branchKey !== 'strength')
      continue;
    if (block.type === 'isolation') {
      seenIsolation = true;
    }
    else if (block.type === 'compound' && seenIsolation) {
      violations.push({
        rule: 'compoundBeforeIsolation',
        label: RULE_LABELS.compoundBeforeIsolation,
        reason: `"${describeBlock(block)}" (compound) follows an isolation exercise earlier in the plan.`,
        positions: [block.order],
      });
    }
  }

  // Reads the strength subsequence, not directly-adjacent rows: two strength blocks count as
  // consecutive here even when a cardio, warm-up, or cooldown block sits between them.
  const strengthBlocks = sorted.filter(block => block.branchKey === 'strength');
  for (let i = 1; i < strengthBlocks.length; i++) {
    const previous = strengthBlocks[i - 1];
    const current = strengthBlocks[i];
    if (previous.muscle && previous.muscle === current.muscle) {
      violations.push({
        rule: 'noBackToBackMuscle',
        label: RULE_LABELS.noBackToBackMuscle,
        reason: `"${describeBlock(previous)}" and "${describeBlock(current)}" are consecutive strength exercises that target the same muscle group.`,
        positions: [previous.order, current.order].sort((a, b) => a - b),
      });
    }
  }

  // Directly-adjacent rows only: cardio blocks separated by another block type never trigger
  // this rule, unlike the strength-subsequence rule above.
  for (let i = 1; i < sorted.length; i++) {
    const previous = sorted[i - 1];
    const current = sorted[i];
    if (previous.branchKey === 'cardio' && current.branchKey === 'cardio' && previous.intensity === 'high' && current.intensity === 'high') {
      violations.push({
        rule: 'noAdjacentHighCardio',
        label: RULE_LABELS.noAdjacentHighCardio,
        reason: `"${describeBlock(previous)}" and "${describeBlock(current)}" are adjacent cardio blocks both at high intensity.`,
        positions: [previous.order, current.order].sort((a, b) => a - b),
      });
    }
  }

  return violations;
}

What It Demonstrates โ€‹

  • A repeatable ordered choice (maxOccurs > 1 with explicitChoiceSelection, displayOrder: 'added', and preserveOrder) as the entire form, not a footnote behind a toggle
  • Four block types with genuinely different field shapes, all routed through one shared row component
  • Per-type occurrence caps via maxOccursTotal (at most one Warm-up, one Cooldown) alongside a shared choice budget
  • Position badges from globalIndex + 1, and up/down reordering that writes the affected order values directly
  • A live summary rail (duration, intensity profile, muscle coverage, convention checks) that recomputes on every add, edit, remove, or reorder
  • Five cross-row, order-only convention checks, each one satisfiable or violable by a reorder alone, with no field ever edited

How It Works โ€‹

The plan is a choice with maxOccurs > 1 and explicitChoiceSelection. Each Add button pushes a new occurrence into its own branch's field array; a number input passes its raw string value to handleChange instead of the native event, so the leaf stays a string in the form model, matching what a real <input type="number"> holds before anything parses it. Reordering rewrites two occurrences' order values and the engine re-sorts with no remount. The summary and the convention checks both come from one pure module, workoutPlanMath.ts, read through vee-validate's useFormContext rather than computedProps, so the rail and every row can call the same functions without duplicating logic. Submit is gated only on the form's aggregate meta.valid; the five conventions are non-blocking warnings and never affect it. No new engine primitive is involved anywhere on this page.

Released under the MIT License.