Build a Vue Sprint Triage Board
Build a Vue sprint triage board with typed components, filters, and persistence.
Introduction
30 Second Summary
Sprint work rarely stays tidy for long. New issues arrive while priorities keep shifting.
In this project, you will build a local sprint triage board with Vue.js. You will map familiar React patterns to Vue's Composition API through each board interaction.
What You'll Build
You will demo the board in Safari as every card and summary responds instantly to your sprint triage decisions.
By the end of this project, you'll have:
- An interactive issue board where you can add prioritized work. You can move each card between open and resolved.
- Live summary cards that recalculate after each issue change. Two reactive filters update the visible cards immediately.
- Typed component communication that keeps form submissions and card status changes flowing to the board. A reusable composable keeps the board logic in one place.
- Secret Mission: Persist every issue change across browser reloads with watch and localStorage.
Do I need any experience before starting?
Advanced React experience is expected. You should feel comfortable with components and hooks.
Familiarity with JavaScript, TypeScript, and terminal workflows will help you focus on Vue's differences. Node.js and Visual Studio Code are already installed in the project environment.
Before We Start
Before the hands-on work begins, commit to the board you are building and the React concept you want to compare with Vue.js. This gives every new Vue pattern a clear reference point.
Set Up the Vue Workspace
Your React experience gives you useful instincts. Generated demo files can still hide which pieces actually boot a Vue.js application.
A minimal manual Vite setup keeps every file relevant to your React-to-Vue comparison. This step checks Node.js compatibility before building that clean foundation.
In this step, get ready to:
- Confirm that your installed Node.js version supports the project.
- Prepare a minimal Vite workspace with pinned dependencies.
- Run the placeholder application in Safari.
Confirm Node.js compatibility
The pinned Vite version depends on a compatible Node.js runtime. Checking this first prevents dependency failures from interrupting the workspace setup.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
Visual Studio Code is open. The next actions give the project a predictable home on your Desktop.
- Click File in the top menu bar.
- Select Open Folder.
- Choose your Desktop in the folder dialog.
- Select the new-folder control in the folder dialog.
- Enter vue-sprint-triage-board as the folder name.
- Confirm the folder creation.
- Select the vue-sprint-triage-board folder.
- Click Open.
Your empty project folder is now the active Visual Studio Code workspace. The integrated terminal opens directly inside this folder.
- Click View in the top menu bar.
- Select Terminal.
- Check the installed Node.js version by running this command:
node --version
What does this command show?
- The command prints the Node.js version available to this terminal.
- This project requires ^22.18.0 || >=24.12.0.
✔️ I see a compatible version
Your Node.js version satisfies ^22.18.0 || >=24.12.0. That compatibility check clears the path for the pinned dependencies.
ⓧ I see an older version
The installed version cannot support this project configuration. Update Node.js before installing the dependencies.
- Visit the official Node.js download page.
- Download the v24.21.0 LTS installer for macOS.
- Run the downloaded installer.
- Close the existing integrated terminal session in Visual Studio Code.
- Create a new integrated terminal from the View menu.
- Repeat the compatibility check by running:
node --version
What should the new terminal confirm?
The new terminal should print a version that satisfies ^22.18.0 || >=24.12.0.
Still seeing the older version?
Close every open Visual Studio Code window before reopening the project. This gives the application a fresh environment after the Node.js installer finishes.
If the older version remains active, help me check which Node.js installation my Visual Studio Code terminal is using.
ⓧ Command not found
The terminal cannot find a Node.js installation. Installing the current LTS release supplies the runtime required by Vite.
- Visit the official Node.js download page.
- Download the v24.21.0 LTS installer for macOS.
- Run the downloaded installer.
- Close the existing integrated terminal session in Visual Studio Code.
- Create a new integrated terminal from the View menu.
- Confirm that Node.js is available by running:
node --version
What confirms the installation?
A version satisfying ^22.18.0 || >=24.12.0 confirms that the runtime is ready.
Command still unavailable?
Restart Visual Studio Code after the installer completes. An older terminal session may not recognize the new installation.
If the command remains unavailable, help me make Node.js available in my Visual Studio Code terminal.
Assemble the minimal project
A Vue Single-File Component keeps a component's template and behavior in one file. This project starts with one small component plus the files that load it.
The dependency manifest comes first because it defines the exact Vue.js, Vite, and TypeScript versions used by every later step.
- Select the file-creation control at the top of the Explorer sidebar.
- Enter package.json as the file name.
- Define the project scripts and pinned dependencies by pasting this into package.json:
{
"name": "vue-sprint-triage-board",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "3.5.43"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.9",
"typescript": "7.0.2",
"vite": "8.3.3"
}
}
What does this file control?
- The scripts object exposes the development and production commands.
- The dependencies object pins Vue.js at 3.5.43.
- The Vue plugin is pinned at 6.0.9.
- TypeScript is pinned at 7.0.2.
- Vite is pinned at 8.3.3.
- Save package.json by pressing Cmd+S on macOS or Ctrl+S on Windows.
- Install the pinned project dependencies by running:
npm install
What does this command install?
The command reads package.json and downloads its declared packages into node_modules.
- Confirm that the Explorer sidebar now lists a node_modules folder.
The dependency installation is complete. Your workspace now has the exact toolchain required by the remaining project files.
Dependency installation failed?
Check that package.json matches the code above. A missing quote or comma prevents npm from reading the file.
If the failure mentions compatibility, repeat the Node.js version check from the previous substep.
For another installation failure, help me diagnose my npm install output for this Vue project.
Vite needs a configuration file that enables Vue Single-File Component compilation. This connects the development server to the Vue plugin.
- Select the file-creation control at the top of the Explorer sidebar.
- Enter vite.config.ts as the file name.
- Enable Vue compilation by pasting this into vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
What does this configuration do?
- The defineConfig helper provides the Vite configuration structure.
- The Vue plugin teaches Vite how to process .vue files.
- Save vite.config.ts.
- Confirm that vite.config.ts appears beside package.json in the Explorer sidebar.
Configuration file not appearing?
Check that you created vite.config.ts inside the vue-sprint-triage-board folder. It should sit beside package.json.
If the imports look unresolved after installation, help me check my Vite configuration and installed packages.
Vite treats index.html as the browser entry point. Its mount element gives Vue a specific place to render the application.
- Select the file-creation control at the top of the Explorer sidebar.
- Enter index.html as the file name.
- Create the browser entry point by pasting this into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue Sprint Triage Board</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
How does the page load Vue?
- The app element becomes Vue's mount target.
- The module script loads src/main.ts as the application entry file.
- The browser tab uses Vue Sprint Triage Board as its title.
- Save index.html.
- Confirm that index.html appears beside package.json in the Explorer sidebar.
Entry page in the wrong location?
Move index.html directly into vue-sprint-triage-board if it appears inside another folder. Vite expects this file beside package.json.
If the file structure is unclear, help me place my Vite entry files in the correct folders.
The src folder holds the application bootstrap and the first component. Creating it separates browser setup files from Vue source code.
- Select the folder-creation control at the top of the Explorer sidebar.
- Create the source folder by entering src.
The Explorer sidebar now lists src beneath the top-level project files.
- Select the src folder in the Explorer sidebar.
- Select the file-creation control at the top of the Explorer sidebar.
- Enter main.ts as the file name.
- Bootstrap the Vue application by pasting this into src/main.ts:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
What does the bootstrap file do?
- The createApp call creates a Vue application from App.vue.
- The mount call attaches that application to the app element in index.html.
- Save src/main.ts.
- Confirm that main.ts appears inside src in the Explorer sidebar.
Bootstrap file not inside src?
Move main.ts into src if it appears beside package.json.
If the import path remains unresolved, help me check the location and import path for my Vue bootstrap file.
The root component supplies the first visible output. A single heading is enough to prove that the browser entry point and Vue bootstrap are connected.
- Select the src folder in the Explorer sidebar.
- Select the file-creation control at the top of the Explorer sidebar.
- Enter App.vue as the file name.
- Create the placeholder component by pasting this into src/App.vue:
<template>
<main>
<h1>Vue Sprint Triage Board</h1>
</main>
</template>
What does this component render?
The component template renders a page heading with the project name. This small output gives the workspace a concrete browser check.
- Save src/App.vue.
- Confirm that App.vue appears beside main.ts inside src.
Component file missing from src?
Check that the file is named App.vue with a capital A. Its name must match the import in main.ts.
If the files still do not connect, help me compare my App.vue filename with the import in main.ts.
Your five project files should now match this checkpoint. Use the second tab if you want to compare every line before starting the server.
✔️ Awesome, I've got everything!
Great. Double-check that every file is saved before moving to the browser verification.
ⓧ I'd like to double check the full code
Compare each file below with the matching file in your Explorer sidebar.
{
"name": "vue-sprint-triage-board",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "3.5.43"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.9",
"typescript": "7.0.2",
"vite": "8.3.3"
}
}
What should match in package.json?
Check the project name, both scripts, and every pinned dependency version.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
What should match in vite.config.ts?
Check both imports and the Vue plugin entry inside the configuration.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue Sprint Triage Board</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
What should match in index.html?
Check the page title, the app mount element, and the /src/main.ts script path.
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
What should match in main.ts?
Check both imports and the mount call that targets app.
<template>
<main>
<h1>Vue Sprint Triage Board</h1>
</main>
</template>
What should match in App.vue?
Check the template structure and the exact Vue Sprint Triage Board heading.
Verify the local workspace
The Vite development server turns the project files into a page Safari can load. It keeps the integrated terminal occupied while the application is running.
Before you run the server, which title do you expect Safari to show?
- Start the Vite development server by running:
npm run dev
What does this command start?
- The command runs the dev script from package.json.
- Vite prints a local development URL while the server remains active.
- Copy the local development URL shown in the terminal: your local Vite development URL.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Safari into the search field.
- Press Enter to open Safari.
- Select Safari's address bar.
- Paste your local Vite development URL into the address bar.
- Press Enter to load the application.
You'll see Vue Sprint Triage Board as the page heading. That clean placeholder proves your browser entry point and Vue component are connected.
Placeholder not loading?
Keep the integrated terminal open with the Vite server running. Closing that process makes the local development URL unavailable.
Check that Safari uses the exact URL printed by the current terminal session.
If Safari still cannot show the heading, help me trace my Vite page from index.html to main.ts and App.vue.
Your clean Vue workspace is running locally. Next, you'll replace the placeholder with a reactive issue board and expose a deliberately stale summary.
Build a Reactive Board
Your minimal Vite app is live in Safari. The placeholder proves your development loop works.
The board now needs issue data that responds to status changes. Its summary also needs to agree with the cards after each change.
You will render a small issue list from Vue.js reactive state. You will then mutate one issue to test whether both displays stay synchronized.
In this step, get ready to:
- Model three issues with typed reactive state.
- Render every issue with a stable key and its current status.
- Test whether the displayed open count stays synchronized after a status mutation.
Model the issue state
A Vue Single-File Component can hold TypeScript logic above its template. The ref function makes the issue array reactive.
- In the Explorer sidebar in Visual Studio Code, select src/App.vue.
- Place your cursor above the existing <template> block.
- Add the typed issue state by copying this code:
<script setup lang="ts">
import { ref } from 'vue'
type Issue = {
id: number
title: string
status: 'open' | 'resolved'
}
const issues = ref<Issue[]>([
{ id: 1, title: 'Clarify empty-state copy', status: 'open' },
{ id: 2, title: 'Fix keyboard focus ring', status: 'open' },
{ id: 3, title: 'Archive completed spike', status: 'resolved' },
])
const openCount = ref(issues.value.filter((issue) => issue.status === 'open').length)
function resolveFirstIssue() {
issues.value[0].status = 'resolved'
}
</script>
What Does This State Do?
- The Issue type limits every status to 'open' or 'resolved'.
- The issues ref holds the three typed issue objects that drive the board.
- The openCount ref initializes from the number of currently open issues.
- The resolveFirstIssue() function changes the first issue's status inside the reactive array.
- Save src/App.vue.
- Return to Safari.
You should still see the Vue Sprint Triage Board placeholder. Safari should show no error overlay.
Seeing a Blank Page?
Check that the new <script setup lang="ts"> block sits above the existing template. Confirm that every brace and quote matches the code block.
Ask for help with the script structure.
Render the issue board
Vue template directives connect reactive values to visible markup. The v-for directive creates one card for each issue.
- In src/App.vue, find the existing <template> block.
- Select the entire block from <template> through </template>.
- Replace the selected block by copying this template:
<template>
<main>
<h1>Vue Sprint Triage Board</h1>
<p>Open issues: {{ openCount }}</p>
<button type="button" @click="resolveFirstIssue">Resolve first issue</button>
<article v-for="issue in issues" :key="issue.id" :class="{ resolved: issue.status === 'resolved' }">
<h2>{{ issue.title }}</h2>
<p>Status: {{ issue.status }}</p>
</article>
</main>
</template>
How Does the Template Work?
- The {{ openCount }} interpolation displays the summary value from the script.
- The v-for directive repeats the article element for every item in issues.
- The :key binding gives each rendered card its primitive issue.id value.
- The button's @click listener calls resolveFirstIssue().
- Save src/App.vue.
- Return to the running board in Safari.
You should see three issue cards beneath the button. The summary should display Open issues: 2.
The first two cards should show Status: open. The final card should show Status: resolved.
Still Seeing Only the Placeholder?
Confirm that you replaced the complete template block in src/App.vue. Check that the script block remains above it.
Ask for help with the rendered board.
✔️ Awesome, I've got everything!
Your src/App.vue file now contains the reactive issue list and its rendered board.
ⓧ I'd like to double check the full code
<script setup lang="ts">
import { ref } from 'vue'
type Issue = {
id: number
title: string
status: 'open' | 'resolved'
}
const issues = ref<Issue[]>([
{ id: 1, title: 'Clarify empty-state copy', status: 'open' },
{ id: 2, title: 'Fix keyboard focus ring', status: 'open' },
{ id: 3, title: 'Archive completed spike', status: 'resolved' },
])
const openCount = ref(issues.value.filter((issue) => issue.status === 'open').length)
function resolveFirstIssue() {
issues.value[0].status = 'resolved'
}
</script>
<template>
<main>
<h1>Vue Sprint Triage Board</h1>
<p>Open issues: {{ openCount }}</p>
<button type="button" @click="resolveFirstIssue">Resolve first issue</button>
<article v-for="issue in issues" :key="issue.id" :class="{ resolved: issue.status === 'resolved' }">
<h2>{{ issue.title }}</h2>
<p>Status: {{ issue.status }}</p>
</article>
</main>
</template>
Expose the stale summary
The cards and summary both begin with the correct values. One status change now tests whether those two displays remain synchronized.
Before you click, make a quick prediction about whether both the first card and the open count change.
- Click Resolve first issue in Safari.
The first card now shows Status: resolved. The summary still displays Open issues: 2.
You found the intended gap: the reactive issue changed while the copied summary stayed stale.
Why Did the Count Stay Stale?
The openCount ref receives a number when the component initializes. Changing issues does not rerun that initializer.
This resembles storing derived data as separate state in React. The separate value can drift away from its source.
Your issue cards now react to mutations. Next, you will make the summary follow the same source of truth.
Create a Computed Board Composable
Your board already proves that Vue.js reacts to a nested status mutation. The copied open count exposed the weak point.
A composable can own the issue state while computed values derive every summary or filtered list. Vue tracks those reactive dependencies automatically.
In this step, get ready to:
- Create a typed source of issue state in a board composable.
- Derive synchronized totals with computed values.
- Connect status filters plus priority filters to the composable.
Create the composable data model
The composable needs a shared vocabulary for issues before it can manage them. Typed priorities plus statuses keep every future component aligned.
- In the Explorer sidebar in Visual Studio Code, create a composables folder inside src.
- Confirm the Explorer sidebar now lists src/composables.
- Inside src/composables, create a file named useIssueBoard.ts.
- Confirm useIssueBoard.ts opens in a new editor tab.
- Define the board types plus starter issues by pasting this code into useIssueBoard.ts:
import { computed, ref } from 'vue'
export type Priority = 'low' | 'medium' | 'high'
export type IssueStatus = 'open' | 'resolved'
export type StatusFilter = 'all' | IssueStatus
export type PriorityFilter = 'all' | Priority
export interface NewIssue {
title: string
priority: Priority
}
export interface Issue extends NewIssue {
id: number
status: IssueStatus
}
const initialIssues: Issue[] = [
{ id: 1, title: 'Clarify empty-state copy', priority: 'medium', status: 'open' },
{ id: 2, title: 'Fix keyboard focus ring', priority: 'high', status: 'open' },
{ id: 3, title: 'Archive completed spike', priority: 'low', status: 'resolved' },
]
What does this code define?
- The exported type aliases constrain priorities plus statuses to known values.
- The NewIssue interface describes data submitted for a new issue.
- The Issue interface adds the identifier plus current status used by the board.
- The initialIssues array gives each starter issue a priority for the new filter.
- Save src/composables/useIssueBoard.ts.
- Confirm the saved file still shows all three starter issues in the editor.
Seeing type errors in the data model?
Check that every starter issue has an id, title, priority, plus status value. Confirm each literal matches the exported type aliases exactly.
Help me fix the typed issue data model.
Derive synchronized board state
The stale count came from copying a derived value once. Each getter below reads the current reactive sources whenever its dependencies change.
- Add the board logic below initialIssues in useIssueBoard.ts by pasting this code:
export function useIssueBoard() {
const issues = ref<Issue[]>(initialIssues.map((issue) => ({ ...issue })))
const statusFilter = ref<StatusFilter>('all')
const priorityFilter = ref<PriorityFilter>('all')
let nextIssueId = 4
const openCount = computed(() => issues.value.filter((issue) => issue.status === 'open').length)
const resolvedCount = computed(() => issues.value.filter((issue) => issue.status === 'resolved').length)
const totalCount = computed(() => issues.value.length)
const visibleIssues = computed(() =>
issues.value.filter((issue) => {
const matchesStatus =
statusFilter.value === 'all' || issue.status === statusFilter.value
const matchesPriority =
priorityFilter.value === 'all' || issue.priority === priorityFilter.value
return matchesStatus && matchesPriority
}),
)
function addIssue(input: NewIssue) { issues.value.unshift({ id: nextIssueId, title: input.title, priority: input.priority, status: 'open' }); nextIssueId += 1 }
function toggleIssue(id: number) { const issue = issues.value.find((candidate) => candidate.id === id); if (!issue) return; issue.status = issue.status === 'open' ? 'resolved' : 'open' }
return { statusFilter, priorityFilter, visibleIssues, openCount, resolvedCount, totalCount, addIssue, toggleIssue }
}
How does the composable stay synchronized?
- The issues ref owns a fresh copy of each starter issue.
- The count getters read issues.value whenever Vue evaluates their current values.
- The visibleIssues getter combines the current status filter with the current priority filter.
- The addIssue() plus toggleIssue() functions provide the mutations that components can request.
- Save src/composables/useIssueBoard.ts.
- Confirm the editor shows no red type-error underlines in useIssueBoard().
Seeing an error in the composable?
Check that computed plus ref both appear in the Vue import. Confirm the final closing brace belongs to useIssueBoard().
Help me debug my board composable.
✔️ Awesome, I've got everything!
Your composable now owns the board state plus every derived value. Make sure useIssueBoard.ts is saved.
ⓧ I'd like to double check the full code
import { computed, ref } from 'vue'
export type Priority = 'low' | 'medium' | 'high'
export type IssueStatus = 'open' | 'resolved'
export type StatusFilter = 'all' | IssueStatus
export type PriorityFilter = 'all' | Priority
export interface NewIssue {
title: string
priority: Priority
}
export interface Issue extends NewIssue {
id: number
status: IssueStatus
}
const initialIssues: Issue[] = [
{ id: 1, title: 'Clarify empty-state copy', priority: 'medium', status: 'open' },
{ id: 2, title: 'Fix keyboard focus ring', priority: 'high', status: 'open' },
{ id: 3, title: 'Archive completed spike', priority: 'low', status: 'resolved' },
]
export function useIssueBoard() {
const issues = ref<Issue[]>(initialIssues.map((issue) => ({ ...issue })))
const statusFilter = ref<StatusFilter>('all')
const priorityFilter = ref<PriorityFilter>('all')
let nextIssueId = 4
const openCount = computed(
() => issues.value.filter((issue) => issue.status === 'open').length,
)
const resolvedCount = computed(
() => issues.value.filter((issue) => issue.status === 'resolved').length,
)
const totalCount = computed(() => issues.value.length)
const visibleIssues = computed(() =>
issues.value.filter((issue) => {
const matchesStatus =
statusFilter.value === 'all' || issue.status === statusFilter.value
const matchesPriority =
priorityFilter.value === 'all' || issue.priority === priorityFilter.value
return matchesStatus && matchesPriority
}),
)
function addIssue(input: NewIssue) {
issues.value.unshift({
id: nextIssueId,
title: input.title,
priority: input.priority,
status: 'open',
})
nextIssueId += 1
}
function toggleIssue(id: number) {
const issue = issues.value.find((candidate) => candidate.id === id)
if (!issue) return
issue.status = issue.status === 'open' ? 'resolved' : 'open'
}
return {
statusFilter,
priorityFilter,
visibleIssues,
openCount,
resolvedCount,
totalCount,
addIssue,
toggleIssue,
}
}
Connect the board to the composable
The component can now focus on presentation plus user interactions. Its state comes from one composable call instead of local copies.
- In src/App.vue, replace the existing script setup block with this code:
<script setup lang="ts">
import { useIssueBoard } from './composables/useIssueBoard'
const {
statusFilter,
priorityFilter,
visibleIssues,
openCount,
resolvedCount,
totalCount,
addIssue,
toggleIssue,
} = useIssueBoard()
</script>
What changes in the script?
- The component imports useIssueBoard() instead of creating its own ref values.
- The destructured bindings expose the filters plus summaries directly to the template.
- The component receives mutation functions from the same source that owns the issue data.
- Replace the existing template block in src/App.vue with this board interface:
<template><main class="shell">
<header class="hero">
<p class="eyebrow">Vue 3 Composition API</p>
<h1>Vue Sprint Triage Board</h1>
<p>Track the work, filter the noise, and keep every derived value in sync.</p>
</header>
<section class="summary" aria-label="Issue summary">
<div><strong>{{ totalCount }}</strong><span>Total</span></div>
<div><strong>{{ openCount }}</strong><span>Open</span></div>
<div><strong>{{ resolvedCount }}</strong><span>Resolved</span></div>
</section>
<section class="filters" aria-label="Issue filters">
<label>Status<select v-model="statusFilter">
<option value="all">All statuses</option><option value="open">Open</option><option value="resolved">Resolved</option>
</select></label>
<label>Priority<select v-model="priorityFilter">
<option value="all">All priorities</option><option value="high">High</option><option value="medium">Medium</option><option value="low">Low</option>
</select></label>
</section>
<section class="board" aria-live="polite">
<p v-if="visibleIssues.length === 0" class="empty-state">No issues match these filters.</p>
<article v-for="issue in visibleIssues" :key="issue.id" :class="{ 'issue-card--resolved': issue.status === 'resolved' }">
<span class="priority" :class="`priority--${issue.priority}`">{{ issue.priority }}</span>
<h2>{{ issue.title }}</h2><p>Status: {{ issue.status }}</p>
<button type="button" @click="toggleIssue(issue.id)">{{ issue.status === 'open' ? 'Resolve' : 'Reopen' }}</button>
</article>
</section>
</main></template>
How does the template react?
- Each v-model binding writes the selected value into its matching filter ref.
- The v-for directive renders only the issues returned by visibleIssues.
- The v-if directive displays an empty state when the active filters match no issues.
- Each issue button sends its identifier to toggleIssue().
- Save src/App.vue.
- Return to Safari from earlier.
Before you use the board, what totals do you expect after resolving the first open issue?
- Click Resolve on the first issue card.
You should see 3 total issues. The open count should change to 1. The resolved count should change to 2.
- Select Resolved from the Status control.
You should see the two resolved issue cards. The three summary totals should stay synchronized.
- Select Medium from the Priority control.
You should see only Clarify empty-state copy. The result proves both filters contribute to the same computed list.
Board missing or filters not updating?
Check that the import path is ./composables/useIssueBoard. Confirm both select elements bind to the matching filter names returned by the composable.
If the cards render without changing status, verify that each button passes issue.id to toggleIssue().
Help me trace my board bindings.
✔️ Awesome, I've got everything!
Your board now shows synchronized totals plus status filtering plus priority filtering. Make sure src/App.vue is saved.
ⓧ I'd like to double check the full code
<script setup lang="ts">
import { useIssueBoard } from './composables/useIssueBoard'
const {
statusFilter,
priorityFilter,
visibleIssues,
openCount,
resolvedCount,
totalCount,
addIssue,
toggleIssue,
} = useIssueBoard()
</script>
<template>
<main class="shell">
<header class="hero">
<p class="eyebrow">Vue 3 Composition API</p>
<h1>Vue Sprint Triage Board</h1>
<p>Track the work, filter the noise, and keep every derived value in sync.</p>
</header>
<section class="summary" aria-label="Issue summary">
<div><strong>{{ totalCount }}</strong><span>Total</span></div>
<div><strong>{{ openCount }}</strong><span>Open</span></div>
<div><strong>{{ resolvedCount }}</strong><span>Resolved</span></div>
</section>
<section class="filters" aria-label="Issue filters">
<label>
Status
<select v-model="statusFilter">
<option value="all">All statuses</option>
<option value="open">Open</option>
<option value="resolved">Resolved</option>
</select>
</label>
<label>
Priority
<select v-model="priorityFilter">
<option value="all">All priorities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</label>
</section>
<section class="board" aria-live="polite">
<p v-if="visibleIssues.length === 0" class="empty-state">
No issues match these filters.
</p>
<article
v-for="issue in visibleIssues"
:key="issue.id"
:class="{ 'issue-card--resolved': issue.status === 'resolved' }"
>
<span class="priority" :class="`priority--${issue.priority}`">
{{ issue.priority }}
</span>
<h2>{{ issue.title }}</h2>
<p>Status: {{ issue.status }}</p>
<button type="button" @click="toggleIssue(issue.id)">
{{ issue.status === 'open' ? 'Resolve' : 'Reopen' }}
</button>
</article>
</section>
</main>
</template>
You fixed the stale-summary gap. The board now recalculates its totals plus visible cards without a dependency array.
Your reactive logic now has a reusable home. Next up, you will connect typed child components to this shared board API.
Connect Typed Vue Components
Your Vue.js board now keeps its totals and filters synchronized through computed state. The previous step gave the parent component a reliable source of truth.
The form and issue cards still live inside one large component. Splitting them into typed Single-File Components makes the data flow visible through props and emitted events.
In this step, get ready to:
- Build a typed form that emits new issue data.
- Build a typed card that receives issue data through a prop.
- Connect both components to the board before testing the production build.
Build the typed issue form
The form owns temporary input state through ref. A typed add-issue event sends the completed payload to the parent.
- Select the src folder in the file sidebar in Visual Studio Code.
- Create a components folder inside src using the folder control at the top of the file sidebar.
- Select the new components folder.
- Create IssueForm.vue using the file control at the top of the file sidebar.
- Give the component its typed state and initial template by pasting this code:
<script setup lang="ts">
import { ref } from 'vue'
import type { NewIssue, Priority } from '../composables/useIssueBoard'
const emit = defineEmits<{
'add-issue': [issue: NewIssue]
}>()
const title = ref('')
const priority = ref<Priority>('medium')
function submitIssue() {
if (!title.value) return
emit('add-issue', {
title: title.value,
priority: priority.value,
})
title.value = ''
priority.value = 'medium'
}
</script>
<template>
<form class="issue-form" @submit.prevent="submitIssue">
<button type="submit" :disabled="!title">Add issue</button>
</form>
</template>
What does this component do?
- The local title and priority refs hold unfinished form values.
- The typed emit function only accepts an add-issue event carrying a NewIssue payload.
- The submitIssue() function emits the payload before resetting both fields.
- The .prevent modifier keeps the browser on the board when the form submits.
- Save IssueForm.vue.
- Confirm IssueForm.vue remains listed inside src/components.
IssueForm.vue missing from the sidebar?
Check that the file sits inside vue-sprint-triage-board/src/components. A file created beside src has the wrong location.
Need help checking the component path?
The initial component is valid. The parent needs to render it before you can see its button in the board.
- Return to src/App.vue in the editor.
- Find this import at the top of the script block:
import { useIssueBoard } from './composables/useIssueBoard'
Why use this line as an anchor?
This import already exists in the parent component. It gives you a precise location for the component import.
- Replace that import with these two imports:
import IssueForm from './components/IssueForm.vue'
import { useIssueBoard } from './composables/useIssueBoard'
What changed in the script?
The parent can now reference IssueForm directly in its template. Components imported through script setup become available without a registration object.
- Find the closing tag for the summary section in the template.
- Locate the filters section immediately below it.
- Insert the form between those two sections:
<IssueForm @add-issue="addIssue" />
How does the event reach the composable?
The child emits add-issue with a NewIssue payload. The parent forwards that payload to addIssue from the composable.
- Save src/App.vue.
- Switch back to the running board in Safari.
You should see a disabled Add issue button between the summary cards and filters. That button proves the child component now renders inside its parent.
Form button missing from the board?
Check that the import path starts with ./components/IssueForm.vue. Confirm the IssueForm tag sits inside the template.
Help me debug why IssueForm is not rendering in App.vue.
The form now needs controls that update its local refs. v-model keeps each control synchronized with the matching value.
- Return to src/components/IssueForm.vue.
- Find the opening form tag in the template.
- Insert these controls above the Add issue button:
<label>
Issue title
<input
v-model.trim="title"
name="title"
placeholder="Describe the next issue"
autocomplete="off"
/>
</label>
<label>
Priority
<select v-model="priority" name="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
How do these controls update state?
- The v-model.trim binding updates title with surrounding whitespace removed.
- The priority selector updates the typed priority ref.
- The submit button becomes available when title contains text.
- Save IssueForm.vue.
- Return to Safari to check the updated form.
You should see an issue title field and a priority selector above the button. Typing a title should enable Add issue.
Form controls not appearing?
Confirm both label elements sit inside the existing form element. Check that every input tag closes correctly.
Help me find a Vue template error in IssueForm.vue.
The form works as a child component. Scoped styles give it a distinct panel without changing the rest of the board.
- Append this scoped style block below the template in IssueForm.vue.
<style scoped>
.issue-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px auto;
gap: 0.75rem;
align-items: end;
padding: 1rem;
border: 1px solid #dbe3f0;
border-radius: 1rem;
background: white;
}
</style>
What does the form container control?
The grid gives the title field the flexible column. The selector and button receive compact columns beside it.
- Save IssueForm.vue.
- Return to Safari to inspect the form panel.
You should see the form inside a white rounded panel. Its three controls should share one row.
Form still showing as plain content?
Confirm the style block sits below </template>. Check that the opening tag includes scoped.
Help me debug why the scoped IssueForm styles are not applying.
- Insert these control styles immediately above </style> in IssueForm.vue.
label {
display: grid;
gap: 0.35rem;
color: #334155;
font-size: 0.85rem;
font-weight: 700;
}
input,
select,
button {
min-height: 42px;
border-radius: 0.65rem;
font: inherit;
}
input,
select {
border: 1px solid #cbd5e1;
padding: 0 0.75rem;
background: white;
}
Why group these selectors?
The grouped selectors give every control matching height and typography. The input-specific rules add a visible boundary for editable fields.
- Save IssueForm.vue.
- Return to Safari to compare the controls.
You should see stronger labels and equally tall controls. The text field and selector should have matching borders.
Control heights do not match?
Check that input, select, and button share one selector group. Confirm the commas remain after the first two selectors.
Help me check the grouped CSS selectors in IssueForm.vue.
- Insert the final button and responsive styles immediately above </style>.
button {
border: 0;
padding: 0 1rem;
background: #4f46e5;
color: white;
font-weight: 800;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
@media (max-width: 700px) {
.issue-form {
grid-template-columns: 1fr;
}
}
What finishes the form styling?
The button rules make the main action visually distinct. The media query stacks the controls when the browser becomes narrow.
- Save IssueForm.vue.
- Return to Safari to inspect the completed form.
You should see a purple action button with a muted disabled state. Narrowing the browser below the component breakpoint should stack the controls.
Button styling missing?
Confirm both button selectors sit inside the scoped style block. Check that the final closing brace appears before </style>.
Help me find a missing brace in the IssueForm.vue styles.
Use the reference below to compare the finished form component.
✔️ Awesome, I've got everything!
Great. Save IssueForm.vue before moving to the issue card.
ⓧ I'd like to double check the full code
<script setup lang="ts">
import { ref } from 'vue'
import type { NewIssue, Priority } from '../composables/useIssueBoard'
const emit = defineEmits<{
'add-issue': [issue: NewIssue]
}>()
const title = ref('')
const priority = ref<Priority>('medium')
function submitIssue() {
if (!title.value) return
emit('add-issue', {
title: title.value,
priority: priority.value,
})
title.value = ''
priority.value = 'medium'
}
</script>
<template>
<form class="issue-form" @submit.prevent="submitIssue">
<label>
Issue title
<input
v-model.trim="title"
name="title"
placeholder="Describe the next issue"
autocomplete="off"
/>
</label>
<label>
Priority
<select v-model="priority" name="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<button type="submit" :disabled="!title">Add issue</button>
</form>
</template>
<style scoped>
.issue-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px auto;
gap: 0.75rem;
align-items: end;
padding: 1rem;
border: 1px solid #dbe3f0;
border-radius: 1rem;
background: white;
}
label {
display: grid;
gap: 0.35rem;
color: #334155;
font-size: 0.85rem;
font-weight: 700;
}
input,
select,
button {
min-height: 42px;
border-radius: 0.65rem;
font: inherit;
}
input,
select {
border: 1px solid #cbd5e1;
padding: 0 0.75rem;
background: white;
}
button {
border: 0;
padding: 0 1rem;
background: #4f46e5;
color: white;
font-weight: 800;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
@media (max-width: 700px) {
.issue-form {
grid-template-columns: 1fr;
}
}
</style>
How to use this reference
Compare each section with your saved file. Pay close attention to the event payload type and closing style tag.
Build the typed issue card
Each card receives one Issue object through a typed prop. Its toggle event sends only the issue ID back to the parent.
- Select src/components in the file sidebar.
- Create IssueCard.vue using the file control at the top of the file sidebar.
- Give the card its typed interface and initial template by pasting this code:
<script setup lang="ts">
import type { Issue } from '../composables/useIssueBoard'
const props = defineProps<{
issue: Issue
}>()
const emit = defineEmits<{
toggle: [id: number]
}>()
</script>
<template>
<article
class="issue-card"
:class="{ 'issue-card--resolved': props.issue.status === 'resolved' }"
>
<div>
<h2>{{ props.issue.title }}</h2>
<p>Status: {{ props.issue.status }}</p>
</div>
<button type="button" @click="emit('toggle', props.issue.id)">
{{ props.issue.status === 'open' ? 'Resolve' : 'Reopen' }}
</button>
</article>
</template>
How does this card communicate?
- The typed issue prop gives the child its display data.
- The toggle event carries a numeric issue ID.
- The conditional class reflects the current status in the card markup.
- The button label switches between Resolve and Reopen.
- Save IssueCard.vue.
- Confirm IssueCard.vue appears beside IssueForm.vue in src/components.
Seeing a prop or emit error?
Check that Issue uses a type-only import. Confirm the event payload uses number.
Help me debug the typed prop or toggle event in IssueCard.vue.
The parent can now replace its repeated card markup with this component. The loop still owns the primitive key.
- Return to the script block in src/App.vue.
- Find these current imports:
import IssueForm from './components/IssueForm.vue'
import { useIssueBoard } from './composables/useIssueBoard'
What does this reference show?
The form is already available to the parent. The card import belongs directly above it.
- Replace those imports with this complete import group:
import IssueCard from './components/IssueCard.vue'
import IssueForm from './components/IssueForm.vue'
import { useIssueBoard } from './composables/useIssueBoard'
What changed in the import group?
Both child components are now available in the parent template. The composable remains the owner of board behavior.
- Find the existing element with v-for="issue in visibleIssues" inside the board section.
- Remove that entire repeated element.
- Insert this component below the empty-state paragraph:
<IssueCard
v-for="issue in visibleIssues"
:key="issue.id"
:issue="issue"
@toggle="toggleIssue"
/>
How does the card loop work?
- The v-for directive creates one card for each visible issue.
- The :issue binding passes the current issue into the typed prop.
- The @toggle listener forwards the emitted ID to toggleIssue.
- Save src/App.vue.
- Return to Safari to check the repeated cards.
You should still see all three starter issues. Each card should show its title and status beside a working status button.
Cards disappeared after the replacement?
Confirm IssueCard sits inside the board section. Check that visibleIssues remains the loop source.
Help me debug why IssueCard is not rendering inside the v-for loop.
Priority belongs to the card's display contract. A class derived from the prop gives each priority level its own visual treatment.
- Return to src/components/IssueCard.vue.
- Find the div that contains the issue title.
- Insert this priority badge above the h2 element:
<span class="priority" :class="`priority--${props.issue.priority}`">
{{ props.issue.priority }}
</span>
How does the badge choose a class?
The template string turns each priority into a matching modifier class. A high-priority issue receives priority--high.
- Save IssueCard.vue.
- Return to Safari to inspect each card.
You should see a priority label above every issue title. The labels should currently use the browser's default styling.
Priority text missing?
Confirm the span sits inside the card's inner div element. Check that the interpolation references props.issue.priority.
Help me debug the priority badge in IssueCard.vue.
- Append this scoped style block below the template in IssueCard.vue.
<style scoped>
.issue-card {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 1rem;
border: 1px solid #dbe3f0;
border-left: 5px solid #4f46e5;
border-radius: 0.9rem;
background: white;
}
.issue-card--resolved {
border-left-color: #16a34a;
opacity: 0.72;
}
</style>
How does status affect the card?
Open cards receive a purple left border. Resolved cards switch to green with reduced opacity.
- Save IssueCard.vue.
- Return to Safari to compare open and resolved cards.
You should see white rounded cards with purple borders for open work. The resolved starter issue should have a green border and softer appearance.
All cards have the same border?
Check that the conditional class uses props.issue.status === 'resolved'. Confirm the modifier selector includes both hyphens.
Help me debug the resolved class on IssueCard.vue.
- Insert these text and badge styles immediately above </style>.
h2 {
margin: 0.35rem 0;
font-size: 1rem;
}
p {
margin: 0;
color: #64748b;
text-transform: capitalize;
}
.priority {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: #e2e8f0;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
What do these styles clarify?
The title and status form a compact text group. The priority becomes a small uppercase badge above them.
- Save IssueCard.vue.
- Return to Safari to inspect the badge shape.
You should see compact titles and pill-shaped priority badges. Each status should begin with a capital letter.
Badge shape not appearing?
Confirm the selector begins with .priority. Check that the style block still ends after this selector.
Help me debug the priority badge styles in IssueCard.vue.
- Insert the priority colors and button styles immediately above </style>.
.priority--high {
background: #fee2e2;
color: #b91c1c;
}
.priority--medium {
background: #fef3c7;
color: #92400e;
}
.priority--low {
background: #dcfce7;
color: #166534;
}
button {
min-height: 40px;
border: 1px solid #cbd5e1;
border-radius: 0.65rem;
padding: 0 0.9rem;
background: #f8fafc;
color: #1e293b;
font: inherit;
font-weight: 700;
cursor: pointer;
}
How do the modifier styles help?
Each priority class receives a distinct color pair. The shared button rules keep status actions consistent across every card.
- Save IssueCard.vue.
- Return to Safari to inspect the completed cards.
You should see red high-priority badges and amber medium-priority badges. The low-priority badge should be green.
Priority badges share one color?
Confirm the template produces priority--high, priority--medium, or priority--low. Check that each CSS selector starts with a full stop.
Help me match the priority class binding to the CSS modifier selectors.
Use the reference below to compare the completed issue card.
✔️ Awesome, I've got everything!
Your card now has a typed prop and a typed output event. Save IssueCard.vue.
ⓧ I'd like to double check the full code
<script setup lang="ts">
import type { Issue } from '../composables/useIssueBoard'
const props = defineProps<{
issue: Issue
}>()
const emit = defineEmits<{
toggle: [id: number]
}>()
</script>
<template>
<article
class="issue-card"
:class="{ 'issue-card--resolved': props.issue.status === 'resolved' }"
>
<div>
<span class="priority" :class="`priority--${props.issue.priority}`">
{{ props.issue.priority }}
</span>
<h2>{{ props.issue.title }}</h2>
<p>Status: {{ props.issue.status }}</p>
</div>
<button type="button" @click="emit('toggle', props.issue.id)">
{{ props.issue.status === 'open' ? 'Resolve' : 'Reopen' }}
</button>
</article>
</template>
<style scoped>
.issue-card {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 1rem;
border: 1px solid #dbe3f0;
border-left: 5px solid #4f46e5;
border-radius: 0.9rem;
background: white;
}
.issue-card--resolved {
border-left-color: #16a34a;
opacity: 0.72;
}
h2 {
margin: 0.35rem 0;
font-size: 1rem;
}
p {
margin: 0;
color: #64748b;
text-transform: capitalize;
}
.priority {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: #e2e8f0;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.priority--high {
background: #fee2e2;
color: #b91c1c;
}
.priority--medium {
background: #fef3c7;
color: #92400e;
}
.priority--low {
background: #dcfce7;
color: #166534;
}
button {
min-height: 40px;
border: 1px solid #cbd5e1;
border-radius: 0.65rem;
padding: 0 0.9rem;
background: #f8fafc;
color: #1e293b;
font: inherit;
font-weight: 700;
cursor: pointer;
}
</style>
How to use this reference
Compare your component from top to bottom. Check the event payload and dynamic priority class closely.
Connect events and verify the board
The child components now describe their inputs and outputs with TypeScript. The parent remains responsible for changing the composable's state.
A production build checks that Vite can compile the completed component graph. The interaction check then proves every computed value still follows the source state.
Compare your completed parent component with the final reference before building.
✔️ Awesome, I've got everything!
Save src/App.vue before running the production build.
ⓧ I'd like to double check the full code
<script setup lang="ts">
import IssueCard from './components/IssueCard.vue'
import IssueForm from './components/IssueForm.vue'
import { useIssueBoard } from './composables/useIssueBoard'
const {
statusFilter,
priorityFilter,
visibleIssues,
openCount,
resolvedCount,
totalCount,
addIssue,
toggleIssue,
} = useIssueBoard()
</script>
<template>
<main class="shell">
<header class="hero">
<p class="eyebrow">Vue 3 Composition API</p>
<h1>Vue Sprint Triage Board</h1>
<p>Track the work, filter the noise, and keep every derived value in sync.</p>
</header>
<section class="summary" aria-label="Issue summary">
<div><strong>{{ totalCount }}</strong><span>Total</span></div>
<div><strong>{{ openCount }}</strong><span>Open</span></div>
<div><strong>{{ resolvedCount }}</strong><span>Resolved</span></div>
</section>
<IssueForm @add-issue="addIssue" />
<section class="filters" aria-label="Issue filters">
<label>
Status
<select v-model="statusFilter">
<option value="all">All statuses</option>
<option value="open">Open</option>
<option value="resolved">Resolved</option>
</select>
</label>
<label>
Priority
<select v-model="priorityFilter">
<option value="all">All priorities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</label>
</section>
<section class="board" aria-live="polite">
<p v-if="visibleIssues.length === 0" class="empty-state">
No issues match these filters.
</p>
<IssueCard
v-for="issue in visibleIssues"
:key="issue.id"
:issue="issue"
@toggle="toggleIssue"
/>
</section>
</main>
</template>
<style>
:root {
color: #172033;
background: #eef2ff;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
.shell {
width: min(900px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0;
}
.hero {
margin-bottom: 1.25rem;
}
.hero h1 {
margin: 0;
font-size: clamp(2rem, 6vw, 3.6rem);
}
.hero > p:last-child {
color: #475569;
}
.eyebrow {
margin: 0 0 0.4rem;
color: #4f46e5;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.summary div {
display: grid;
gap: 0.2rem;
padding: 1rem;
border-radius: 1rem;
background: #172033;
color: white;
}
.summary strong {
font-size: 1.8rem;
}
.summary span {
color: #cbd5e1;
}
.filters {
display: flex;
gap: 0.75rem;
margin: 1rem 0;
}
.filters label {
display: grid;
gap: 0.3rem;
color: #334155;
font-size: 0.8rem;
font-weight: 800;
}
.filters select {
min-height: 40px;
border: 1px solid #cbd5e1;
border-radius: 0.65rem;
padding: 0 2rem 0 0.7rem;
background: white;
}
.board {
display: grid;
gap: 0.75rem;
}
.empty-state {
padding: 2rem;
border: 1px dashed #94a3b8;
border-radius: 1rem;
color: #64748b;
text-align: center;
}
@media (max-width: 600px) {
.summary {
grid-template-columns: 1fr;
}
.filters {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
</style>
What should the parent contain?
The parent imports both child components and the composable. Its template connects child events directly to composable methods.
- Save src/App.vue.
- Save src/components/IssueForm.vue.
- Save src/components/IssueCard.vue.
- Create a second terminal using the new-terminal control in the existing terminal panel.
Before you run the build, do you expect the typed component connections to compile successfully?
- Build the completed application by running:
npm run build
What does this build check?
The build compiles the component imports and templates into production assets. A successful result confirms that Vite can process the completed application.
You should see the build complete without errors. The command should return control to the terminal.
Production build failed?
Read the first file path in the build error. Compare that file with its full-code reference above.
Ask me to diagnose the production build error. Share the same error in the NextWork community if it depends on your local environment.
Before you test the board, which totals do you expect to change when a new open issue is added?
- Return to the running board in Safari.
- Select All statuses in the status filter.
- Select All priorities in the priority filter.
- Enter Prepare sprint demo in the issue title field.
- Select High in the form's priority selector.
- Click Add issue.
You should see the new high-priority issue at the top of the board. The total count and open count should each increase by one.
- Select High in the priority filter.
You should see only high-priority cards. The new issue should remain visible.
- Click Resolve on Prepare sprint demo.
The card should switch to resolved with a Reopen button. The open count should decrease while the resolved count increases.
- Select Resolved in the status filter.
You should still see Prepare sprint demo because it now matches both active filters. Every visible value is reading from the same reactive board state.
New issue or totals not updating?
Check that IssueForm listens with @add-issue="addIssue". Confirm IssueCard listens with @toggle="toggleIssue".
Help me trace the typed event flow through the board.
You made the full component flow work. The form emits typed issue data while each card emits a typed ID.
Secret mission
Persist the Board Across Reloads
Make every issue addition and status change survive a Safari reload. Use a deep Vue watcher to synchronize the board with local storage while keeping both filters temporary.
Clean Up Your Resources
Clean Up Your Resources
Your completed Vue.js board runs entirely on your Mac. There are no ongoing costs or subscriptions.
Resources you used:
- The local vue-sprint-triage-board folder with your project files.
- The local node_modules directory inside vue-sprint-triage-board.
- The local Vite development server running in Visual Studio Code's integrated terminal.
- The saved issue data held in Safari localStorage for the local Vite origin.
Keep everything running
No action needed. Choose this if you want to keep testing the completed board.
- Keep the Vite development server running while you use the board.
- Retain the vue-sprint-triage-board folder at its current location.
- Leave the saved issue data in Safari so your additions and status changes keep returning after reloads.
Pause - I'll come back to this later
Pausing frees the integrated terminal. Your project files remain available for your return.
- Stop the Vite process in the integrated terminal from earlier.
- Confirm that the terminal returns to a prompt.
- Keep the vue-sprint-triage-board folder at its current location.
- Leave Safari's saved issue data in place.
Your board is paused. The source files and saved issues are ready for your next session.
Delete - I don't want to use this again
Deleting these resources removes your working copy and saved browser state. Choose this option only when you no longer need the board.
- Stop the Vite process in the integrated terminal from earlier.
- Confirm that the terminal returns to a prompt.
- Return to Safari from earlier.
- Use Safari's website data controls to clear the local Vite origin's website data.
- Reload the local URL in Safari.
You'll see the three starter issues again. The board no longer restores your saved additions or status changes.
- Locate the vue-sprint-triage-board folder in Finder at the same location where you created it.
- Delete the vue-sprint-triage-board folder in Finder.
- Check the folder's original location in Finder.
That is a clean slate: the project folder is gone from Finder.
Safari no longer restores your saved issues.
Nice Work!
Nice Work!
You did it. You turned your React experience into a working Vue.js sprint triage board with synchronized state.
What you learned:
- Built a local sprint triage board with Vue.js through a Vite workflow powered by TypeScript.
- Connected reusable Single-File Components through typed props plus emitted events.
- Replaced stale copied counts with automatically tracked derived state using computed inside the reusable useIssueBoard() composable.
- Secret Mission: Added persistent issue state across Safari reloads with watch plus localStorage.
Ready to quiz yourself?