Build an Incident Triage Dashboard
Build and deploy an accessible incident dashboard with browser-local state.
Introduction
30 Second Summary
Service failures create pressure fast. Scattered notes make it harder to see which problem needs attention first.
In this project, you will build an accessible incident triage dashboard that turns reports into a manageable operations queue. You will publish the finished product as a live demo that employers can inspect.
What You'll Build
You'll open a live dashboard where each incident moves from report to resolution while the queue and summary respond immediately.
By the end of this project, you'll have:
- A responsive incident queue where you can add reports, filter by severity, resolve active work, and watch the summary update.
- An accessible keyboard workflow with explicit labels, visible focus, native controls, and status messages that announce every change.
- A live employer-ready demo published with GitHub Pages. Its incident data survives refreshes in the same browser. Its public GitHub repository explains your design decisions.
- Secret Mission: Add a confirmed deletion workflow that preserves incident data and announces the outcome accessibly.
Are there any prerequisites?
You do not need prior frontend experience. You'll use a Mac while the guide walks you through setting up Visual Studio Code, Node.js, GitHub Desktop, and Safari.
Before We Start
A clear purpose will guide every decision you make in this project. This opening checkpoint connects an operations team's incident workflow to the frontend engineering judgment you want employers to see.
Set Up Your Development Workspace
A shareable dashboard needs a dependable workspace before you start building. Confirming each tool now keeps installation problems separate from application bugs later.
You will use Visual Studio Code to edit the project. Node.js provides the local preview tools.
GitHub Desktop manages the local repository. Safari displays the first version of your dashboard.
In this step, get ready to:
- Prepare the local development tools.
- Connect GitHub Desktop to a verified personal account.
- Create the repository shell for a browser preview.
Install and verify the workspace tools
Visual Studio Code gives every project file a clear home. Start by checking whether the editor is already available on your Mac.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
✔️ Visual Studio Code appears
- Press Enter to open Visual Studio Code.
Your editor is open and ready for the repository you create later in this step.
ⓧ Visual Studio Code is missing
The editor needs to be installed before Spotlight can open it.
- Visit the official Visual Studio Code installation guide.
- Download the macOS disk image.
- Open the downloaded disk image.
- Drag Visual Studio Code.app into Applications.
- Press Cmd+Space to reopen Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Enter to open Visual Studio Code.
Visual Studio Code still missing?
Confirm that Visual Studio Code.app is inside your Applications folder. Reopen Spotlight after moving the application.
Help me troubleshoot the Visual Studio Code installation on my Mac.
Node.js runs the local server command used throughout this project. This project uses Node.js v24.22.1 LTS so every learner starts from the same runtime.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Check the installed Node.js version by running this command:
node --version
What does this command do?
The command asks Node.js to print its installed version. The result tells you whether the required runtime is available.
✔️ I see v24.22.1
Your Node.js runtime matches the project version. That removes a common source of setup differences before coding begins.
ⓧ I see an older version
An older runtime can behave differently from the project version. Install the current project version before continuing.
- Visit the official Node.js download page.
- Download Node.js v24.22.1 LTS for macOS.
- Complete the macOS installer.
- Close Terminal after the installer finishes.
- Reopen Terminal through Spotlight.
- Confirm the updated version by running this command:
node --version
What should I see?
Terminal should now print v24.22.1. This confirms that your shell can access the updated runtime.
ⓧ Command not found
Terminal cannot find Node.js because the runtime is not installed yet. The official installer adds Node.js to your Mac.
- Visit the official Node.js download page.
- Download Node.js v24.22.1 LTS for macOS.
- Complete the macOS installer.
- Close Terminal after the installer finishes.
- Reopen Terminal through Spotlight.
- Confirm the installation by running this command:
node --version
What should I see?
Terminal should print v24.22.1. This confirms that Node.js is installed and available to your shell.
Still seeing the wrong Node.js result?
Close every Terminal window after installation. A new Terminal session reloads the command locations available to your shell.
Help me troubleshoot my Node.js version on macOS.
GitHub Desktop keeps repository work visible without requiring command-line authentication. It needs a verified personal GitHub account before you create the project repository.
- Press Cmd+Space to open Spotlight.
- Type GitHub Desktop into Spotlight.
✔️ GitHub Desktop is signed in
- Press Enter to open GitHub Desktop.
- Confirm that GitHub Desktop displays your verified personal account.
Your repository workflow is authenticated and ready.
ⓧ GitHub Desktop needs sign-in
A verified personal account lets GitHub Desktop create repositories under your identity.
- Press Enter to open GitHub Desktop.
- Create a personal account from the GitHub signup page if you do not have one.
- Verify the email address connected to your personal account.
- Return to GitHub Desktop.
- Complete the sign-in flow with your verified personal account.
ⓧ GitHub Desktop is missing
Install GitHub Desktop before connecting your account.
- Visit the official GitHub Desktop installation guide.
- Click Download for macOS.
- Open the downloaded archive.
- Move the GitHub Desktop application into Applications.
- Open GitHub Desktop through Spotlight.
- Create a personal account from the GitHub signup page if you do not have one.
- Verify the email address connected to your personal account.
- Complete the GitHub Desktop sign-in flow.
- Press Cmd+Space to open Spotlight.
- Type Safari into Spotlight.
- Press Enter to confirm Safari opens.
Your editor, runtime, repository client, account, and browser are ready. The next move gives those tools a real project to work on.
Create the local repository and project files
A local Git repository records the project from its first file. GitHub Desktop also creates the initial README.md so the repository begins with documentation.
- Return to GitHub Desktop.
- Click Create a New Repository on your Hard Drive.
- Enter incident-triage-dashboard in the Name field.
- Choose your Desktop in the Local path field.
- Select Initialize this repository with a README.
- Click Create repository.
That is the repository foundation in place. GitHub Desktop now shows incident-triage-dashboard as the current local repository.
What did GitHub Desktop create?
The repository tracks changes made inside the incident-triage-dashboard folder. Its initialized README.md contains the project heading.
- Select Repository in the GitHub Desktop menu bar.
- Click Open in Visual Studio Code.
- Confirm that the Explorer sidebar shows README.md.
- Select README.md in the Explorer sidebar.
- Confirm that the file contains # incident-triage-dashboard.
Visual Studio Code did not open?
In GitHub Desktop, open Settings. Select Integrations to set Visual Studio Code as the default editor.
Help me connect GitHub Desktop to Visual Studio Code.
- Select the Explorer view in the Visual Studio Code Activity Bar.
- Click New File... in the Explorer sidebar.
- Enter index.html as the file name.
- Click New File... in the Explorer sidebar again.
- Enter styles.css as the file name.
The Explorer now lists README.md, index.html, and styles.css inside the repository.
- Click New File... in the Explorer sidebar.
- Enter app.js as the file name.
The Explorer now shows all four target files. styles.css and app.js stay empty until you build the interactive dashboard.
- Select index.html in the Explorer sidebar.
- Add the placeholder page by pasting this code into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Incident Triage Dashboard</title>
</head>
<body>
<h1>Incident Triage Dashboard</h1>
</body>
</html>
What does this code do?
- The document metadata gives the browser the page language and responsive viewport settings.
- The page title labels the Safari tab as Incident Triage Dashboard.
- The visible heading provides a concrete preview target for the local server.
- Save index.html with Cmd+S.
- Confirm that the editor displays the Incident Triage Dashboard heading inside the saved document.
Does the HTML look incomplete?
Confirm that the opening and closing html, head, and body tags are present. Check that the file name is exactly index.html.
Help me compare my placeholder HTML with the target file.
✔️ Awesome, I've got everything!
Your project shell is saved. Keep Visual Studio Code open for the preview.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Incident Triage Dashboard</title>
</head>
<body>
<h1>Incident Triage Dashboard</h1>
</body>
</html>
Preview the project in Safari
Opening the HTML through a local server gives the project a normal browser origin. That setup becomes important when the dashboard starts saving incidents in browser storage.
- Select View in the Visual Studio Code menu bar.
- Select Terminal to open the integrated terminal.
The integrated terminal starts inside the open incident-triage-dashboard folder. Before you run the checks, ask yourself which result proves that the required runtime is active inside this repository.
- Check Node.js from the repository folder by running this command:
node --version
What should I see?
Terminal should print v24.22.1. This proves that the repository terminal can access the required Node.js runtime.
- Check npm from the repository folder by running this command:
npm --version
What should I see?
Terminal should print an npm version number. This proves that the package runner required for the preview is available.
The first preview can pause while npm downloads serve. A quiet Terminal during that download does not mean the command is stuck.
The first run may also display an npm installation prompt. Accept the prompt so npm can run serve version 14.2.6.
Before you start the server, ask yourself whether Safari will display the heading from the file you just saved.
- Start the local preview from the repository folder by running this command:
npx serve@14.2.6
What does this command do?
The command runs serve version 14.2.6 against the current repository folder. Terminal prints a Local URL while the server stays active.
- Accept the npm installation prompt if it appears.
- Click the Local URL printed in Terminal.
- Choose Safari if macOS asks which browser should open the URL.
Safari should show the large Incident Triage Dashboard heading on a plain page. That is your first visible win: the repository is serving a real browser page from your Mac.
Placeholder page not loading?
Keep the server command running in the Visual Studio Code terminal. Confirm that Terminal started from the incident-triage-dashboard folder.
Confirm that index.html is saved in the same folder as README.md. Then reopen the Local URL.
Help me troubleshoot my local serve preview.
Your development workspace now has a working editor, runtime, repository workflow, and local preview. Next, you will replace the placeholder with the accessible incident triage interface.
Build the Interactive Triage Queue
Your local project shell is running in Safari. Now the placeholder page needs to become a dashboard that supports a real incident workflow.
This step builds the queue with Semantic HTML, responsive CSS, and DOM rendering. The first version stores incidents in memory, which creates a deliberate durability gap when Safari refreshes.
In this step, get ready to:
- Build the semantic dashboard interface.
- Style the responsive layout and interaction states.
- Add in-memory behavior before testing the refresh gap.
Build the semantic interface
Semantic elements give the dashboard a meaningful structure before styling is applied. Connected labels, native controls, and a status region also create the foundation for keyboard and assistive technology support.
- Switch back to index.html in Visual Studio Code.
- Replace the existing body element with this page shell:
<body>
<a class="skip-link" href="#main-content">Skip to dashboard</a>
<header class="site-header">
<div>
<p class="eyebrow">Operations workspace</p>
<h1>Incident Triage Dashboard</h1>
<p class="intro">Log, prioritize, and resolve service incidents from one focused queue.</p>
</div>
</header>
<main id="main-content" class="dashboard">
</main>
<script src="app.js"></script>
</body>
What does this structure do?
- The skip link gives keyboard users a direct route to the dashboard content.
- The header introduces the operations workflow.
- The main-content element becomes the destination for the skip link.
- The script element loads the behavior from app.js.
- Save index.html.
- Refresh Safari to confirm the operations header and dashboard introduction replace the temporary heading.
Still seeing the temporary page?
Confirm that you replaced the complete body element without deleting the closing html element. Check that Safari is still using the Local URL from the server.
Ask for help with the page shell.
The first panel collects an incident title, severity, and status. Every visible label points to a matching control id, so each field has an explicit accessible name.
- Inside main-content, add the incident panel by pasting this code above the closing main tag:
<section class="panel" aria-labelledby="log-heading">
<h2 id="log-heading">Log an incident</h2>
<form id="incident-form">
<label for="incident-title">Incident title</label>
<input id="incident-title" name="title" type="text" maxlength="80" autocomplete="off" required>
<div class="form-row">
<div>
<label for="incident-severity">Severity</label>
<select id="incident-severity" name="severity">
<option>Critical</option>
<option>High</option>
<option selected>Medium</option>
<option>Low</option>
</select>
</div>
<div>
<label for="incident-status">Status</label>
<select id="incident-status" name="status">
<option selected>Open</option>
<option>Monitoring</option>
<option>Resolved</option>
</select>
</div>
</div>
<button class="primary-button" type="submit">Add incident</button>
</form>
<p id="app-status" class="status-message" role="status" aria-atomic="true"></p>
</section>
How is the form accessible?
- Each for value exactly matches the related control id.
- Native inputs, selects, and buttons retain their built-in keyboard behavior.
- The empty role="status" region is ready to announce complete updates through aria-atomic="true".
- Save index.html.
- Refresh Safari to confirm you can see the incident title field, two selectors, and the Add incident button.
Missing a form control?
Check that the entire panel sits between the opening and closing main tags. A missing closing tag can cause the remaining controls to appear in the wrong place.
Help me check the incident form markup.
Summary cards turn the incident array into visible totals. The values begin at zero because JavaScript has not rendered the seeded incidents yet.
- Add the summary section below the incident form panel by pasting:
<section class="panel" aria-labelledby="summary-heading">
<h2 id="summary-heading">Queue summary</h2>
<div class="summary-grid">
<article class="summary-card">
<h3>Total</h3>
<p id="total-count">0</p>
</article>
<article class="summary-card">
<h3>Critical active</h3>
<p id="critical-count">0</p>
</article>
<article class="summary-card">
<h3>Resolved</h3>
<p id="resolved-count">0</p>
</article>
</div>
</section>
What do the summary elements provide?
The three stable element IDs give JavaScript direct targets for derived totals. Each value changes when the incident array changes.
- Save index.html.
- Refresh Safari to confirm the form is followed by Total, Critical active, and Resolved values of 0.
Summary cards out of order?
Confirm that all three article elements are nested inside summary-grid. Check that the summary section closes before the queue section begins.
Help me fix the summary structure.
The queue needs a filter, a list target, and an empty state. JavaScript uses these elements to render only the incidents that match the selected severity.
- Add the queue section below the summary section by pasting:
<section class="panel panel-wide" aria-labelledby="queue-heading">
<div class="queue-header">
<div>
<h2 id="queue-heading">Incident queue</h2>
<p id="queue-description">Showing all incidents.</p>
</div>
<div class="filter-control">
<label for="severity-filter">Filter by severity</label>
<select id="severity-filter">
<option>All</option>
<option>Critical</option>
<option>High</option>
<option>Medium</option>
<option>Low</option>
</select>
</div>
</div>
<ul id="incident-list" class="incident-list"></ul>
<p id="empty-state" class="empty-state" hidden>No incidents match this filter.</p>
</section>
How does the queue support filtering?
- The severity selector provides the active filter value.
- The empty list becomes the rendering target for incident cards.
- The hidden empty state appears when a filter has no matching incidents.
- The queue description reports the current result count.
- Save index.html.
- Refresh Safari to confirm the queue heading, severity filter, and empty list area appear below the summary.
Queue missing from the page?
Confirm that the queue section appears before the closing main tag. Check that the severity selector uses severity-filter as its ID.
Help me locate the queue markup issue.
Use these tabs to compare the complete semantic interface before styling it.
✔️ Awesome, I've got everything!
Great. Save index.html before moving on to the dashboard styles.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Incident Triage Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to dashboard</a>
<header class="site-header">
<div>
<p class="eyebrow">Operations workspace</p>
<h1>Incident Triage Dashboard</h1>
<p class="intro">Log, prioritize, and resolve service incidents from one focused queue.</p>
</div>
</header>
<main id="main-content" class="dashboard">
<section class="panel" aria-labelledby="log-heading">
<h2 id="log-heading">Log an incident</h2>
<form id="incident-form">
<label for="incident-title">Incident title</label>
<input id="incident-title" name="title" type="text" maxlength="80" autocomplete="off" required>
<div class="form-row">
<div>
<label for="incident-severity">Severity</label>
<select id="incident-severity" name="severity">
<option>Critical</option>
<option>High</option>
<option selected>Medium</option>
<option>Low</option>
</select>
</div>
<div>
<label for="incident-status">Status</label>
<select id="incident-status" name="status">
<option selected>Open</option>
<option>Monitoring</option>
<option>Resolved</option>
</select>
</div>
</div>
<button class="primary-button" type="submit">Add incident</button>
</form>
<p id="app-status" class="status-message" role="status" aria-atomic="true"></p>
</section>
<section class="panel" aria-labelledby="summary-heading">
<h2 id="summary-heading">Queue summary</h2>
<div class="summary-grid">
<article class="summary-card">
<h3>Total</h3>
<p id="total-count">0</p>
</article>
<article class="summary-card">
<h3>Critical active</h3>
<p id="critical-count">0</p>
</article>
<article class="summary-card">
<h3>Resolved</h3>
<p id="resolved-count">0</p>
</article>
</div>
</section>
<section class="panel panel-wide" aria-labelledby="queue-heading">
<div class="queue-header">
<div>
<h2 id="queue-heading">Incident queue</h2>
<p id="queue-description">Showing all incidents.</p>
</div>
<div class="filter-control">
<label for="severity-filter">Filter by severity</label>
<select id="severity-filter">
<option>All</option>
<option>Critical</option>
<option>High</option>
<option>Medium</option>
<option>Low</option>
</select>
</div>
</div>
<ul id="incident-list" class="incident-list"></ul>
<p id="empty-state" class="empty-state" hidden>No incidents match this filter.</p>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
Style the dashboard and interaction states
The interface now has the right meaning, but its structure is difficult to scan. Responsive CSS turns those elements into panels, cards, controls, and a queue that adapts to narrow screens.
- Switch back to styles.css in Visual Studio Code.
- Add the page foundation and panel layout by pasting:
:root {
color-scheme: dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
* { box-sizing: border-box; }
body { min-width: 320px; margin: 0; background: #0f172a; }
button, input, select { font: inherit; }
.skip-link { position: absolute; left: 1rem; top: -4rem; z-index: 10; padding: 0.75rem 1rem; border-radius: 0.5rem; background: #f8fafc; color: #0f172a; }
.skip-link:focus { top: 1rem; }
.site-header { padding: 3rem max(1rem, calc((100vw - 1100px) / 2)); background: linear-gradient(135deg, #0f172a, #172554); border-bottom: 1px solid #334155; }
.site-header h1, .site-header p { max-width: 760px; margin-right: auto; }
.site-header h1 { margin-top: 0.25rem; margin-bottom: 0.75rem; font-size: clamp(2rem, 5vw, 3.5rem); }
.eyebrow { margin: 0; color: #7dd3fc; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.intro, #queue-description { color: #cbd5e1; }
.dashboard { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 0.75fr); gap: 1rem; width: min(1100px, calc(100% - 2rem)); margin: 1rem auto 3rem; }
.panel { padding: 1.25rem; border: 1px solid #334155; border-radius: 1rem; background: #1e293b; box-shadow: 0 16px 40px rgb(2 6 23 / 25%); }
.panel h2 { margin-top: 0; }
.panel-wide { grid-column: 1 / -1; }
What do these styles establish?
- The root rules establish the dark color palette and system font stack.
- The dashboard becomes a two-column grid with a full-width queue panel.
- The skip link remains off-screen until keyboard focus reaches it.
- The panels gain visible boundaries against the page background.
- Save styles.css.
- Refresh Safari to confirm the page has a dark header and bordered dashboard panels arranged in two columns.
Styles not appearing?
Confirm that index.html contains the stylesheet link for styles.css. Check that both files are in the same incident-triage-dashboard folder.
Help me connect the dashboard stylesheet.
Controls need clear spacing and visible focus. The focus outline is especially important because it shows keyboard users which element receives the next action.
- Add the form, button, focus, and summary styles below the existing CSS by pasting:
label { display: block; margin-bottom: 0.4rem; color: #f8fafc; font-weight: 700; }
input, select { width: 100%; min-height: 2.75rem; padding: 0.65rem 0.75rem; border: 1px solid #64748b; border-radius: 0.5rem; background: #0f172a; color: #f8fafc; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin: 0.9rem 0; }
button { min-height: 2.75rem; padding: 0.65rem 0.9rem; border: 0; border-radius: 0.5rem; cursor: pointer; font-weight: 800; }
.primary-button { width: 100%; background: #38bdf8; color: #082f49; }
.secondary-button { background: #e2e8f0; color: #0f172a; }
button:hover { filter: brightness(1.08); }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #facc15; outline-offset: 3px; }
.status-message { min-height: 1.5rem; margin-bottom: 0; color: #bae6fd; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.summary-card { padding: 1rem; border-radius: 0.75rem; background: #0f172a; text-align: center; }
.summary-card h3 { margin: 0; color: #cbd5e1; font-size: 0.9rem; }
.summary-card p { margin: 0.4rem 0 0; color: #7dd3fc; font-size: 2rem; font-weight: 800; }
Why reserve status space?
The minimum height on status-message reserves room before feedback appears. Updating the message does not push the surrounding layout down.
The yellow focus outline stays distinct from the dark background. Keyboard position remains easy to track.
- Save styles.css.
- Refresh Safari to confirm the fields fill their columns and the summary values sit in three dark cards.
- Press the Tab key to confirm the active link, field, selector, or button receives a yellow outline.
Focus outline missing?
Use the keyboard to move focus because :focus-visible appears for keyboard navigation. Confirm that the complete selector includes buttons, inputs, selects, and links.
Help me debug the focus styles.
Incident cards need room for metadata and actions without losing the incident title. Badge classes provide a consistent visual language for severity and status.
- Add the queue, card, action, and badge styles by pasting:
.queue-header, .incident-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.filter-control { width: min(220px, 100%); }
.incident-list { display: grid; gap: 0.75rem; margin: 1rem 0 0; padding: 0; list-style: none; }
.incident-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid #475569; border-left: 5px solid #38bdf8; border-radius: 0.75rem; background: #0f172a; }
.incident-card h3 { margin: 0; overflow-wrap: anywhere; }
.incident-meta, .incident-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.incident-meta { margin-top: 0.65rem; }
.badge { display: inline-flex; align-items: center; min-height: 1.75rem; padding: 0.2rem 0.55rem; border-radius: 999px; color: #fff; font-size: 0.8rem; font-weight: 800; }
.badge-critical { background: #991b1b; }
.badge-high { background: #9a3412; }
.badge-medium { background: #854d0e; }
.badge-low { background: #166534; }
.badge-open { background: #1d4ed8; }
.badge-monitoring { background: #6d28d9; }
.badge-resolved { background: #047857; }
.empty-state { padding: 1rem; border: 1px dashed #64748b; border-radius: 0.75rem; color: #cbd5e1; text-align: center; }
How will incident cards adapt?
Each incident card uses one flexible content column plus an action column. The metadata row can wrap when badges need more space.
Severity and status classes map each label to a stable badge color. The text remains available alongside the color.
- Save styles.css.
- Refresh Safari to confirm the severity filter aligns with the queue heading and the empty list area retains consistent spacing.
Queue spacing looks uneven?
Confirm that each selector begins with its leading full stop. Check that incident-list and incident-card remain separate selectors.
Help me check the queue CSS.
The final rules collapse the dashboard as the viewport narrows. This keeps controls and incident actions usable without horizontal scrolling.
- Add the narrow-screen rules at the bottom of styles.css by pasting:
@media (max-width: 760px) {
.dashboard { grid-template-columns: 1fr; }
.panel-wide { grid-column: auto; }
.incident-card { grid-template-columns: 1fr; }
.queue-header, .incident-heading { flex-direction: column; }
.filter-control { width: 100%; }
}
@media (max-width: 520px) {
.form-row, .summary-grid { grid-template-columns: 1fr; }
}
What changes on narrow screens?
- The dashboard becomes one column below 760px.
- Incident content and actions stack vertically.
- The form row and summary cards become one column below 520px.
- Save styles.css.
- Narrow the Safari window to confirm the panels, form controls, summary cards, and queue header stack into one column.
Layout still overflowing?
Confirm that both media rules are at the bottom of styles.css. Check that each media rule has its own closing brace.
Help me fix the narrow layout.
Use these tabs to compare the complete stylesheet before adding behavior.
✔️ Awesome, I've got everything!
Your responsive dashboard shell is ready. Save styles.css before continuing.
ⓧ I'd like to double check the full code
:root {
color-scheme: dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
* { box-sizing: border-box; }
body { min-width: 320px; margin: 0; background: #0f172a; }
button, input, select { font: inherit; }
.skip-link { position: absolute; left: 1rem; top: -4rem; z-index: 10; padding: 0.75rem 1rem; border-radius: 0.5rem; background: #f8fafc; color: #0f172a; }
.skip-link:focus { top: 1rem; }
.site-header { padding: 3rem max(1rem, calc((100vw - 1100px) / 2)); background: linear-gradient(135deg, #0f172a, #172554); border-bottom: 1px solid #334155; }
.site-header h1, .site-header p { max-width: 760px; margin-right: auto; }
.site-header h1 { margin-top: 0.25rem; margin-bottom: 0.75rem; font-size: clamp(2rem, 5vw, 3.5rem); }
.eyebrow { margin: 0; color: #7dd3fc; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.intro, #queue-description { color: #cbd5e1; }
.dashboard { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 0.75fr); gap: 1rem; width: min(1100px, calc(100% - 2rem)); margin: 1rem auto 3rem; }
.panel { padding: 1.25rem; border: 1px solid #334155; border-radius: 1rem; background: #1e293b; box-shadow: 0 16px 40px rgb(2 6 23 / 25%); }
.panel h2 { margin-top: 0; }
.panel-wide { grid-column: 1 / -1; }
label { display: block; margin-bottom: 0.4rem; color: #f8fafc; font-weight: 700; }
input, select { width: 100%; min-height: 2.75rem; padding: 0.65rem 0.75rem; border: 1px solid #64748b; border-radius: 0.5rem; background: #0f172a; color: #f8fafc; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin: 0.9rem 0; }
button { min-height: 2.75rem; padding: 0.65rem 0.9rem; border: 0; border-radius: 0.5rem; cursor: pointer; font-weight: 800; }
.primary-button { width: 100%; background: #38bdf8; color: #082f49; }
.secondary-button { background: #e2e8f0; color: #0f172a; }
button:hover { filter: brightness(1.08); }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #facc15; outline-offset: 3px; }
.status-message { min-height: 1.5rem; margin-bottom: 0; color: #bae6fd; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.summary-card { padding: 1rem; border-radius: 0.75rem; background: #0f172a; text-align: center; }
.summary-card h3 { margin: 0; color: #cbd5e1; font-size: 0.9rem; }
.summary-card p { margin: 0.4rem 0 0; color: #7dd3fc; font-size: 2rem; font-weight: 800; }
.queue-header, .incident-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.filter-control { width: min(220px, 100%); }
.incident-list { display: grid; gap: 0.75rem; margin: 1rem 0 0; padding: 0; list-style: none; }
.incident-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid #475569; border-left: 5px solid #38bdf8; border-radius: 0.75rem; background: #0f172a; }
.incident-card h3 { margin: 0; overflow-wrap: anywhere; }
.incident-meta, .incident-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.incident-meta { margin-top: 0.65rem; }
.badge { display: inline-flex; align-items: center; min-height: 1.75rem; padding: 0.2rem 0.55rem; border-radius: 999px; color: #fff; font-size: 0.8rem; font-weight: 800; }
.badge-critical { background: #991b1b; }
.badge-high { background: #9a3412; }
.badge-medium { background: #854d0e; }
.badge-low { background: #166534; }
.badge-open { background: #1d4ed8; }
.badge-monitoring { background: #6d28d9; }
.badge-resolved { background: #047857; }
.empty-state { padding: 1rem; border: 1px dashed #64748b; border-radius: 0.75rem; color: #cbd5e1; text-align: center; }
@media (max-width: 760px) {
.dashboard { grid-template-columns: 1fr; }
.panel-wide { grid-column: auto; }
.incident-card { grid-template-columns: 1fr; }
.queue-header, .incident-heading { flex-direction: column; }
.filter-control { width: 100%; }
}
@media (max-width: 520px) {
.form-row, .summary-grid { grid-template-columns: 1fr; }
}
Add the in-memory application behavior
The page now looks like a dashboard, but every value is still static. DOM rendering connects an incident array to the visible queue, summary, filter, and status region.
Why plain JavaScript?
This dashboard is a static product with a focused interaction model. Plain JavaScript keeps the work centered on state transitions, accessible feedback, and browser behavior without adding framework scaffolding.
- Switch back to app.js in Visual Studio Code.
- Add the seed data, page references, cloning helper, and in-memory array by pasting:
const seedIncidents = [
{ id: "seed-1", title: "Checkout API latency", severity: "Critical", status: "Open" },
{ id: "seed-2", title: "Delayed email notifications", severity: "Medium", status: "Monitoring" },
{ id: "seed-3", title: "Status page copy update", severity: "Low", status: "Resolved" },
];
const form = document.querySelector("#incident-form");
const titleInput = document.querySelector("#incident-title");
const severityInput = document.querySelector("#incident-severity");
const statusInput = document.querySelector("#incident-status");
const severityFilter = document.querySelector("#severity-filter");
const incidentList = document.querySelector("#incident-list");
const emptyState = document.querySelector("#empty-state");
const queueDescription = document.querySelector("#queue-description");
const appStatus = document.querySelector("#app-status");
const totalCount = document.querySelector("#total-count");
const criticalCount = document.querySelector("#critical-count");
const resolvedCount = document.querySelector("#resolved-count");
function copySeedIncidents() {
return seedIncidents.map((incident) => ({ ...incident }));
}
let incidents = seedIncidents.map((incident) => ({ ...incident }));
What does this foundation hold?
- The seed array provides three realistic incidents for every fresh page load.
- The DOM references connect JavaScript to the controls and output elements.
- The object spread creates new incident objects for the working array.
- The working array exists only in the current page session.
- Save app.js.
- Refresh Safari to confirm the styled dashboard still loads without a script failure.
Dashboard stopped loading?
Check every quote and closing bracket in seedIncidents. Confirm that each selector exactly matches an ID from index.html.
Help me debug the JavaScript foundation.
Each incident becomes a list item containing a title, metadata badges, and a resolve action. The button stores its action and incident ID in data attributes for delegated click handling.
- Add the badge helper below the in-memory array by pasting:
function createBadge(label) {
const badge = document.createElement("span");
badge.className = `badge badge-${label.toLowerCase()}`;
badge.textContent = label;
return badge;
}
What does the badge helper do?
The helper converts a severity or status label into a span with the matching badge class. It uses textContent to place text into the page.
- Save app.js.
- Refresh Safari to confirm the dashboard loads with its form and summary intact.
Seeing a script error?
Confirm that createBadge() sits after the closing line of the in-memory initializer. Check the template literal backticks around the badge class.
Help me fix the badge helper.
- Add the incident-card renderer below createBadge() by pasting:
function createIncidentItem(incident) {
const item = document.createElement("li");
item.className = "incident-card";
const content = document.createElement("div");
const heading = document.createElement("div");
heading.className = "incident-heading";
const title = document.createElement("h3");
title.textContent = incident.title;
heading.append(title);
const metadata = document.createElement("div");
metadata.className = "incident-meta";
metadata.append(createBadge(incident.severity), createBadge(incident.status));
content.append(heading, metadata);
item.append(content);
if (incident.status !== "Resolved") {
const actions = document.createElement("div");
actions.className = "incident-actions";
const resolveButton = document.createElement("button");
resolveButton.type = "button";
resolveButton.className = "secondary-button";
resolveButton.dataset.action = "resolve";
resolveButton.dataset.id = incident.id;
resolveButton.textContent = "Mark resolved";
resolveButton.setAttribute("aria-label", `Mark ${incident.title} as resolved`);
actions.append(resolveButton);
item.append(actions);
}
return item;
}
How is each incident card built?
- The incident title is rendered with textContent.
- Severity and status values become badges through createBadge().
- Active incidents receive a resolve button with an incident-specific accessible label.
- Resolved incidents omit the resolve action.
- Save app.js.
- Refresh Safari to confirm the dashboard still loads with no broken markup.
Page blank after adding the renderer?
Check that createIncidentItem() closes after return item. Confirm that the resolve button data attributes use the current incident ID.
Help me debug the incident renderer.
Rendering now connects the working array to the queue. The summary is derived from the full array, while the visible list follows the selected severity.
- Add the filtering and summary helpers below createIncidentItem() by pasting:
function getVisibleIncidents() {
return severityFilter.value === "All"
? incidents
: incidents.filter((incident) => incident.severity === severityFilter.value);
}
function updateSummary() {
totalCount.textContent = incidents.length;
criticalCount.textContent = incidents.filter(
(incident) => incident.severity === "Critical" && incident.status !== "Resolved",
).length;
resolvedCount.textContent = incidents.filter((incident) => incident.status === "Resolved").length;
}
How are the counts derived?
The visible list depends on the selected severity. The summary always represents the complete working array, so filtering the view does not change the underlying totals.
- Add the queue renderer below updateSummary() by pasting:
function renderIncidents() {
const visibleIncidents = getVisibleIncidents();
incidentList.replaceChildren();
visibleIncidents.forEach((incident) => incidentList.append(createIncidentItem(incident)));
emptyState.hidden = visibleIncidents.length !== 0;
queueDescription.textContent = severityFilter.value === "All"
? `Showing all ${visibleIncidents.length} incidents.`
: `Showing ${visibleIncidents.length} ${severityFilter.value.toLowerCase()} incidents.`;
updateSummary();
}
What happens during a render?
- The existing list contents are cleared.
- Every visible incident becomes a new card.
- The empty state and queue description reflect the selected filter.
- The summary counts are recalculated from the current incident array.
- Add the initial render call at the bottom of app.js by pasting:
renderIncidents();
Why call the renderer now?
The initial call transforms the three seeded objects into visible incident cards. It also replaces the zero summary values with counts derived from those incidents.
- Save app.js.
- Refresh Safari to confirm three incident cards appear and the summary shows Total 3, Critical active 1, and Resolved 1.
That is the first working slice complete. Your static dashboard now renders a real incident queue from JavaScript data.
Seeded incidents not appearing?
Confirm that renderIncidents(); is the final line in the file. Check that the list selector points to incident-list.
Help me restore the seeded queue.
Submitting the form creates a new object and places it at the start of the working array. The handler then resets the form, renders the queue, updates the status message, and returns focus to the title field.
- Insert the submit handler directly above the final renderIncidents(); call by pasting:
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
appStatus.textContent = "Enter an incident title before submitting.";
titleInput.focus();
return;
}
const incident = {
id: `incident-${Date.now()}`,
title,
severity: severityInput.value,
status: statusInput.value,
};
incidents = [incident, ...incidents];
form.reset();
severityFilter.value = "All";
renderIncidents();
appStatus.textContent = `${incident.title} was added to the queue.`;
titleInput.focus();
});
What happens after submission?
A timestamp gives the incident a unique ID for this browser session. The new incident is added without mutating the previous array.
The visible status message confirms the outcome without moving focus away from the title field. The same message is available through the status region.
- Save app.js.
- Refresh Safari.
- Enter Payment gateway timeout in the Incident title field.
- Select Critical from the Severity control.
- Click Add incident.
You should see Payment gateway timeout at the top of the queue. The total becomes 4, the critical-active count becomes 2, and the status region confirms the addition.
New incident not appearing?
Confirm that the handler uses the submit event on incident-form. Check that the final render call remains below the handler.
Help me debug incident submission.
Filtering changes the visible slice without deleting incidents from memory. The handler also writes a concise description into the status region.
- Insert the filter handler above the final renderIncidents(); call by pasting:
severityFilter.addEventListener("change", () => {
renderIncidents();
appStatus.textContent = severityFilter.value === "All"
? "The queue now shows every severity."
: `The queue is filtered to ${severityFilter.value} severity.`;
});
Why does filtering rerender?
Changing the selector changes the result from getVisibleIncidents(). Rerendering replaces the queue with cards from that filtered result.
- Save app.js.
- Refresh Safari.
- Select Critical from the Filter by severity control.
You should see only Checkout API latency because the incident added before this refresh has already disappeared. The queue description and status message both report the Critical filter.
Filter leaves every card visible?
Confirm that the change handler calls renderIncidents(). Check that getVisibleIncidents() compares the incident severity with the selected filter value.
Help me fix severity filtering.
One delegated listener can manage every resolve button in the queue. New cards work automatically because clicks are handled by the stable list element.
- Insert the delegated resolve handler above the final renderIncidents(); call by pasting:
incidentList.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action='resolve']");
if (!button) return;
const incident = incidents.find((item) => item.id === button.dataset.id);
if (!incident) return;
incident.status = "Resolved";
renderIncidents();
appStatus.textContent = `${incident.title} was marked resolved.`;
});
What is event delegation?
Event delegation places one listener on the incident list instead of adding a listener to every button. The clicked button supplies the action and incident ID through its data attributes.
Resolving an incident changes its status before the queue and summary render again. The resolve button then disappears from that card.
- Save app.js.
- Refresh Safari.
- Select All from the severity filter.
- Click Mark resolved for Checkout API latency.
You should see its status change to Resolved and its button disappear. The critical-active count becomes 0, the resolved count becomes 2, and the status region names the resolved incident.
Resolve button does nothing?
Confirm that the list listener searches for button[data-action='resolve']. Check that each resolve button receives its incident ID through dataset.id.
Help me debug delegated resolution.
Use these tabs to compare the complete in-memory application before testing its deliberate limitation.
✔️ Awesome, I've got everything!
Your queue can now add, filter, summarize, and resolve incidents during the current page session.
ⓧ I'd like to double check the full code
const seedIncidents = [
{ id: "seed-1", title: "Checkout API latency", severity: "Critical", status: "Open" },
{ id: "seed-2", title: "Delayed email notifications", severity: "Medium", status: "Monitoring" },
{ id: "seed-3", title: "Status page copy update", severity: "Low", status: "Resolved" },
];
const form = document.querySelector("#incident-form");
const titleInput = document.querySelector("#incident-title");
const severityInput = document.querySelector("#incident-severity");
const statusInput = document.querySelector("#incident-status");
const severityFilter = document.querySelector("#severity-filter");
const incidentList = document.querySelector("#incident-list");
const emptyState = document.querySelector("#empty-state");
const queueDescription = document.querySelector("#queue-description");
const appStatus = document.querySelector("#app-status");
const totalCount = document.querySelector("#total-count");
const criticalCount = document.querySelector("#critical-count");
const resolvedCount = document.querySelector("#resolved-count");
function copySeedIncidents() {
return seedIncidents.map((incident) => ({ ...incident }));
}
let incidents = seedIncidents.map((incident) => ({ ...incident }));
function createBadge(label) {
const badge = document.createElement("span");
badge.className = `badge badge-${label.toLowerCase()}`;
badge.textContent = label;
return badge;
}
function createIncidentItem(incident) {
const item = document.createElement("li");
item.className = "incident-card";
const content = document.createElement("div");
const heading = document.createElement("div");
heading.className = "incident-heading";
const title = document.createElement("h3");
title.textContent = incident.title;
heading.append(title);
const metadata = document.createElement("div");
metadata.className = "incident-meta";
metadata.append(createBadge(incident.severity), createBadge(incident.status));
content.append(heading, metadata);
item.append(content);
if (incident.status !== "Resolved") {
const actions = document.createElement("div");
actions.className = "incident-actions";
const resolveButton = document.createElement("button");
resolveButton.type = "button";
resolveButton.className = "secondary-button";
resolveButton.dataset.action = "resolve";
resolveButton.dataset.id = incident.id;
resolveButton.textContent = "Mark resolved";
resolveButton.setAttribute("aria-label", `Mark ${incident.title} as resolved`);
actions.append(resolveButton);
item.append(actions);
}
return item;
}
function getVisibleIncidents() {
return severityFilter.value === "All"
? incidents
: incidents.filter((incident) => incident.severity === severityFilter.value);
}
function updateSummary() {
totalCount.textContent = incidents.length;
criticalCount.textContent = incidents.filter(
(incident) => incident.severity === "Critical" && incident.status !== "Resolved",
).length;
resolvedCount.textContent = incidents.filter((incident) => incident.status === "Resolved").length;
}
function renderIncidents() {
const visibleIncidents = getVisibleIncidents();
incidentList.replaceChildren();
visibleIncidents.forEach((incident) => incidentList.append(createIncidentItem(incident)));
emptyState.hidden = visibleIncidents.length !== 0;
queueDescription.textContent = severityFilter.value === "All"
? `Showing all ${visibleIncidents.length} incidents.`
: `Showing ${visibleIncidents.length} ${severityFilter.value.toLowerCase()} incidents.`;
updateSummary();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
appStatus.textContent = "Enter an incident title before submitting.";
titleInput.focus();
return;
}
const incident = {
id: `incident-${Date.now()}`,
title,
severity: severityInput.value,
status: statusInput.value,
};
incidents = [incident, ...incidents];
form.reset();
severityFilter.value = "All";
renderIncidents();
appStatus.textContent = `${incident.title} was added to the queue.`;
titleInput.focus();
});
severityFilter.addEventListener("change", () => {
renderIncidents();
appStatus.textContent = severityFilter.value === "All"
? "The queue now shows every severity."
: `The queue is filtered to ${severityFilter.value} severity.`;
});
incidentList.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action='resolve']");
if (!button) return;
const incident = incidents.find((item) => item.id === button.dataset.id);
if (!incident) return;
incident.status = "Resolved";
renderIncidents();
appStatus.textContent = `${incident.title} was marked resolved.`;
});
renderIncidents();
The queue now works during the current page session. Its data still lives only in the JavaScript array, so a refresh creates a fresh copy of the three seeded incidents.
- Enter Search service errors in the Incident title field.
- Select High from the Severity control.
- Click Add incident.
- Select High from the severity filter.
- Confirm that Search service errors is the only visible incident.
- Select All from the severity filter.
- Click Mark resolved for Search service errors.
Before you refresh, do you expect the added incident and its resolved state to survive?
- Refresh Safari to test the current in-memory state.
You will see the queue reset to Checkout API latency, Delayed email notifications, and Status page copy update. The summary returns to Total 3, Critical active 1, and Resolved 1.
Why did the incident disappear?
Refreshing Safari runs app.js from the beginning. The working array is recreated from seedIncidents, so additions and status changes from the previous page session are discarded.
This reset is the intended shortfall. The next step adds browser-local persistence so the same interactions survive a refresh.
You now have a responsive, accessible triage queue with working add, filter, summary, and resolve interactions. Next, you will make those state changes durable across browser refreshes.
Persist the Queue and Document It
The interactive queue now supports the full triage workflow. However, every added incident disappears when Safari refreshes because the current JavaScript array only lives in memory.
This step stores the queue with localStorage. You will also turn the generated README.md into evidence of your product decisions.
In this step, get ready to:
- Load saved incidents from browser storage.
- Preserve added incidents after a refresh.
- Document the dashboard for recruiters.
Load incidents from browser storage
Web Storage associates saved data with the page's origin. The local preview keeps its own queue across browser sessions.
Why use the local server?
The behavior of localStorage can vary for pages opened through a file: URL. The local server gives the dashboard a consistent origin for storage.
- In app.js, add the storage key above const seedIncidents.
- Replace the temporary let incidents initializer with the loading block shown below.
const STORAGE_KEY = "incident-triage-incidents-v1";
function loadIncidents() {
const storedIncidents = localStorage.getItem(STORAGE_KEY);
if (!storedIncidents) return copySeedIncidents();
try {
const parsedIncidents = JSON.parse(storedIncidents);
return Array.isArray(parsedIncidents) ? parsedIncidents : copySeedIncidents();
} catch {
return copySeedIncidents();
}
}
let incidents = loadIncidents();
What does this code do?
- The STORAGE_KEY gives this queue a stable name in browser storage.
- localStorage.getItem() reads the saved string for that key.
- JSON.parse() restores the saved string as an array.
- copySeedIncidents() supplies fresh seed data when storage is empty or invalid.
- Save app.js.
- Refresh the dashboard in Safari.
You will see the three seeded incidents render again. This confirms that the new loading path can provide a valid queue when no saved data exists.
Does the queue stay empty?
Check that loadIncidents() appears below copySeedIncidents(). Confirm that the initializer calls the function with matching capitalization.
Help me check the incident loading code in app.js.
Loading creates one half of persistence. The queue also needs to serialize each new version before a refresh can restore it.
- Add saveIncidents() below the new incidents initializer in app.js.
- Add the save call below the line that inserts a submitted incident.
function saveIncidents() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(incidents));
}
incidents = [incident, ...incidents];
saveIncidents();
form.reset();
How does saving work?
- JSON.stringify() converts the incident array into the string format that browser storage accepts.
- localStorage.setItem() writes that string under STORAGE_KEY.
- The submit handler saves immediately after placing the new incident at the start of the array.
- Save app.js.
- Enter Database failover test in the incident title field.
- Click Add incident.
- Before refreshing Safari, predict whether the new incident will remain in the queue.
- Refresh Safari.
The new incident returns after the refresh. That is the first durable state change in your dashboard.
Did the new incident disappear?
Confirm that saveIncidents() runs immediately after incidents = [incident, ...incidents]. Check that the same STORAGE_KEY is used for reading and writing.
Help me find why added incidents are not surviving a Safari refresh.
Persist resolutions and test feedback
A durable queue must preserve every state transition. Resolving an incident changes the same array, so that handler needs its own save call.
- In app.js, find the delegated click handler near the bottom of the file.
- Add the save call immediately after the incident status changes.
incident.status = "Resolved";
saveIncidents();
renderIncidents();
Why save before rendering?
The handler first updates the incident object. It then stores the changed array before rendering the new status and summary counts.
- Save app.js.
- In Safari, click Mark resolved on an active incident.
- Before refreshing Safari, predict whether the resolved badge and summary count will return.
- Refresh Safari.
You will see the incident remain resolved. The resolved summary count also returns unchanged.
- Press the Tab key repeatedly to move through the form controls.
- Confirm that each focused control has a yellow outline.
- Change the severity filter.
- Confirm that the visible status message describes the active filter.
- Add another incident.
- Confirm that the status message names the added incident.
Keyboard focus stays on the active workflow while the status region reports each result. Validation is the only path that deliberately returns focus to the title field.
Is a resolved incident returning as active?
Check that the resolve handler calls saveIncidents() before renderIncidents(). Confirm that the call sits inside the delegated click handler.
Help me debug why resolved incidents return as active after a refresh.
Use the tabs below to compare your completed app.js with the final persistence implementation.
✔️ Awesome, I've got everything!
Your queue now loads saved data and preserves both additions and resolutions.
ⓧ I'd like to double check the full code
const STORAGE_KEY = "incident-triage-incidents-v1";
const seedIncidents = [
{ id: "seed-1", title: "Checkout API latency", severity: "Critical", status: "Open" },
{ id: "seed-2", title: "Delayed email notifications", severity: "Medium", status: "Monitoring" },
{ id: "seed-3", title: "Status page copy update", severity: "Low", status: "Resolved" },
];
const form = document.querySelector("#incident-form");
const titleInput = document.querySelector("#incident-title");
const severityInput = document.querySelector("#incident-severity");
const statusInput = document.querySelector("#incident-status");
const severityFilter = document.querySelector("#severity-filter");
const incidentList = document.querySelector("#incident-list");
const emptyState = document.querySelector("#empty-state");
const queueDescription = document.querySelector("#queue-description");
const appStatus = document.querySelector("#app-status");
const totalCount = document.querySelector("#total-count");
const criticalCount = document.querySelector("#critical-count");
const resolvedCount = document.querySelector("#resolved-count");
function copySeedIncidents() {
return seedIncidents.map((incident) => ({ ...incident }));
}
function loadIncidents() {
const storedIncidents = localStorage.getItem(STORAGE_KEY);
if (!storedIncidents) return copySeedIncidents();
try {
const parsedIncidents = JSON.parse(storedIncidents);
return Array.isArray(parsedIncidents) ? parsedIncidents : copySeedIncidents();
} catch {
return copySeedIncidents();
}
}
let incidents = loadIncidents();
function saveIncidents() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(incidents));
}
function createBadge(label) {
const badge = document.createElement("span");
badge.className = `badge badge-${label.toLowerCase()}`;
badge.textContent = label;
return badge;
}
function createIncidentItem(incident) {
const item = document.createElement("li");
item.className = "incident-card";
const content = document.createElement("div");
const heading = document.createElement("div");
heading.className = "incident-heading";
const title = document.createElement("h3");
title.textContent = incident.title;
heading.append(title);
const metadata = document.createElement("div");
metadata.className = "incident-meta";
metadata.append(createBadge(incident.severity), createBadge(incident.status));
content.append(heading, metadata);
item.append(content);
if (incident.status !== "Resolved") {
const actions = document.createElement("div");
actions.className = "incident-actions";
const resolveButton = document.createElement("button");
resolveButton.type = "button";
resolveButton.className = "secondary-button";
resolveButton.dataset.action = "resolve";
resolveButton.dataset.id = incident.id;
resolveButton.textContent = "Mark resolved";
resolveButton.setAttribute("aria-label", `Mark ${incident.title} as resolved`);
actions.append(resolveButton);
item.append(actions);
}
return item;
}
function getVisibleIncidents() {
return severityFilter.value === "All"
? incidents
: incidents.filter((incident) => incident.severity === severityFilter.value);
}
function updateSummary() {
totalCount.textContent = incidents.length;
criticalCount.textContent = incidents.filter(
(incident) => incident.severity === "Critical" && incident.status !== "Resolved",
).length;
resolvedCount.textContent = incidents.filter((incident) => incident.status === "Resolved").length;
}
function renderIncidents() {
const visibleIncidents = getVisibleIncidents();
incidentList.replaceChildren();
visibleIncidents.forEach((incident) => incidentList.append(createIncidentItem(incident)));
emptyState.hidden = visibleIncidents.length !== 0;
queueDescription.textContent = severityFilter.value === "All"
? `Showing all ${visibleIncidents.length} incidents.`
: `Showing ${visibleIncidents.length} ${severityFilter.value.toLowerCase()} incidents.`;
updateSummary();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
appStatus.textContent = "Enter an incident title before submitting.";
titleInput.focus();
return;
}
const incident = {
id: `incident-${Date.now()}`,
title,
severity: severityInput.value,
status: statusInput.value,
};
incidents = [incident, ...incidents];
saveIncidents();
form.reset();
severityFilter.value = "All";
renderIncidents();
appStatus.textContent = `${incident.title} was added to the queue.`;
titleInput.focus();
});
severityFilter.addEventListener("change", () => {
renderIncidents();
appStatus.textContent = severityFilter.value === "All"
? "The queue now shows every severity."
: `The queue is filtered to ${severityFilter.value} severity.`;
});
incidentList.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action='resolve']");
if (!button) return;
const incident = incidents.find((item) => item.id === button.dataset.id);
if (!incident) return;
incident.status = "Resolved";
saveIncidents();
renderIncidents();
appStatus.textContent = `${incident.title} was marked resolved.`;
});
renderIncidents();
Turn the README into interview evidence
A recruiter needs context before the code becomes meaningful. Your README.md will explain the workplace problem and the behavior you built.
- Switch back to README.md in Visual Studio Code.
- Replace the generated repository heading with the project overview below.
# Incident Triage Dashboard
A responsive, accessible frontend dashboard for logging, filtering, summarizing, and resolving service incidents.
## Problem
Operations teams need a fast way to see active incidents, identify critical work, and communicate status without scanning an unstructured list.
## Features
- Add incidents with severity and status
- Filter the queue by severity
- Mark active incidents as resolved
- Recalculate total, critical-active, and resolved counts
- Preserve incident state across refreshes with browser storage
- Announce interaction results without moving keyboard focus
- Adapt the layout for desktop and narrow screens
What does this section communicate?
The opening frames the dashboard as a response to an operations problem. The feature list connects that problem to observable product behavior.
- Save README.md.
- Confirm that the file now shows the project title, problem statement, and seven feature bullets.
Is the README still one heading?
Confirm that you replaced the original generated line before pasting the new sections. Check that each heading starts on its own line.
Help me fix the Markdown structure in README.md.
The next sections show how to run the project locally. They also explain the accessibility decisions that shape the interface.
- Append the local run instructions and accessibility choices below the feature list.
## Run locally
```shell
npx serve@14.2.6
```
Open the Local URL printed in Terminal.
## Accessibility choices
- Visible labels are explicitly connected to their controls.
- Native form controls and buttons preserve keyboard behavior.
- A skip link moves keyboard users directly to the dashboard.
- Strong focus outlines remain visible on interactive elements.
- Dynamic outcomes are written into a `role="status"` region with `aria-atomic="true"`.
- User-entered incident titles are rendered with `textContent` rather than injected HTML.
Why document these choices?
The run section makes the project reproducible with serve 14.2.6. The accessibility section shows that labels, keyboard behavior, focus, announcements, and safe text rendering were deliberate engineering decisions.
- Save README.md.
- Confirm that the file includes the local command and six accessibility choices.
The final sections explain the boundary of browser-local persistence. They also show how the static project is intended to reach users.
- Append the trade-off and deployment sections below the accessibility list.
- Personalize the final Trade-offs sentence while retaining an authenticated API and persistent database as your production recommendation.
## Trade-offs
This focused demo stores a small array in `localStorage`. The data is scoped to one browser origin and is not shared across devices or users. A multi-user production version would replace this layer with an authenticated API and persistent database while retaining the same UI state transitions.
## Deployment
The static site is deployed from the repository root with GitHub Pages.
What does the trade-off show?
Browser storage fits a focused single-browser demonstration. Shared production state needs identity, an API, and a database.
The deployment note prepares the repository for GitHub Pages in the next step.
- Save README.md.
- Confirm that the Trade-offs section names the browser-origin limitation.
- Confirm that the Deployment section names GitHub Pages.
Is the trade-off unclear?
Keep the recommendation focused on shared state. Name the authenticated API as the application boundary and the persistent database as the shared data store.
Help me refine my production trade-off sentence without overstating this demo.
Use the tabs below to compare your completed README.md with the project version. Keep your personalized production recommendation when you compare the Trade-offs section.
✔️ Awesome, I've got everything!
Your README now explains the product, its accessibility choices, its storage boundary, and its deployment target.
ⓧ I'd like to double check the full code
# Incident Triage Dashboard
A responsive, accessible frontend dashboard for logging, filtering, summarizing, and resolving service incidents.
## Problem
Operations teams need a fast way to see active incidents, identify critical work, and communicate status without scanning an unstructured list.
## Features
- Add incidents with severity and status
- Filter the queue by severity
- Mark active incidents as resolved
- Recalculate total, critical-active, and resolved counts
- Preserve incident state across refreshes with browser storage
- Announce interaction results without moving keyboard focus
- Adapt the layout for desktop and narrow screens
## Run locally
```shell
npx serve@14.2.6
```
Open the Local URL printed in Terminal.
## Accessibility choices
- Visible labels are explicitly connected to their controls.
- Native form controls and buttons preserve keyboard behavior.
- A skip link moves keyboard users directly to the dashboard.
- Strong focus outlines remain visible on interactive elements.
- Dynamic outcomes are written into a `role="status"` region with `aria-atomic="true"`.
- User-entered incident titles are rendered with `textContent` rather than injected HTML.
## Trade-offs
This focused demo stores a small array in `localStorage`. The data is scoped to one browser origin and is not shared across devices or users. A multi-user production version would replace this layer with an authenticated API and persistent database while retaining the same UI state transitions.
## Deployment
The static site is deployed from the repository root with GitHub Pages.
The final check proves that the saved queue and its documentation work together. Use a distinct incident title so the restored data is easy to spot.
- Return to the Terminal panel from earlier.
- Start the local preview if it is no longer running by using this command:
npx serve@14.2.6
What should the command show?
Terminal prints a Local URL for the project folder. Keep the server running while you complete the browser check.
- Open the Local URL in Safari.
- Add an incident named Recruiter demo persistence check.
- Mark a different active incident as resolved.
- Before refreshing Safari, predict which incident details and summary counts will return.
- Refresh Safari.
You will see the uniquely named incident and resolved state return unchanged. The summary counts will match the restored queue.
- Return to README.md in VS Code.
- Confirm that it explains the problem, features, accessibility choices, storage trade-off, local run command, and GitHub Pages deployment.
That is the durability gap closed. Your dashboard now survives refreshes while its README explains the engineering judgment behind the result.
Your local product is durable and documented. Next up, you will publish the repository and turn it into a live demo that employers can inspect.
Publish Your Live Demo
Your dashboard now preserves incidents across refreshes. Its README explains the accessibility choices behind the experience.
A local project remains invisible to an employer. Publishing a public GitHub repository turns your source history into employer evidence.
GitHub Pages serves the same static files through a public URL. You will finish with a live product that an employer can test.
In this step, get ready to:
- Create a meaningful commit for the complete project.
- Publish the source in a public repository.
- Deploy a persistent live demo with GitHub Pages.
Commit and publish the repository
A Git commit captures a named snapshot of your finished project. Publishing that snapshot gives reviewers access to your code history.
- Switch back to GitHub Desktop.
- Select incident-triage-dashboard in the repository list.
- Review the Changes list.
- Confirm that index.html, styles.css, app.js, and README.md are included.
- Enter Build accessible incident triage dashboard in the Summary field.
- Click Commit to the current branch.
The Changes list clears after the commit. You now have a named snapshot of the complete dashboard.
Publishing makes every tracked file visible. This project needs no credentials.
A quick file review prevents an accidental secret from becoming public.
- Review the committed files for secrets or API keys.
- Click Publish repository.
You will see a publishing dialog for the repository. This is where you choose its owner and visibility.
- Leave Keep this code private deselected.
- Select your verified personal account under Organization if GitHub Desktop asks where to publish.
The dialog now points to your personal account. It also shows that the repository will be public.
- Click Publish Repository.
Good work. Your source now has a public home that an employer can inspect.
- Click Repository in the GitHub Desktop menu.
- Click View on GitHub.
The public repository opens in your browser. You should see index.html, styles.css, app.js, and README.md in the file list.
Cannot publish the repository?
- Confirm that GitHub Desktop is signed in to your verified personal account.
- Confirm that incident-triage-dashboard is the selected repository.
- Check that the local commit completed before selecting Publish repository.
Help me diagnose why I cannot publish this repository from GitHub Desktop.
Configure GitHub Pages
The public repository exposes your source. GitHub Pages turns those files into a website that runs in a browser.
Why GitHub Pages for this dashboard?
GitHub Pages publishes the same repository an employer inspects. A separate platform such as Vercel adds another account integration.
This dashboard only needs static hosting. Keeping the source and deployment together makes the project easier to review.
- Click Settings in the public repository.
- Select Pages in the repository sidebar.
The Pages screen shows the publishing controls under Build and deployment.
- Set Source to Deploy from a branch.
The branch and folder dropdowns become available. These controls define which repository files GitHub Pages publishes.
- Select the current default branch in the branch dropdown.
- Select / in the folder dropdown.
The publishing source now points to the repository root on your default branch.
- Click Save.
The first deployment can take up to 10 minutes. A short wait here is normal.
- Refresh the Pages settings until Visit site appears.
The Visit site button confirms that GitHub Pages has produced a public deployment.
Still waiting for the live site?
- Confirm that the repository is public.
- Return to Pages to confirm that Source shows Deploy from a branch.
- Confirm that the folder dropdown shows /.
Help me troubleshoot why my GitHub Pages deployment is not becoming available.
Test the deployed product
The live URL has a different browser origin from your local preview. Its localStorage data therefore starts as a separate store.
Before you open the deployment, do you expect your locally added incidents to appear on the public site?
- Click Visit site on the Pages settings screen.
You should see the complete dashboard with the three seeded incidents. Your local preview incidents do not appear because the public URL has its own browser storage.
- Enter Public demo persistence check in the Incident title field.
- Select High in the Severity field.
- Click Add incident.
You will see a status message naming the new incident. The total count increases by one.
That is the public dashboard creating its own persistent browser state.
Before you refresh, do you expect the new incident to remain in the public queue?
- Refresh the live page in Safari.
The new incident returns after the refresh. Its severity remains High.
- Select High in the Filter by severity control.
The queue now shows incidents with High severity. The status region reports the active filter.
- Click Mark resolved for Public demo persistence check.
The incident receives its resolved state. The resolved summary count increases.
Before the final refresh, do you expect that resolved state to survive on the deployed origin?
- Refresh the live page in Safari.
The incident remains resolved after the refresh. The summary counts still match the saved queue.
- Copy the live GitHub Pages URL into your job-search notes.
You have shipped the project beyond your Mac. Your live dashboard now gives employers a working product to inspect.
Secret mission
Add Safe Incident Deletion
Incident queues sometimes need a record removed completely. In this mission, you will add confirmed deletion that persists after refreshes and reports each successful removal through the dashboard's accessible status region.
Clean Up Your Resources
Clean Up Your Resources
Your dashboard uses no metered cloud resources or paid API calls. Keeping the public repository and live site on GitHub Free costs $0.
Resources you used:
- A local Git repository in the incident-triage-dashboard folder.
- A public GitHub repository named incident-triage-dashboard.
- A public GitHub Pages deployment.
- Incident data saved in localStorage for the local preview and deployed site.
Keep everything running
No action is needed. This option keeps every version of the dashboard available for continued work.
- Keep the public repository and GitHub Pages deployment available.
- Retain the local incident-triage-dashboard folder on your Mac.
- Leave the browser-local incident data intact for your next session.
Pause - I'll come back to this later
This option takes the live demo offline while preserving your source code. Your local folder and browser-local incident data remain intact.
- Return to the public incident-triage-dashboard repository.
- Click Settings.
- Select Pages.
- Open the menu beside Your site is live at.
- Select Unpublish site.
The live dashboard goes offline. Your public repository remains available with its complete source code.
Delete - I don't want to use this again
This option removes the project resources so you can start fresh. Repository deletion is permanent, so clear the saved incident data before removing its URLs.
Clear browser-local incident data:
The local preview and GitHub Pages site use separate browser origins. Clearing both copies is a little fiddly because each origin stores its own incidents.
- Open an integrated terminal in Visual Studio Code for the open incident-triage-dashboard folder.
- Start the local preview by running:
npx serve@14.2.6
What does this command do?
The serve package hosts the static files from your open incident-triage-dashboard folder. The Local URL lets you clear that origin's saved incidents through the dashboard.
- Open the Local URL printed in the terminal with Safari.
- Set Filter by severity to All.
- Click Delete for every incident in the local queue.
- Approve each confirmation.
- Refresh the local page.
Good. The local queue stays empty after the refresh.
Local preview not loading?
Confirm the local server is still running from the incident-triage-dashboard folder. Help me troubleshoot the local dashboard preview.
Clear the deployed incident data:
- Return to the public incident-triage-dashboard repository on GitHub.
- Click Settings.
- Select Pages.
- Click Visit site.
- Set Filter by severity to All.
- Click Delete for every incident in the deployed queue.
- Approve each confirmation.
- Refresh the deployed page.
That's both storage origins cleared. The deployed queue stays empty after the refresh.
Delete the remote repository and deployment:
- Return to the public repository.
- Click Settings.
- Scroll to Danger Zone.
- Select Delete this repository.
- Select I want to delete this repository.
- Select I have read and understand these effects.
- Enter incident-triage-dashboard in the repository-name confirmation.
- Click Delete this repository.
GitHub removes the remote repository and its Pages site. The former repository URL no longer shows your project.
Delete the local repository:
- Close Visual Studio Code to stop the local preview.
- Use Spotlight to open Finder.
- Search your Mac for incident-triage-dashboard.
- Delete the incident-triage-dashboard folder in Finder.
- Search your Mac for incident-triage-dashboard again.
You should no longer find the local repository folder. Cleanup complete. Your dashboard no longer has a local copy or a public deployment.
Nice Work!
Nice Work!
You did it! You built an accessible incident triage dashboard that is live on GitHub Pages.
You've learned how to:
- Built an interactive triage workflow for logging service incidents. Added severity filters. Added one-click resolution. Used DOM rendering to keep the queue summary synchronized. Used event delegation to handle incident actions.
- Created an accessible browser experience with explicit form labels. Kept focus outlines visible during keyboard navigation. Announced dynamic outcomes through role="status". Preserved queue data across refreshes with localStorage.
- Wrote a recruiter-focused README that explains the product problem. Documented the accessibility choices. Documented the browser-storage trade-off. Managed the project history with GitHub Desktop. Published the source in a public repository. Shipped a live demo for employers to inspect.
- Secret Mission: Added confirmed incident deletion. Kept cancelled deletions safe. Persisted approved deletions across refreshes. Announced each deletion through accessible status feedback.
Ready to quiz yourself?