Build a Docker Productivity Launchpad

Build a local productivity dashboard with Docker volumes, ports, and networks.

Introduction

30 Second Summary

Starting the day often means reopening the same links before useful work can begin. Your priority can also drift away from the record of what you finished.

In this project, you will use Docker Desktop to package a personal productivity dashboard as a reusable container image. Your dashboard keeps its saved state after you replace the container.

What You'll Build

Picture opening your local dashboard each morning to start a focus session around the one priority that matters most.

By the end of this project, you'll have:

  • A daily launchpad where you set one priority beside a configurable focus timer. Pinned links stay close at hand. Recent focused minutes show your progress.
  • A private local dashboard you can open at http://localhost:8080 from your Windows computer.
  • A storage and networking proof that keeps saved data after container replacement through a named volume. A temporary checker reaches the launchpad using container-name DNS.
  • Secret Mission: Run separate Work and Study profiles from the same image. Each profile uses its own port and saved data.

Are there any prerequisites?

You'll need a supported Windows 10 or Windows 11 computer with virtualization enabled plus an internet connection. Step 1 helps you verify Docker Desktop and Visual Studio Code with installation guidance if either is missing.

Before We Start

Before the hands-on work begins, this is your moment to commit to building a personal productivity launchpad. Choosing where it fits into your day gives the dashboard a clear purpose from the start.

Prepare Docker Desktop and VS Code

Your launchpad needs a working container engine before its source can become an image. Docker Desktop provides that engine on Windows.

Visual Studio Code keeps the project folders in one window. Its integrated PowerShell terminal gives you a consistent place to run every command.

In this step, get ready to:
  • Verify the WSL 2 foundation for Docker Desktop.
  • Confirm Docker Desktop can communicate with its container engine.
  • Open the new launchpad folder structure in Visual Studio Code.
Prepare the Docker engine

Windows Subsystem for Linux 2 provides the Linux environment used by Docker Desktop. Docker Desktop requires WSL version 2.1.5 or later for this backend.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search box.
  • Press Enter to open PowerShell.
  • Check the installed WSL version by running this command:
wsl --version

What does this command show?

The command prints version information for WSL and its supporting components. The first WSL version number determines which outcome below matches your computer.

✔️ I see the required WSL version

Your WSL foundation is ready. A version of 2.1.5 or later supports the Docker Desktop backend used in this project.

ⓧ I see an older version

Your installed WSL version needs an update before Docker Desktop can rely on it. Run the update from an administrator PowerShell window.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search box.
  • Select Run as administrator from the search options.
  • Approve the Windows permission request if it appears.
  • Update WSL by running this command:
wsl --update

What does this command do?

The command downloads the latest available WSL components. The update can take several minutes while Windows retrieves the required files.

  • Restart Windows if the update requests it.
  • Return to PowerShell after the restart.
  • Run the WSL version check above again.

ⓧ The command is not recognized

Windows needs WSL installed before Docker Desktop can use its default backend. Run the installation from an administrator PowerShell window.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search box.
  • Select Run as administrator from the search options.
  • Approve the Windows permission request if it appears.
  • Install WSL by running this command:
wsl --install

What does this command do?

The command enables the required Windows components and installs WSL. Windows may request a restart before the new environment becomes available.

  • Restart Windows when requested.
  • Return to PowerShell after the restart.
  • Run the WSL version check above again.

Still blocked by WSL?

Confirm that hardware virtualization is enabled on your computer. A managed work or school device may require help from its administrator.

Share the output without including personal information when you ask for help with your WSL setup.

The Docker command-line client and the Docker engine are separate parts. The next check proves that PowerShell can reach both parts.

  • Check the Docker client and engine by running this command:
docker version

What does this command check?

The Client section describes the Docker command-line client. The Server section appears when that client can communicate with the running engine.

✔️ I see Client and Server

That is the Docker connection working from end to end. PowerShell can now send container commands to the running Docker Desktop engine.

ⓧ I see Client without Server

The client is installed. The missing server response means Docker Desktop has not finished starting its engine.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop into the search box.
  • Press Enter to start Docker Desktop.

The first engine start can take several minutes while WSL initializes. A longer wait here is normal.

  • Wait until Docker Desktop reports that its engine is running.
  • Return to PowerShell.
  • Run the Docker version check above again.

ⓧ PowerShell cannot find docker

Installing Docker Desktop may raise a licensing question. Its published free-use terms cover this personal learning project.

  • Open the Docker Desktop Windows installation page in your browser.
  • Download the Windows installer that matches your processor.
  • Run the downloaded Docker Desktop Installer.exe file.
  • Choose the recommended per-user installation.
  • Select WSL 2 on the Configuration page.
  • Select Close when the installation finishes.

The first engine start can take several minutes while Docker Desktop prepares its WSL environment.

  • Start Docker Desktop from Windows search.
  • Read the Docker Subscription Service Agreement if it appears.
  • Select Accept to continue.
  • Wait until Docker Desktop reports that its engine is running.
  • Return to PowerShell.
  • Run the Docker version check above again.

Docker still not answering?

  • Restart Docker Desktop after any WSL update or Windows restart.
  • Confirm Docker Desktop is using its WSL 2 backend.
  • Use the command output when you ask for help with the Docker engine.
Install or confirm Visual Studio Code

The Windows User setup is the recommended Visual Studio Code installation for most people. It adds the code command to your PATH after applications using the old PATH are restarted.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search box.

✔️ Visual Studio Code is listed

  • Select Visual Studio Code from the search results.

The editor opens with no project folder selected yet.

ⓧ Visual Studio Code is missing

  • Open the Visual Studio Code Windows setup page in your browser.
  • Download the recommended Windows User setup.
  • Run the downloaded User setup installer.
  • Complete the installation using the User setup defaults.
  • Close any Visual Studio Code window opened by the installer.
  • Start Visual Studio Code from Windows search.

Visual Studio Code now starts from a fresh Windows environment with its PATH entry available.

Why use the User setup?

The User setup installs Visual Studio Code for your Windows account without requiring administrator permissions. It also supports background updates.

🙋‍♀️ Having trouble installing VS Code?

Confirm that you downloaded the Windows User setup from the official page. A managed computer may block software installation through organization policy.

Describe what the installer shows when you ask for help with the VS Code installation.

Create and open the project folder

The project needs one predictable folder for every source file you add later. The empty static subfolder reserves the location for the browser interface.

Finding your Windows account folder can be a little fiddly. It is the folder under C:\Users that matches your Windows sign-in name.

  • Press the Windows key to open Windows search.
  • Type File Explorer into the search box.
  • Press Enter to open File Explorer.
  • Click the File Explorer address bar.
  • Enter C:\Users into the address bar.
  • Press Enter to load that location.
  • Open the folder that matches your Windows sign-in name.

You are now inside the account folder represented by C:\Users\<your-user> in this project.

✔️ Projects already exists

  • Open the Projects folder.

ⓧ Projects is missing

  • Select New in the File Explorer toolbar.
  • Select Folder.
  • Enter Projects as the folder name.
  • Press Enter to create the folder.
  • Open the new Projects folder.
  • Select New in the File Explorer toolbar.
  • Select Folder.
  • Enter productivity-launchpad as the folder name.
  • Press Enter to create the project folder.
  • Open the new productivity-launchpad folder.

File Explorer now shows the empty folder at C:\Users\<your-user>\Projects\productivity-launchpad.

  • Select New in the File Explorer toolbar.
  • Select Folder.
  • Enter static as the folder name.
  • Press Enter to create the subfolder.

You can now see an empty static folder inside productivity-launchpad.

Visual Studio Code may ask whether you trust this folder. You created the folder yourself, so confirming trust is safe.

  • Switch back to Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder.
  • Choose C:\Users\<your-user>\Projects\productivity-launchpad in the folder picker.
  • Select Select Folder.
  • Confirm that you trust the folder if Visual Studio Code asks.

The Explorer sidebar now shows productivity-launchpad with the empty static subfolder.

  • Select Terminal from the top menu.
  • Select New Terminal.
  • Choose PowerShell from the terminal profile menu if another shell opens.
  • Confirm the Visual Studio Code command is available by running:
code --version

What does this check prove?

A version in the output proves the Visual Studio Code installer added the code command to PATH. Future commands can now launch folders directly in the editor.

You should see a Visual Studio Code version followed by build information. The integrated PowerShell terminal remains ready for the project commands ahead.

Command still unavailable?

  • Close every Visual Studio Code window.
  • Start Visual Studio Code again from Windows search.
  • Open a fresh integrated PowerShell terminal.
  • Ask for help if the command remains unavailable by selecting help me fix the VS Code PATH entry.

Before you run this final check, do you expect both parts of Docker to answer inside the Visual Studio Code terminal?

  • Verify the Docker connection from the integrated PowerShell terminal by running:
docker version

What does the final check prove?

The integrated terminal uses the same Docker client you tested earlier. Seeing both sections here proves the editor can reach the running engine.

You should see separate Client and Server sections in the terminal. That output confirms Docker Desktop is ready for the image build in the next step.

Missing the Server section?

  • Confirm Docker Desktop is still running.
  • Wait for its engine to finish starting.
  • Run the final Docker check again.
  • Use the terminal output when you ask for help with the missing Docker server response.

✔️ Awesome, I've got everything!

Your tools are ready. Keep the integrated PowerShell terminal open inside Visual Studio Code.

ⓧ I'd like to double check the full code

There are no project files to compare yet. Your setup should match these exact artifacts.

  • The folder C:\Users\<your-user>\Projects\productivity-launchpad exists and is open in Visual Studio Code.
  • The folder C:\Users\<your-user>\Projects\productivity-launchpad\static exists as an empty subfolder.
  • The integrated terminal uses PowerShell.
  • The Docker check prints both Client and Server sections.

Your container engine now answers from inside the editor. Next, you will turn this empty folder into a working productivity dashboard image.

Build and Open the Launchpad

Your container engine is running. Your project folder is ready in Visual Studio Code. Now you can turn that empty folder into a working dashboard.

This step connects your source files to a Dockerfile. Docker uses those files to build a reusable container image. A running container publishes the dashboard to your Windows computer through a local port.

In this step, get ready to:
  • Create the Python application, browser interface, and Dockerfile.
  • Build the launchpad:1.0 image and start its container.
  • Open the dashboard and inspect its published port in Docker Desktop.
Create the launchpad files

The application saves its state at /data/state.json inside the container. The browser interface reads that state through the application's JSON endpoints.

  • Return to the Explorer sidebar in Visual Studio Code.
  • Select the productivity-launchpad folder.
  • Click the New File icon at the top of the Explorer sidebar.
  • Enter app.py as the file name.

You should see app.py listed beside the existing static folder.

  • Select the existing static folder in the Explorer sidebar.
  • Click the New File icon at the top of the Explorer sidebar.
  • Enter index.html as the file name.

You should see index.html nested inside the static folder.

  • Select the productivity-launchpad folder again.
  • Click the New File icon at the top of the Explorer sidebar.
  • Enter Dockerfile as the file name.

The Dockerfile starts app.py when the container launches. Add the server now so the file is ready before the final double-check.

  • Open app.py from the Visual Studio Code Explorer sidebar.
  • Add the imports, file paths, and initial-state helpers by pasting this first section:
import http.server
import json
import pathlib

APP_DIR = pathlib.Path(__file__).parent
INDEX_FILE = APP_DIR / "static" / "index.html"
DATA_FILE = pathlib.Path("/data/state.json")


def fresh_state():
    return {
        "priority": "",
        "duration": 25,
        "links": [],
        "sessions": [],
    }


def load_state():
    try:
        stored = json.loads(DATA_FILE.read_text(encoding="utf-8"))
    except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError):
        return fresh_state()
    return clean_state(stored)
  • Place the state-cleaning section directly below return clean_state(stored):
def clean_state(payload):
    if not isinstance(payload, dict):
        return fresh_state()

    priority = str(payload.get("priority", ""))[:120]

    try:
        duration = int(payload.get("duration", 25))
    except (TypeError, ValueError):
        duration = 25
    duration = max(1, min(duration, 180))

    links = []
    raw_links = payload.get("links", [])
    if isinstance(raw_links, list):
        for item in raw_links[:12]:
            if not isinstance(item, dict):
                continue
            label = str(item.get("label", "")).strip()[:50]
            url = str(item.get("url", "")).strip()[:500]
            if label and url.startswith(("http://", "https://")):
                links.append({"label": label, "url": url})
  • Continue clean_state() by placing this section directly below the links code:
    sessions = []
    raw_sessions = payload.get("sessions", [])
    if isinstance(raw_sessions, list):
        for item in raw_sessions[:100]:
            if not isinstance(item, dict):
                continue
            goal = str(item.get("goal", "Focus session")).strip()[:120]
            completed_at = str(item.get("completedAt", ""))[:40]
            try:
                minutes = int(item.get("minutes", 0))
            except (TypeError, ValueError):
                continue
            if goal and completed_at and 1 <= minutes <= 180:
                sessions.append(
                    {
                        "goal": goal,
                        "minutes": minutes,
                        "completedAt": completed_at,
                    }
                )

    return {
        "priority": priority,
        "duration": duration,
        "links": links,
        "sessions": sessions,
    }

What Does the State Code Do?

  • The file paths point the server to the browser interface and saved JSON data.
  • The state helpers restore safe defaults when saved data is missing or invalid.
  • The cleaning rules limit the priority, duration, links, and session history before saving them.
  • Add the save helper and response helpers directly below clean_state():
def save_state(state):
    DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
    DATA_FILE.write_text(
        json.dumps(state, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


class LaunchpadHandler(http.server.BaseHTTPRequestHandler):
    def send_bytes(self, body, content_type, status=200):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def send_json(self, payload, status=200):
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_bytes(body, "application/json; charset=utf-8", status)
  • Add the browser and API read routes inside LaunchpadHandler by placing this section below send_json():
    def do_GET(self):
        path = self.path.split("?", 1)[0]
        if path == "/":
            self.send_bytes(
                INDEX_FILE.read_bytes(),
                "text/html; charset=utf-8",
            )
        elif path == "/api/state":
            self.send_json(load_state())
        elif path == "/api/health":
            self.send_json({"status": "ok"})
        else:
            self.send_json({"error": "Not found"}, 404)
  • Add the state-saving route inside LaunchpadHandler by placing this section below do_GET():
    def do_POST(self):
        path = self.path.split("?", 1)[0]
        if path != "/api/state":
            self.send_json({"error": "Not found"}, 404)
            return

        try:
            content_length = int(self.headers.get("Content-Length", "0"))
            if content_length > 200_000:
                self.send_json({"error": "Request is too large"}, 413)
                return
            payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
        except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
            self.send_json({"error": "Invalid JSON"}, 400)
            return

        state = clean_state(payload)
        save_state(state)
        self.send_json(state)
  • Finish app.py by placing the server startup block at the bottom of the file:
if __name__ == "__main__":
    server = http.server.HTTPServer(("0.0.0.0", 8000), LaunchpadHandler)
    print("Productivity Launchpad is listening on port 8000", flush=True)
    server.serve_forever()

What Does the Server Code Do?

  • The request handler serves the dashboard at / and returns saved state from /api/state.
  • The POST route cleans incoming JSON before writing it to /data/state.json.
  • The startup block listens on container port 8000.
  • Click File in the Visual Studio Code menu bar.
  • Click Save All.
  • Confirm that app.py now contains the imports, state helpers, request handler, and server startup block.

Does the Code Look Misaligned?

Keep the spaces shown before the lines inside clean_state() and LaunchpadHandler. Python uses that indentation to group related code.

Compare the complete file in the double-check tab below if a section landed in the wrong place. Ask for help checking the app.py structure.

  • Open static/index.html from the Visual Studio Code Explorer sidebar.
  • Replace the empty file with the complete interface code below.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Productivity Launchpad</title>
  <style>
    :root {
      color-scheme: light;
      font-family: Arial, Helvetica, sans-serif;
      background: #e7e3d8;
      color: #171815;
      --paper: #f6f3ea;
      --ink: #171815;
      --muted: #66665f;
      --line: #aaa79d;
      --accent: #d94f2b;
      --accent-dark: #923119;
      --status: #246b4a;
      --danger: #9d2f24;
    }

    * { box-sizing: border-box; }

    body {
      margin: 0;
      min-height: 100vh;
      background: #e7e3d8;
    }

    button, input { font: inherit; }

    button {
      min-height: 2.75rem;
      border: 1px solid var(--ink);
      border-radius: 0;
      padding: 0.65rem 0.9rem;
      cursor: pointer;
      background: var(--ink);
      color: var(--paper);
      font-size: 0.76rem;
      font-weight: 800;
      letter-spacing: 0.08rem;
      text-transform: uppercase;
    }

    button:hover {
      background: var(--accent-dark);
      color: var(--paper);
    }

    button:active {
      background: var(--accent);
      color: var(--paper);
    }

    button:disabled { cursor: not-allowed; color: #76766f; background: #d2cec3; }

    button.secondary {
      color: var(--ink);
      background: transparent;
    }

    button.secondary:hover { color: var(--paper); background: var(--ink); }

    button.danger {
      color: var(--danger);
      background: transparent;
      border-color: var(--danger);
    }

    button.danger:hover { color: var(--paper); background: var(--danger); }

    input {
      width: 100%;
      min-height: 2.75rem;
      border: 1px solid var(--line);
      border-radius: 0;
      padding: 0.7rem 0.75rem;
      background: #fffdf7;
      color: var(--ink);
    }

    input::placeholder { color: #77766f; }

    :where(button, input, a):focus-visible {
      outline: 3px solid var(--accent);
      outline-offset: 3px;
    }

    a {
      color: var(--accent-dark);
      font-weight: 700;
      text-decoration-thickness: 0.12em;
      text-underline-offset: 0.2em;
    }

    a:hover { color: var(--accent); }

    .shell {
      width: min(1180px, calc(100% - 2rem));
      margin: 1rem auto;
      background: var(--paper);
      border: 1px solid var(--ink);
    }

    .masthead {
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 2rem;
      align-items: end;
      padding: clamp(1.25rem, 3vw, 2.5rem);
      border-bottom: 4px solid var(--ink);
    }

    .eyebrow, .section-number, .field-label, #save-status {
      margin: 0;
      font-family: "Courier New", Courier, monospace;
      font-size: 0.72rem;
      font-weight: 700;
      letter-spacing: 0.1em;
      text-transform: uppercase;
    }

    .eyebrow { margin-bottom: 0.7rem; color: var(--accent-dark); }

    h1, h2, p { margin-top: 0; }

    h1 {
      max-width: 14ch;
      margin-bottom: 0.55rem;
      font-family: Georgia, "Times New Roman", serif;
      font-size: clamp(2.35rem, 7vw, 5.7rem);
      font-weight: 400;
      line-height: 0.88;
      letter-spacing: -0.055em;
    }

    h2 {
      margin-bottom: 0;
      font-size: 0.88rem;
      letter-spacing: 0.08em;
      line-height: 1.2;
      text-transform: uppercase;
    }

    .muted { color: var(--muted); }
    #today { margin-bottom: 0; font-size: 0.9rem; }

    #save-status {
      min-width: 9rem;
      margin-bottom: 0.2rem;
      padding: 0.55rem 0.7rem;
      color: var(--status);
      border: 1px solid currentColor;
      text-align: center;
    }

    .section-heading {
      display: grid;
      grid-template-columns: 2.5rem 1fr;
      gap: 0.8rem;
      align-items: baseline;
      margin-bottom: 1.25rem;
    }

    .section-number { color: var(--accent-dark); }

    .priority-panel {
      padding: clamp(1.25rem, 3vw, 2.25rem);
      border-bottom: 1px solid var(--ink);
    }

    .priority-form {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto auto;
      gap: 0.65rem;
    }

    .workspace {
      display: grid;
      grid-template-columns: minmax(0, 1.35fr) minmax(19rem, 0.65fr);
    }

    .focus-panel {
      min-width: 0;
      padding: clamp(1.25rem, 4vw, 3rem);
      border-right: 1px solid var(--ink);
    }

    .timer {
      margin: clamp(1.5rem, 5vw, 4.5rem) 0;
      font-family: Georgia, "Times New Roman", serif;
      font-size: clamp(4.5rem, 15vw, 10.5rem);
      font-variant-numeric: tabular-nums;
      font-weight: 400;
      line-height: 0.8;
      letter-spacing: -0.075em;
    }

    .timer-controls {
      display: grid;
      grid-template-columns: minmax(7rem, 0.8fr) repeat(4, auto);
      gap: 0.55rem;
      align-items: end;
      padding-top: 1rem;
      border-top: 1px solid var(--line);
    }

    .field-label {
      display: block;
      margin-bottom: 0.4rem;
      color: var(--muted);
    }

    .rail { min-width: 0; }

    .links-panel, .history-panel {
      padding: clamp(1.25rem, 3vw, 2rem);
    }

    .links-panel { border-bottom: 1px solid var(--ink); }

    .stack { display: grid; gap: 0.6rem; }

    ul, ol {
      margin: 0;
      padding-left: 1.25rem;
    }

    .link-list {
      padding: 0;
      margin-top: 1.25rem;
      list-style: none;
      border-top: 1px solid var(--line);
    }

    .link-list li {
      display: flex;
      justify-content: space-between;
      gap: 0.75rem;
      align-items: center;
      min-height: 3rem;
      padding: 0.5rem 0;
      border-bottom: 1px solid var(--line);
    }

    .link-list button { min-height: 2.1rem; padding: 0.4rem 0.55rem; }

    .history-heading {
      display: flex;
      justify-content: space-between;
      gap: 1rem;
      align-items: start;
    }

    .stats {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      margin-bottom: 1.25rem;
      border-top: 1px solid var(--ink);
      border-bottom: 1px solid var(--ink);
    }

    .stat { padding: 0.8rem 0; }
    .stat + .stat { padding-left: 1rem; border-left: 1px solid var(--ink); }

    .stat strong {
      display: block;
      font-family: Georgia, "Times New Roman", serif;
      font-size: 2rem;
      font-weight: 400;
      line-height: 1;
    }

    .stat span {
      font-family: "Courier New", Courier, monospace;
      font-size: 0.68rem;
      letter-spacing: 0.08em;
      text-transform: uppercase;
    }

    #history {
      display: grid;
      gap: 0.65rem;
      padding-left: 1.3rem;
      color: var(--muted);
      font-size: 0.86rem;
      line-height: 1.45;
    }

    @media (max-width: 900px) {
      .workspace { grid-template-columns: 1fr; }
      .focus-panel { border-right: 0; border-bottom: 1px solid var(--ink); }
      .timer-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
      .duration-field { grid-column: 1 / -1; }
    }

    @media (max-width: 620px) {
      .shell { width: calc(100% - 1rem); margin: 0.5rem auto; }
      .masthead { grid-template-columns: 1fr; gap: 1.25rem; }
      #save-status { width: 100%; text-align: left; }
      .priority-form { grid-template-columns: 1fr; }
      .timer { font-size: clamp(4rem, 25vw, 7rem); }
      .timer-controls { grid-template-columns: 1fr 1fr; }
      .timer-controls button { width: 100%; }
      .history-heading { align-items: center; }
    }
  </style>
</head>
<body>
  <main class="shell">
    <header class="masthead">
      <div>
        <p class="eyebrow">Local workstation / Daily docket</p>
        <h1>Productivity Launchpad</h1>
        <p id="today" class="muted"></p>
      </div>
      <p id="save-status" aria-live="polite">Loading...</p>
    </header>

    <section class="priority-panel" aria-labelledby="priority-heading">
      <div class="section-heading">
        <span class="section-number">01</span>
        <h2 id="priority-heading">Today's one priority</h2>
      </div>
      <form id="priority-form" class="priority-form">
        <input id="priority" maxlength="120" aria-label="Today's one priority" placeholder="What would make today successful?" required>
        <button type="submit">Save priority</button>
        <button id="reset-day" class="secondary" type="button">Reset day</button>
      </form>
    </section>

    <section class="workspace">
      <article class="focus-panel" aria-labelledby="focus-heading">
        <div class="section-heading">
          <span class="section-number">02</span>
          <h2 id="focus-heading">Focus session</h2>
        </div>
        <div id="timer" class="timer" aria-live="off">25:00</div>
        <div class="timer-controls">
          <label class="duration-field">
            <span class="field-label">Minutes</span>
            <input type="number" id="duration" min="1" max="180" value="25">
          </label>
          <button id="start" type="button">Start</button>
          <button id="pause" class="secondary" type="button">Pause</button>
          <button id="reset-timer" class="secondary" type="button">Reset</button>
          <button id="complete" class="secondary" type="button">Complete now</button>
        </div>
      </article>

      <aside class="rail" aria-label="Launchpad records">
        <section class="links-panel" aria-labelledby="links-heading">
          <div class="section-heading">
            <span class="section-number">03</span>
            <h2 id="links-heading">Pinned links</h2>
          </div>
          <form id="link-form" class="stack">
            <input id="link-label" maxlength="50" aria-label="Link label" placeholder="Label" required>
            <input id="link-url" type="url" maxlength="500" aria-label="Link URL" placeholder="https://example.com" required>
            <button type="submit">Pin link</button>
          </form>
          <ul id="links" class="link-list"></ul>
        </section>

        <section class="history-panel" aria-labelledby="history-heading">
          <div class="section-heading history-heading">
            <div style="display: grid; grid-template-columns: 2.5rem 1fr; gap: 0.8rem; align-items: baseline;">
              <span class="section-number">04</span>
              <h2 id="history-heading">Focus log</h2>
            </div>
            <button id="clear-history" class="danger" type="button">Clear</button>
          </div>
          <div class="stats">
            <div class="stat"><strong id="session-count">0</strong><span class="muted">sessions</span></div>
            <div class="stat"><strong id="minute-count">0</strong><span class="muted">minutes</span></div>
          </div>
          <ol id="history"></ol>
        </section>
      </aside>
    </section>
  </main>

  <script>
    // Cache the controls that read and update the launchpad state.
    const elements = {
      today: document.querySelector("#today"),
      status: document.querySelector("#save-status"),
      priorityForm: document.querySelector("#priority-form"),
      priority: document.querySelector("#priority"),
      resetDay: document.querySelector("#reset-day"),
      timer: document.querySelector("#timer"),
      duration: document.querySelector("#duration"),
      start: document.querySelector("#start"),
      pause: document.querySelector("#pause"),
      resetTimer: document.querySelector("#reset-timer"),
      complete: document.querySelector("#complete"),
      linkForm: document.querySelector("#link-form"),
      linkLabel: document.querySelector("#link-label"),
      linkUrl: document.querySelector("#link-url"),
      links: document.querySelector("#links"),
      sessionCount: document.querySelector("#session-count"),
      minuteCount: document.querySelector("#minute-count"),
      history: document.querySelector("#history"),
      clearHistory: document.querySelector("#clear-history"),
    };

    // Keep the current state and countdown values available to every interaction.
    let state = { priority: "", duration: 25, links: [], sessions: [] };
    let remainingSeconds = state.duration * 60;
    let intervalId = null;

    elements.today.textContent = new Date().toLocaleDateString(undefined, {
      weekday: "long",
      year: "numeric",
      month: "long",
      day: "numeric",
    });

    function setStatus(message) {
      elements.status.textContent = message;
    }

    // Load and save the shared state through the Python JSON endpoint.
    async function loadState() {
      const response = await fetch("/api/state");
      if (!response.ok) throw new Error("Could not load state");
      state = await response.json();
      renderAll();
      setStatus("Ready");
    }

    async function saveState() {
      setStatus("Saving...");
      const response = await fetch("/api/state", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(state),
      });
      if (!response.ok) throw new Error("Could not save state");
      state = await response.json();
      setStatus("Saved");
    }

    // Render every saved value when the page first loads.
    function renderAll() {
      elements.priority.value = state.priority;
      elements.duration.value = state.duration;
      renderLinks();
      renderHistory();
      resetTimer();
    }

    function renderLinks() {
      elements.links.replaceChildren();
      if (state.links.length === 0) {
        const empty = document.createElement("li");
        empty.textContent = "No links pinned yet.";
        elements.links.append(empty);
        return;
      }

      state.links.forEach((link, index) => {
        const item = document.createElement("li");
        const anchor = document.createElement("a");
        anchor.href = link.url;
        anchor.target = "_blank";
        anchor.rel = "noreferrer";
        anchor.textContent = link.label;

        const remove = document.createElement("button");
        remove.type = "button";
        remove.className = "secondary";
        remove.textContent = "Remove";
        remove.addEventListener("click", async () => {
          state.links.splice(index, 1);
          await saveState();
          renderLinks();
        });

        item.append(anchor, remove);
        elements.links.append(item);
      });
    }

    function renderHistory() {
      elements.sessionCount.textContent = state.sessions.length;
      elements.minuteCount.textContent = state.sessions.reduce(
        (total, session) => total + session.minutes,
        0,
      );
      elements.history.replaceChildren();

      if (state.sessions.length === 0) {
        const empty = document.createElement("li");
        empty.textContent = "Complete a focus session to start your history.";
        elements.history.append(empty);
        return;
      }

      state.sessions.slice(0, 6).forEach((session) => {
        const item = document.createElement("li");
        const when = new Date(session.completedAt).toLocaleString();
        item.textContent = `${session.goal} · ${session.minutes} min · ${when}`;
        elements.history.append(item);
      });
    }

    // Keep the timer display and browser title in sync.
    function updateTimerDisplay() {
      const minutes = Math.floor(remainingSeconds / 60);
      const seconds = remainingSeconds % 60;
      elements.timer.textContent = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
      document.title = `${elements.timer.textContent} · Productivity Launchpad`;
    }

    function pauseTimer() {
      if (intervalId !== null) {
        clearInterval(intervalId);
        intervalId = null;
      }
    }

    function resetTimer() {
      pauseTimer();
      remainingSeconds = Number(state.duration) * 60;
      updateTimerDisplay();
    }

    async function completeSession() {
      pauseTimer();
      state.sessions.unshift({
        goal: state.priority.trim() || "Focus session",
        minutes: Number(state.duration),
        completedAt: new Date().toISOString(),
      });
      state.sessions = state.sessions.slice(0, 100);
      await saveState();
      renderHistory();
      resetTimer();
    }

    // Connect form submissions and buttons to state changes.
    elements.priorityForm.addEventListener("submit", async (event) => {
      event.preventDefault();
      state.priority = elements.priority.value.trim();
      await saveState();
    });

    elements.resetDay.addEventListener("click", async () => {
      state.priority = "";
      elements.priority.value = "";
      await saveState();
    });

    elements.duration.addEventListener("change", async () => {
      const value = Math.max(1, Math.min(Number(elements.duration.value) || 25, 180));
      state.duration = value;
      elements.duration.value = value;
      await saveState();
      resetTimer();
    });

    elements.start.addEventListener("click", () => {
      if (intervalId !== null) return;
      intervalId = setInterval(() => {
        remainingSeconds -= 1;
        updateTimerDisplay();
        if (remainingSeconds <= 0) completeSession();
      }, 1000);
    });

    elements.pause.addEventListener("click", pauseTimer);
    elements.resetTimer.addEventListener("click", resetTimer);
    elements.complete.addEventListener("click", completeSession);

    elements.linkForm.addEventListener("submit", async (event) => {
      event.preventDefault();
      const label = elements.linkLabel.value.trim();
      const url = elements.linkUrl.value.trim();
      if (!url.startsWith("https://") && !url.startsWith("http://")) return;
      state.links.push({ label, url });
      await saveState();
      elements.linkForm.reset();
      renderLinks();
    });

    elements.clearHistory.addEventListener("click", async () => {
      state.sessions = [];
      await saveState();
      renderHistory();
    });

    loadState().catch((error) => {
      console.error(error);
      setStatus("Connection error");
    });
  </script>
</body>
</html>

How Is the Interface Organized?

  • The HTML creates the priority form, focus timer, pinned links, and session history.
  • The CSS controls the page layout and the button states you see when you hover or click.
  • The JavaScript loads saved JSON, updates the timer, and sends changes back to the Python server.
  • Save static/index.html.
  • Confirm that the file starts with <!doctype html> and ends with </html>.

Does the Interface Code Look Incomplete?

Confirm that you replaced the entire empty file. Missing closing tags or braces can stop the dashboard from loading.

Ask for help checking the interface file.

  • Open Dockerfile from the Visual Studio Code Explorer sidebar.
  • Add the container instructions by pasting this code.
FROM python:3.15.2-slim-trixie
WORKDIR /app
COPY app.py .
COPY static ./static
EXPOSE 8000
CMD ["python", "app.py"]

What Does the Dockerfile Do?

  • The base image supplies the pinned Python runtime.
  • The COPY instructions place the application and interface inside /app.
  • The container listens on port 8000 and starts app.py.
  • Save Dockerfile.
  • Confirm that the Explorer sidebar lists app.py, static/index.html, and Dockerfile.

Is the Dockerfile Missing an Instruction?

Confirm that the file contains all six lines in the same order. Keep the file name as Dockerfile with no extension.

Ask for help checking the Dockerfile.

✔️ Awesome, I've got everything!

Your three files are ready when their contents match the reference tab.

  • Click File in the Visual Studio Code menu bar.
  • Click Save All.

ⓧ I'd like to double check the full code

Use this tab as a backup whenever you want to check your work. It keeps complete copies of app.py, static/index.html, and Dockerfile.

  • Place this code in app.py.
import http.server
import json
import pathlib

APP_DIR = pathlib.Path(__file__).parent
INDEX_FILE = APP_DIR / "static" / "index.html"
DATA_FILE = pathlib.Path("/data/state.json")


def fresh_state():
    return {
        "priority": "",
        "duration": 25,
        "links": [],
        "sessions": [],
    }


def load_state():
    try:
        stored = json.loads(DATA_FILE.read_text(encoding="utf-8"))
    except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError):
        return fresh_state()
    return clean_state(stored)


def clean_state(payload):
    if not isinstance(payload, dict):
        return fresh_state()

    priority = str(payload.get("priority", ""))[:120]

    try:
        duration = int(payload.get("duration", 25))
    except (TypeError, ValueError):
        duration = 25
    duration = max(1, min(duration, 180))

    links = []
    raw_links = payload.get("links", [])
    if isinstance(raw_links, list):
        for item in raw_links[:12]:
            if not isinstance(item, dict):
                continue
            label = str(item.get("label", "")).strip()[:50]
            url = str(item.get("url", "")).strip()[:500]
            if label and url.startswith(("http://", "https://")):
                links.append({"label": label, "url": url})

    sessions = []
    raw_sessions = payload.get("sessions", [])
    if isinstance(raw_sessions, list):
        for item in raw_sessions[:100]:
            if not isinstance(item, dict):
                continue
            goal = str(item.get("goal", "Focus session")).strip()[:120]
            completed_at = str(item.get("completedAt", ""))[:40]
            try:
                minutes = int(item.get("minutes", 0))
            except (TypeError, ValueError):
                continue
            if goal and completed_at and 1 <= minutes <= 180:
                sessions.append(
                    {
                        "goal": goal,
                        "minutes": minutes,
                        "completedAt": completed_at,
                    }
                )

    return {
        "priority": priority,
        "duration": duration,
        "links": links,
        "sessions": sessions,
    }


def save_state(state):
    DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
    DATA_FILE.write_text(
        json.dumps(state, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


class LaunchpadHandler(http.server.BaseHTTPRequestHandler):
    def send_bytes(self, body, content_type, status=200):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def send_json(self, payload, status=200):
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_bytes(body, "application/json; charset=utf-8", status)

    def do_GET(self):
        path = self.path.split("?", 1)[0]
        if path == "/":
            self.send_bytes(
                INDEX_FILE.read_bytes(),
                "text/html; charset=utf-8",
            )
        elif path == "/api/state":
            self.send_json(load_state())
        elif path == "/api/health":
            self.send_json({"status": "ok"})
        else:
            self.send_json({"error": "Not found"}, 404)

    def do_POST(self):
        path = self.path.split("?", 1)[0]
        if path != "/api/state":
            self.send_json({"error": "Not found"}, 404)
            return

        try:
            content_length = int(self.headers.get("Content-Length", "0"))
            if content_length > 200_000:
                self.send_json({"error": "Request is too large"}, 413)
                return
            payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
        except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
            self.send_json({"error": "Invalid JSON"}, 400)
            return

        state = clean_state(payload)
        save_state(state)
        self.send_json(state)


if __name__ == "__main__":
    server = http.server.HTTPServer(("0.0.0.0", 8000), LaunchpadHandler)
    print("Productivity Launchpad is listening on port 8000", flush=True)
    server.serve_forever()

What Does the Python Application Do?

  • The state helpers load saved data from /data/state.json.
  • The clean_state() function validates priority, timer, link, and session values.
  • The LaunchpadHandler class serves the page and the JSON endpoints.
  • The server listens on container port 8000.
  • Place this code in static/index.html.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Productivity Launchpad</title>
  <style>
    :root {
      color-scheme: light;
      font-family: Arial, Helvetica, sans-serif;
      background: #e7e3d8;
      color: #171815;
      --paper: #f6f3ea;
      --ink: #171815;
      --muted: #66665f;
      --line: #aaa79d;
      --accent: #d94f2b;
      --accent-dark: #923119;
      --status: #246b4a;
      --danger: #9d2f24;
    }

    * { box-sizing: border-box; }

    body {
      margin: 0;
      min-height: 100vh;
      background: #e7e3d8;
    }

    button, input { font: inherit; }

    button {
      min-height: 2.75rem;
      border: 1px solid var(--ink);
      border-radius: 0;
      padding: 0.65rem 0.9rem;
      cursor: pointer;
      background: var(--ink);
      color: var(--paper);
      font-size: 0.76rem;
      font-weight: 800;
      letter-spacing: 0.08rem;
      text-transform: uppercase;
    }

    button:hover {
      background: var(--accent-dark);
      color: var(--paper);
    }

    button:active {
      background: var(--accent);
      color: var(--paper);
    }

    button:disabled { cursor: not-allowed; color: #76766f; background: #d2cec3; }

    button.secondary {
      color: var(--ink);
      background: transparent;
    }

    button.secondary:hover { color: var(--paper); background: var(--ink); }

    button.danger {
      color: var(--danger);
      background: transparent;
      border-color: var(--danger);
    }

    button.danger:hover { color: var(--paper); background: var(--danger); }

    input {
      width: 100%;
      min-height: 2.75rem;
      border: 1px solid var(--line);
      border-radius: 0;
      padding: 0.7rem 0.75rem;
      background: #fffdf7;
      color: var(--ink);
    }

    input::placeholder { color: #77766f; }

    :where(button, input, a):focus-visible {
      outline: 3px solid var(--accent);
      outline-offset: 3px;
    }

    a {
      color: var(--accent-dark);
      font-weight: 700;
      text-decoration-thickness: 0.12em;
      text-underline-offset: 0.2em;
    }

    a:hover { color: var(--accent); }

    .shell {
      width: min(1180px, calc(100% - 2rem));
      margin: 1rem auto;
      background: var(--paper);
      border: 1px solid var(--ink);
    }

    .masthead {
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 2rem;
      align-items: end;
      padding: clamp(1.25rem, 3vw, 2.5rem);
      border-bottom: 4px solid var(--ink);
    }

    .eyebrow, .section-number, .field-label, #save-status {
      margin: 0;
      font-family: "Courier New", Courier, monospace;
      font-size: 0.72rem;
      font-weight: 700;
      letter-spacing: 0.1em;
      text-transform: uppercase;
    }

    .eyebrow { margin-bottom: 0.7rem; color: var(--accent-dark); }

    h1, h2, p { margin-top: 0; }

    h1 {
      max-width: 14ch;
      margin-bottom: 0.55rem;
      font-family: Georgia, "Times New Roman", serif;
      font-size: clamp(2.35rem, 7vw, 5.7rem);
      font-weight: 400;
      line-height: 0.88;
      letter-spacing: -0.055em;
    }

    h2 {
      margin-bottom: 0;
      font-size: 0.88rem;
      letter-spacing: 0.08em;
      line-height: 1.2;
      text-transform: uppercase;
    }

    .muted { color: var(--muted); }
    #today { margin-bottom: 0; font-size: 0.9rem; }

    #save-status {
      min-width: 9rem;
      margin-bottom: 0.2rem;
      padding: 0.55rem 0.7rem;
      color: var(--status);
      border: 1px solid currentColor;
      text-align: center;
    }

    .section-heading {
      display: grid;
      grid-template-columns: 2.5rem 1fr;
      gap: 0.8rem;
      align-items: baseline;
      margin-bottom: 1.25rem;
    }

    .section-number { color: var(--accent-dark); }

    .priority-panel {
      padding: clamp(1.25rem, 3vw, 2.25rem);
      border-bottom: 1px solid var(--ink);
    }

    .priority-form {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto auto;
      gap: 0.65rem;
    }

    .workspace {
      display: grid;
      grid-template-columns: minmax(0, 1.35fr) minmax(19rem, 0.65fr);
    }

    .focus-panel {
      min-width: 0;
      padding: clamp(1.25rem, 4vw, 3rem);
      border-right: 1px solid var(--ink);
    }

    .timer {
      margin: clamp(1.5rem, 5vw, 4.5rem) 0;
      font-family: Georgia, "Times New Roman", serif;
      font-size: clamp(4.5rem, 15vw, 10.5rem);
      font-variant-numeric: tabular-nums;
      font-weight: 400;
      line-height: 0.8;
      letter-spacing: -0.075em;
    }

    .timer-controls {
      display: grid;
      grid-template-columns: minmax(7rem, 0.8fr) repeat(4, auto);
      gap: 0.55rem;
      align-items: end;
      padding-top: 1rem;
      border-top: 1px solid var(--line);
    }

    .field-label {
      display: block;
      margin-bottom: 0.4rem;
      color: var(--muted);
    }

    .rail { min-width: 0; }

    .links-panel, .history-panel {
      padding: clamp(1.25rem, 3vw, 2rem);
    }

    .links-panel { border-bottom: 1px solid var(--ink); }

    .stack { display: grid; gap: 0.6rem; }

    ul, ol {
      margin: 0;
      padding-left: 1.25rem;
    }

    .link-list {
      padding: 0;
      margin-top: 1.25rem;
      list-style: none;
      border-top: 1px solid var(--line);
    }

    .link-list li {
      display: flex;
      justify-content: space-between;
      gap: 0.75rem;
      align-items: center;
      min-height: 3rem;
      padding: 0.5rem 0;
      border-bottom: 1px solid var(--line);
    }

    .link-list button { min-height: 2.1rem; padding: 0.4rem 0.55rem; }

    .history-heading {
      display: flex;
      justify-content: space-between;
      gap: 1rem;
      align-items: start;
    }

    .stats {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      margin-bottom: 1.25rem;
      border-top: 1px solid var(--ink);
      border-bottom: 1px solid var(--ink);
    }

    .stat { padding: 0.8rem 0; }
    .stat + .stat { padding-left: 1rem; border-left: 1px solid var(--ink); }

    .stat strong {
      display: block;
      font-family: Georgia, "Times New Roman", serif;
      font-size: 2rem;
      font-weight: 400;
      line-height: 1;
    }

    .stat span {
      font-family: "Courier New", Courier, monospace;
      font-size: 0.68rem;
      letter-spacing: 0.08em;
      text-transform: uppercase;
    }

    #history {
      display: grid;
      gap: 0.65rem;
      padding-left: 1.3rem;
      color: var(--muted);
      font-size: 0.86rem;
      line-height: 1.45;
    }

    @media (max-width: 900px) {
      .workspace { grid-template-columns: 1fr; }
      .focus-panel { border-right: 0; border-bottom: 1px solid var(--ink); }
      .timer-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
      .duration-field { grid-column: 1 / -1; }
    }

    @media (max-width: 620px) {
      .shell { width: calc(100% - 1rem); margin: 0.5rem auto; }
      .masthead { grid-template-columns: 1fr; gap: 1.25rem; }
      #save-status { width: 100%; text-align: left; }
      .priority-form { grid-template-columns: 1fr; }
      .timer { font-size: clamp(4rem, 25vw, 7rem); }
      .timer-controls { grid-template-columns: 1fr 1fr; }
      .timer-controls button { width: 100%; }
      .history-heading { align-items: center; }
    }
  </style>
<head>
<body>
  <main class="shell">
    <header class="masthead">
      <div>
        <p class="eyebrow">Local workstation / Daily docket</p>
        <h1>Productivity Launchpad</h1>
        <p id="today" class="muted"></p>
      </div>
      <p id="save-status" aria-live="polite">Loading...</p>
    </header>

    <section class="priority-panel" aria-labelledby="priority-heading">
      <div class="section-heading">
        <span class="section-number">01</span>
        <h2 id="priority-heading">Today's one priority</h2>
      </div>
      <form id="priority-form" class="priority-form">
        <input id="priority" maxlength="120" aria-label="Today's one priority" placeholder="What would make today successful?" required>
        <button type="submit">Save priority</button>
        <button id="reset-day" class="secondary" type="button">Reset day</button>
      </form>
    </section>

    <section class="workspace">
      <article class="focus-panel" aria-labelledby="focus-heading">
        <div class="section-heading">
          <span class="section-number">02</span>
          <h2 id="focus-heading">Focus session</h2>
        </div>
        <div id="timer" class="timer" aria-live="off">25:00</div>
        <div class="timer-controls">
          <label class="duration-field">
            <span class="field-label">Minutes</span>
            <input type="number" id="duration" min="1" max="180" value="25">
          </label>
          <button id="start" type="button">Start</button>
          <button id="pause" class="secondary" type="button">Pause</button>
          <button id="reset-timer" class="secondary" type="button">Reset</button>
          <button id="complete" class="secondary" type="button">Complete now</button>
        </div>
      </article>

      <aside class="rail" aria-label="Launchpad records">
        <section class="links-panel" aria-labelledby="links-heading">
          <div class="section-heading">
            <span class="section-number">03</span>
            <h2 id="links-heading">Pinned links</h2>
          </div>
          <form id="link-form" class="stack">
            <input id="link-label" maxlength="50" aria-label="Link label" placeholder="Label" required>
            <input id="link-url" type="url" maxlength="500" aria-label="Link URL" placeholder="https://example.com" required>
            <button type="submit">Pin link</button>
          </form>
          <ul id="links" class="link-list"></ul>
        </section>

        <section class="history-panel" aria-labelledby="history-heading">
          <div class="section-heading history-heading">
            <div style="display: grid; grid-template-columns: 2.5rem 1fr; gap: 0.8rem; align-items: baseline;">
              <span class="section-number">04</span>
              <h2 id="history-heading">Focus log</h2>
            </div>
            <button id="clear-history" class="danger" type="button">Clear</button>
          </div>
          <div class="stats">
            <div class="stat"><strong id="session-count">0</strong><span class="muted">sessions</span></div>
            <div class="stat"><strong id="minute-count">0</strong><span class="muted">minutes</span></div>
          </div>
          <ol id="history"></ol>
        </section>
      </aside>
    </section>
  </main>

  <script>
    // Cache the controls that read and update the launchpad state.
    const elements = {
      today: document.querySelector("#today"),
      status: document.querySelector("#save-status"),
      priorityForm: document.querySelector("#priority-form"),
      priority: document.querySelector("#priority"),
      resetDay: document.querySelector("#reset-day"),
      timer: document.querySelector("#timer"),
      duration: document.querySelector("#duration"),
      start: document.querySelector("#start"),
      pause: document.querySelector("#pause"),
      resetTimer: document.querySelector("#reset-timer"),
      complete: document.querySelector("#complete"),
      linkForm: document.querySelector("#link-form"),
      linkLabel: document.querySelector("#link-label"),
      linkUrl: document.querySelector("#link-url"),
      links: document.querySelector("#links"),
      sessionCount: document.querySelector("#session-count"),
      minuteCount: document.querySelector("#minute-count"),
      history: document.querySelector("#history"),
      clearHistory: document.querySelector("#clear-history"),
    };

    // Keep the current state and countdown values available to every interaction.
    let state = { priority: "", duration: 25, links: [], sessions: [] };
    let remainingSeconds = state.duration * 60;
    let intervalId = null;

    elements.today.textContent = new Date().toLocaleDateString(undefined, {
      weekday: "long",
      year: "numeric",
      month: "long",
      day: "numeric",
    });

    function setStatus(message) {
      elements.status.textContent = message;
    }

    // Load and save the shared state through the Python JSON endpoint.
    async function loadState() {
      const response = await fetch("/api/state");
      if (!response.ok) throw new Error("Could not load state");
      state = await response.json();
      renderAll();
      setStatus("Ready");
    }

    async function saveState() {
      setStatus("Saving...");
      const response = await fetch("/api/state", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(state),
      });
      if (!response.ok) throw new Error("Could not save state");
      state = await response.json();
      setStatus("Saved");
    }

    // Render every saved value when the page first loads.
    function renderAll() {
      elements.priority.value = state.priority;
      elements.duration.value = state.duration;
      renderLinks();
      renderHistory();
      resetTimer();
    }

    function renderLinks() {
      elements.links.replaceChildren();
      if (state.links.length === 0) {
        const empty = document.createElement("li");
        empty.textContent = "No links pinned yet.";
        elements.links.append(empty);
        return;
      }

      state.links.forEach((link, index) => {
        const item = document.createElement("li");
        const anchor = document.createElement("a");
        anchor.href = link.url;
        anchor.target = "_blank";
        anchor.rel = "noreferrer";
        anchor.textContent = link.label;

        const remove = document.createElement("button");
        remove.type = "button";
        remove.className = "secondary";
        remove.textContent = "Remove";
        remove.addEventListener("click", async () => {
          state.links.splice(index, 1);
          await saveState();
          renderLinks();
        });

        item.append(anchor, remove);
        elements.links.append(item);
      });
    }

    function renderHistory() {
      elements.sessionCount.textContent = state.sessions.length;
      elements.minuteCount.textContent = state.sessions.reduce(
        (total, session) => total + session.minutes,
        0,
      );
      elements.history.replaceChildren();

      if (state.sessions.length === 0) {
        const empty = document.createElement("li");
        empty.textContent = "Complete a focus session to start your history.";
        elements.history.append(empty);
        return;
      }

      state.sessions.slice(0, 6).forEach((session) => {
        const item = document.createElement("li");
        const when = new Date(session.completedAt).toLocaleString();
        item.textContent = `${session.goal} · ${session.minutes} min · ${when}`;
        elements.history.append(item);
      });
    }

    // Keep the timer display and browser title in sync.
    function updateTimerDisplay() {
      const minutes = Math.floor(remainingSeconds / 60);
      const seconds = remainingSeconds % 60;
      elements.timer.textContent = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
      document.title = `${elements.timer.textContent} · Productivity Launchpad`;
    }

    function pauseTimer() {
      if (intervalId !== null) {
        clearInterval(intervalId);
        intervalId = null;
      }
    }

    function resetTimer() {
      pauseTimer();
      remainingSeconds = Number(state.duration) * 60;
      updateTimerDisplay();
    }

    async function completeSession() {
      pauseTimer();
      state.sessions.unshift({
        goal: state.priority.trim() || "Focus session",
        minutes: Number(state.duration),
        completedAt: new Date().toISOString(),
      });
      state.sessions = state.sessions.slice(0, 100);
      await saveState();
      renderHistory();
      resetTimer();
    }

    // Connect form submissions and buttons to state changes.
    elements.priorityForm.addEventListener("submit", async (event) => {
      event.preventDefault();
      state.priority = elements.priority.value.trim();
      await saveState();
    });

    elements.resetDay.addEventListener("click", async () => {
      state.priority = "";
      elements.priority.value = "";
      await saveState();
    });

    elements.duration.addEventListener("change", async () => {
      const value = Math.max(1, Math.min(Number(elements.duration.value) || 25, 180));
      state.duration = value;
      elements.duration.value = value;
      await saveState();
      resetTimer();
    });

    elements.start.addEventListener("click", () => {
      if (intervalId !== null) return;
      intervalId = setInterval(() => {
        remainingSeconds -= 1;
        updateTimerDisplay();
        if (remainingSeconds <= 0) completeSession();
      }, 1000);
    });

    elements.pause.addEventListener("click", pauseTimer);
    elements.resetTimer.addEventListener("click", resetTimer);
    elements.complete.addEventListener("click", completeSession);

    elements.linkForm.addEventListener("submit", async (event) => {
      event.preventDefault();
      const label = elements.linkLabel.value.trim();
      const url = elements.linkUrl.value.trim();
      if (!url.startsWith("https://") && !url.startsWith("http://")) return;
      state.links.push({ label, url });
      await saveState();
      elements.linkForm.reset();
      renderLinks();
    });

    elements.clearHistory.addEventListener("click", async () => {
      state.sessions = [];
      await saveState();
      renderHistory();
    });

    loadState().catch((error) => {
      console.error(error);
      setStatus("Connection error");
    });
  </script>
</body>
</html>

How Is the Interface Organized?

  • The masthead presents the date and save status as operational information instead of decoration.
  • The priority strip spans the full workspace because it sets the purpose for every focus session.
  • The timer uses the largest type on the page. Pinned links and the focus log sit in a narrower records column.
  • Buttons and fields use sharp borders, clear hover states, and an orange focus outline. On smaller screens, each section stacks into one readable column.
  • Place this code in Dockerfile.
FROM python:3.15.2-slim-trixie
WORKDIR /app
COPY app.py .
COPY static ./static
EXPOSE 8000
CMD ["python", "app.py"]

What Does the Dockerfile Do?

  • The base image supplies the pinned Python runtime.
  • The COPY instructions place the application files inside /app.
  • The EXPOSE instruction documents container port 8000.
  • The CMD instruction starts app.py when the container runs.
  • Click File in the Visual Studio Code menu bar.
  • Click Save All.
Build the image and start the container

A Docker build reads the Dockerfile from the current project folder. The final dot sends that folder to the builder as the build context.

  • Switch back to the integrated PowerShell terminal from the previous step.
  • Build the custom image from the current productivity-launchpad folder by running:
docker build -t launchpad:1.0 .

What Does This Command Do?

  • The docker build command builds an image from the Dockerfile.
  • The -t option gives the image the name launchpad:1.0.
  • The final . selects the current folder as the build context.

The first build can take longer while Docker downloads the Python base image. PowerShell continues printing build progress while that work runs.

  • Wait for the build to finish successfully.

Did the Image Build Fail?

  • Confirm that Dockerfile has no file extension.
  • Confirm that app.py sits beside Dockerfile.
  • Confirm that index.html sits inside the static folder.
  • Ask for help with the build output.

The image packages the Python runtime with your application files. A container is the running instance of that image.

  • Start the launchpad container with a loopback-only published port by running:
docker run -d --name launchpad -p 127.0.0.1:8080:8000 launchpad:1.0

How Does the Port Mapping Work?

  • The -d option runs the container in the background.
  • The --name launchpad option gives the container a reusable name.
  • The -p 127.0.0.1:8080:8000 option publishes container port 8000 on host port 8080.
  • The 127.0.0.1 address keeps the dashboard available only on your Windows computer.

PowerShell prints a container identifier when Docker starts the container in the background.

Did the Container Fail to Start?

  • Confirm that Docker Desktop still shows its engine as running.
  • Confirm that the image name in the command is launchpad:1.0.
  • Check whether another local application is already using port 8080.
  • Ask for help with the container output.
Open and inspect the dashboard

The published port forms a path from your browser to the Python server inside the container. Opening the local address tests the source files, image, container process, and port mapping together.

Before you open the address, which dashboard sections do you expect the container to serve?

  • Click your web browser icon on the Windows taskbar.
  • Enter http://localhost:8080 in the address bar.
  • Press Enter.

You should see the Productivity Launchpad title above a full-width priority strip. A large 25:00 timer sits beside pinned links and a focus log, while the save status reads Ready.

That completes the first full Docker loop. Your own image is now serving a working application through a local browser address.

  • Switch back to Docker Desktop from earlier.
  • Select Containers in the left sidebar.
  • Select the launchpad container.
  • Confirm that the container is running from launchpad:1.0.
  • Confirm that host address 127.0.0.1:8080 connects to container port 8000.

What Does This Prove?

The container listens on port 8000 inside Docker. The published port lets your browser reach that service at 127.0.0.1:8080.

No named volume is attached yet. The application currently writes /data/state.json into the container's writable filesystem.

🙋‍♀️ Does the Dashboard Refuse to Load?

  • Confirm in Docker Desktop that the launchpad container is still running.
  • Confirm that the browser address uses host port 8080.
  • Compare app.py with the full-code reference if the container stops after starting.
  • Ask for help tracing the browser connection.

Your dashboard is live from a custom image. Next, you will control which project files Docker receives during a build and inspect the layers that produced launchpad:1.0.

Control the Build Context

Your Productivity Launchpad is live from the launchpad:1.0 container image. That proves Docker Desktop can turn your source files into a working local dashboard.

The first build offered the entire project directory to Docker as a build context. A .dockerignore file controls what reaches the builder.

In this step, get ready to:
  • Add ignore rules for editor metadata, caches, local data, and documentation.
  • Rebuild the launchpad:1.0 image from the controlled context.
  • Inspect the rebuilt image through its history table in Windows PowerShell.
Create the ignore file

A build context is the set of files available to COPY during an image build. A .dockerignore file removes matching paths before Docker sends that context to the builder.

  • In the Visual Studio Code Explorer sidebar, select the productivity-launchpad folder.
  • Select New File in the Explorer toolbar.
  • Enter .dockerignore as the file name.
  • Press Enter to create the file.
  • Fill .dockerignore with the build exclusions by pasting this content:
.git
.vscode
__pycache__
*.pyc
*.db
data
README.md

What do these patterns exclude?

  • The .git pattern excludes version-control metadata.
  • The .vscode pattern excludes local editor settings.
  • The __pycache__ pattern excludes Python cache directories.
  • The *.pyc pattern excludes compiled Python cache files.
  • The *.db pattern excludes local database files.
  • The data pattern excludes a local data directory.
  • The README.md pattern excludes project documentation from the context.
  • Save .dockerignore by pressing Ctrl+S.
  • Confirm .dockerignore appears next to Dockerfile in Explorer.

Good work: your project now has an explicit boundary around the files offered to the image builder.

Build File Missing?

  • Check that the file name begins with a dot.
  • Confirm the file is beside Dockerfile inside the productivity-launchpad folder.
  • Ask for help with finding my .dockerignore file in Visual Studio Code.

✔️ Awesome, I've got everything!

Your .dockerignore file is saved with all seven build-context exclusions.

ⓧ I'd like to double check the full code

Compare your complete .dockerignore file with this reference.

.git
.vscode
__pycache__
*.pyc
*.db
data
README.md
Rebuild the image

The ignore rules take effect when Docker processes the next build context. Rebuilding the same image tag applies those rules without replacing the running container.

  • Return to the integrated Windows PowerShell terminal from earlier.
  • Rebuild the launchpad image from the controlled context by running:
docker build -t launchpad:1.0 .

What Does This Build Command Do?

  • The docker build command starts an image build.
  • The -t launchpad:1.0 portion assigns the existing image name and version tag.
  • The final . selects the current productivity-launchpad folder as the build context.
  • Check the build output for load .dockerignore.
  • Check the build output for load build context.

Good progress: your rebuilt image now uses the controlled context.

Your existing launchpad container remains available at http://localhost:8080.

Build Not Completing?

  • Confirm the integrated terminal is inside the productivity-launchpad folder.
  • Check that Dockerfile appears in the Visual Studio Code Explorer sidebar.
  • Ask for help with debugging my controlled Docker build.
Inspect the image history

Image history exposes the Dockerfile instructions that shaped the rebuilt image. This connects your Dockerfile to the resulting layers.

  • Make a quick prediction about which Dockerfile instructions will appear in the layer table.
  • Inspect the rebuilt image history by running this command:
docker image history launchpad:1.0

What Should I See?

  • The command prints a layer table for launchpad:1.0.
  • The WORKDIR row records the application directory inside the image.
  • The COPY rows record the application files added to the image.
  • The EXPOSE row documents the container's listening port.
  • The CMD row records the application startup command.

🙋‍♀️ History Table Missing?

  • Confirm the image name is launchpad:1.0.
  • Confirm the rebuild completed before inspecting the history.
  • Ask for help with inspecting my launchpad image history.
  • Switch back to Docker Desktop from earlier.
  • Select Images in the sidebar.
  • Find launchpad:1.0 to confirm the rebuilt image is listed.
  • Return to the browser tab showing http://localhost:8080.
  • Refresh the dashboard.

You should still see the Productivity Launchpad heading. The priority form, focus timer, quick links, and focus history remain available.

Your image now rebuilds from a controlled context with inspectable layers. Next, you will see what happens to saved dashboard data when its container is replaced.

Observe Ephemeral Data Loss

Your controlled build context produced the reusable container image called launchpad:1.0. The running dashboard now gives you a practical way to test what belongs to one container.

The application saves its state at /data/state.json inside the current runtime. Replacing that runtime reveals whether your saved productivity data follows the image.

In this step, get ready to:
  • Create a recognizable dashboard state with a priority, quick link, and completed session.
  • Replace the running container with a fresh container from the same image.
  • Check which dashboard data survives the replacement.
Create a recognizable saved state

A useful replacement test needs a clear before state. You will create three visible entries that are easy to check later.

  • Return to the browser tab showing http://localhost:8080.
  • Enter Finish my Docker lesson under Today's one priority.
  • Click Save priority.

You should see Saved in the top corner. Your priority remains in the field.

  • Enter Example in the first field under Quick links.
  • Enter https://example.com in the second field.
  • Click Add link.

You should see the Example link in the list. The two link fields reset after the save.

  • Enter 1 in the Minutes field.
  • Click Complete now.

Under Focus history, you should see 1 session. The focused-minutes total should also show 1.

Your test state is ready. Each entry gives the replacement check a visible signal.

Replace the running container

The image still contains the same application files. A replacement container starts from that unchanged image.

Removing a running container can feel risky. This command targets only the runtime named launchpad, so your source files remain untouched.

  • Return to the integrated Windows PowerShell terminal from earlier.
  • Force-remove the running launchpad container by running this command:
docker rm -f launchpad

What does this command do?

  • The docker rm command removes the container named launchpad.
  • The -f option permits removal while the container is running.
  • The command leaves the launchpad:1.0 image available.
  • Confirm PowerShell returns to the prompt without reporting a failure.

The original runtime is now gone. The unchanged image can create a fresh runtime at the same local address.

Container not removed?

  • Confirm Docker Desktop still shows that its engine is running.
  • Check that the container name in your command is launchpad.
  • Retry the removal command from the integrated PowerShell terminal.
  • Help me diagnose why Docker cannot remove the launchpad container.
  • Start a fresh launchpad container from launchpad:1.0 by running this command:
docker run -d --name launchpad -p 127.0.0.1:8080:8000 launchpad:1.0

How is the replacement configured?

  • The -d option runs the replacement in the background.
  • The --name launchpad option assigns the original container name.
  • The published port mapping connects host port 8080 to container port 8000.
  • The 127.0.0.1 address keeps the dashboard available only on your Windows computer.
  • The final launchpad:1.0 value selects the unchanged image.
  • Confirm PowerShell prints a container identifier.

The replacement container is running. It serves the same dashboard from the same local address.

Fresh container not starting?

  • Confirm the removal command finished before you ran the replacement command.
  • Check that launchpad:1.0 remains available in Docker Desktop.
  • Check whether another local process already uses port 8080.
  • Help me diagnose why the fresh launchpad container will not start.
Check the replacement state

The replacement serves the same application code. Its dashboard state now shows which data came from the image and which data belonged to the removed runtime.

Before you refresh, do you expect the priority, quick link, and completed session to reappear?

  • Return to the browser tab showing http://localhost:8080.
  • Refresh the page.

You should see a blank priority field. The quick-link list should show No links pinned yet.

The focus history should show 0 sessions. The focused-minutes total should also show 0.

You have made the storage boundary visible. The dashboard still works while the data created inside the deleted runtime is gone.

Why did the data disappear?

The first container stored /data/state.json in its writable layer. That layer was part of the container's ephemeral filesystem.

Force-removing the container deleted that filesystem. The reusable image remained available because runtime data does not change the image.

No named volume held the state outside the first container. In the replacement, load_state() finds no saved file and returns the blank values from fresh_state().

The fresh launchpad container now runs from launchpad:1.0 at http://localhost:8080. It has no volume attached.

Your Complete Code files remain unchanged. The controlled .dockerignore also remains unchanged.

Still seeing the old entries?

  • Confirm the browser address is http://localhost:8080.
  • Refresh the page again after the replacement container finishes starting.
  • Confirm the replacement command does not contain the --mount option.
  • Help me determine why the fresh container still shows the old dashboard state.

You have proved that a container's writable state disappears with that container. Next, you will give /data/state.json durable storage and test communication by container name.

Persist Data and Test Container DNS

Your fresh dashboard exposed the storage problem from the previous step. Docker removed the old container's writable layer, so everything stored in /data/state.json disappeared with it.

A named volume gives that file a durable home outside the container. A user-defined bridge network also gives the launchpad a private network where another container can reach it by name.

In this step, get ready to:
  • Create the launchpad's durable storage plus private network.
  • Prove the dashboard data survives container replacement.
  • Reach the launchpad from a temporary checker through container-name DNS.
Create the volume and network

The volume stores files independently from any single container. The network gives attached containers a shared route for private communication.

  • Create the launchpad-data volume from the integrated Windows PowerShell terminal by running this command:
docker volume create launchpad-data

What does this command do?

Docker creates storage called launchpad-data that exists separately from the launchpad container. Mounting this volume at /data gives state.json a durable location.

PowerShell confirms the new volume by printing launchpad-data.

  • Create the launchpad-net network by running this command:
docker network create launchpad-net

What does this command do?

The command creates a user-defined bridge network called launchpad-net. Containers attached to this network can communicate through private container networking.

PowerShell prints the identifier for your new network. The storage plus networking foundation is now ready.

Resource creation not working?

  • Confirm Docker Desktop still shows that its engine is running.
  • Check that the integrated terminal is using PowerShell.
  • Retry the command after Docker Desktop finishes starting.
  • help me troubleshoot Docker volume or network creation in PowerShell
Recreate the launchpad with persistent storage

The running launchpad still writes into its disposable container layer. Replacing it with a volume-mounted container redirects every saved dashboard update into launchpad-data.

  • Remove the unmounted launchpad container by running this command:
docker rm -f launchpad

Why remove the current container?

The existing container has no volume mount. Force-removing it releases the launchpad name plus host port for the replacement container.

PowerShell prints the removed container name. The old unmounted launchpad is now gone.

  • Start the replacement container with the volume plus network attached by running this command:
docker run -d --name launchpad --network launchpad-net -p 127.0.0.1:8080:8000 --mount type=volume,src=launchpad-data,target=/data launchpad:1.0

How is this container configured?

  • The --network launchpad-net option attaches the container to your private network.
  • The -p 127.0.0.1:8080:8000 option publishes the dashboard only through your computer's loopback address.
  • The --mount type=volume,src=launchpad-data,target=/data option maps the named volume to the application's data directory.
  • The launchpad:1.0 image remains unchanged. Runtime options now provide persistence plus networking.
  • Switch back to the dashboard tab at http://localhost:8080.
  • Refresh the page.

You should see the empty dashboard load from the new volume-mounted container.

  • Enter Test persistent storage in the Today's one priority card.
  • Click Save priority.

The save status changes to Saved. Your priority is now stored in the mounted volume.

  • Enter 1 in the Minutes field.
  • Click Complete now.

The focus history now shows one completed session. The total focused time shows one minute.

  • Enter Reference in the first field under Quick links.
  • Enter the full address of a website you use in the second field.
  • Click Add link.

The new quick link appears in the list. Your volume now contains a priority, a link, a one-minute duration plus a completed session.

Before you replace the container, do you think those saved records now belong to the container or the mounted volume?

  • Remove the volume-mounted container by running this command:
docker rm -f launchpad

What is being deleted?

This removes the container's writable layer. The launchpad-data volume remains available because its lifecycle is separate from the container.

PowerShell prints launchpad after removing the container. Your named volume remains intact.

  • Recreate the launchpad with the same volume by running this command:
docker run -d --name launchpad --network launchpad-net -p 127.0.0.1:8080:8000 --mount type=volume,src=launchpad-data,target=/data launchpad:1.0

Why reuse the same command?

The replacement container mounts the existing launchpad-data volume at /data. The application reads the retained state.json file when the dashboard loads.

  • Switch back to the dashboard tab.
  • Refresh the page.

Your Test persistent storage priority is still present. You should also see the quick link, one-minute duration plus completed focus session.

That is the storage problem solved. Your productivity data now survives the lifecycle of any individual launchpad container.

Saved data missing after recreation?

  • Check that the recreation command includes src=launchpad-data.
  • Check that the mount target is exactly /data.
  • Refresh http://localhost:8080 after the replacement container starts.
  • help me diagnose missing launchpad data after container recreation
Test container-name DNS

A container-name DNS lookup lets containers on launchpad-net resolve launchpad to the running application. A short-lived checker can therefore call the health endpoint without a second published host port.

Before you run the checker, what container name do you expect it to use as the destination?

  • Run the temporary checker on launchpad-net by using this command:
docker run --rm --network launchpad-net launchpad:1.0 python -c "import urllib.request; print(urllib.request.urlopen('http://launchpad:8000/api/health').read().decode())"

How does the checker reach the app?

  • The --network launchpad-net option places the checker on the same network as the launchpad.
  • The address http://launchpad:8000/api/health uses the launchpad container name as its hostname.
  • The --rm option removes the checker container after the request finishes.
  • The checker reuses the existing launchpad:1.0 image without publishing another port.

PowerShell prints {"status": "ok"}. This proves Docker DNS resolved launchpad across the user-defined network.

You have now proved both halves of the runtime design. The named volume preserves state while the bridge network provides stable container-to-container addressing.

Checker cannot reach the launchpad?

  • Confirm the volume-mounted launchpad container is still running.
  • Check that both commands use the same launchpad-net network name.
  • Check that the checker URL uses launchpad as the hostname.
  • help me troubleshoot the launchpad checker connection

Your launchpad now keeps its data through container replacement. It also answers other containers through its stable launchpad name.

Secret mission

Create Separate Work and Study Profiles

Run a Study dashboard beside your Work dashboard without rebuilding the image. Separate ports and named volumes keep each profile available without mixing their saved data.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs on your Windows computer, so there are no cloud charges. Docker Desktop remains free only when your use fits its published free-use terms.

Running containers use local memory, while images and volumes use local disk space. The tabs below cover keeping, pausing, and deleting your resources.

Resources you used:

  • The local source folder at C:\Users\<your-user>\Projects\productivity-launchpad.
  • The shared container image named launchpad:1.0.
  • The Work container named launchpad.
  • The Study container named study-launchpad.
  • The Work named volume named launchpad-data.
  • The Study named volume named study-launchpad-data.
  • The user-defined bridge network named launchpad-net.

Keep everything running

No action needed. Choose this option if you want both dashboards available as daily start pages.

  • Leave the launchpad container running for your Work profile.
  • Leave the study-launchpad container running for your Study profile.
  • Bookmark the Work dashboard at http://localhost:8080.
  • Bookmark the Study dashboard at http://localhost:8081.
  • Keep C:\Users\<your-user>\Projects\productivity-launchpad for future code changes.

Pause - I'll come back to this later

Stopping both containers frees their local memory. Your image, source files, network, and saved profile data remain available.

  • Return to Docker Desktop's Containers view.
  • Select launchpad.
  • Click Stop.
  • Select study-launchpad.
  • Click Stop.
  • Confirm both containers show a stopped state.
  • Use the start action for both containers when you return to the project.

The dashboard URLs stop responding while the containers are stopped. Each profile's saved state returns when its container starts with the same named volume.

Delete - I don't want to use this again

Deleting the named volumes permanently removes the saved Work and Study profiles. Your Windows setup and Docker Desktop installation stay intact.

Remove the containers first:

  • Return to Docker Desktop's Containers view.
  • Stop launchpad with the Stop action.
  • Remove launchpad with its remove action.
  • Confirm the container removal when prompted.
  • Stop study-launchpad with the Stop action.
  • Remove study-launchpad with its remove action.
  • Confirm the container removal when prompted.

Remove storage and networking:

  • Locate launchpad-net in Docker Desktop's network resource list.
  • Remove launchpad-net with its remove action.
  • Locate launchpad-data in Docker Desktop's volume resource list.
  • Remove launchpad-data with its remove action.
  • Locate study-launchpad-data in Docker Desktop's volume resource list.
  • Remove study-launchpad-data with its remove action.
  • Confirm each removal when prompted.

Remove the image and source files:

  • Switch to Docker Desktop's Images view.
  • Select launchpad:1.0.
  • Remove launchpad:1.0 with its remove action.
  • Press the Windows key to open Windows search.
  • Type File Explorer.
  • Press Enter to open File Explorer.
  • Locate C:\Users\<your-user>\Projects\productivity-launchpad.
  • Permanently delete the productivity-launchpad folder with File Explorer's permanent-delete option.

Verify the cleanup:

  • Return to the Work dashboard browser tab.
  • Refresh http://localhost:8080.
  • Return to the Study dashboard browser tab.
  • Refresh http://localhost:8081.

Both dashboard addresses should now be unavailable. Docker Desktop should no longer list the two containers, two named volumes, network, or shared image.

Nice Work!

Nice Work!

That is a strong finish. You built a local productivity dashboard as a reusable container image with Docker.

You've learned how to:

  • Built the complete launchpad as a reusable container image tagged launchpad:1.0. Published its dashboard only to http://localhost:8080 on your Windows computer.
  • Controlled the build context with .dockerignore. Inspected the resulting image layers to see how Dockerfile instructions shaped the image.
  • Recreated launchpad to prove its writable container filesystem was disposable. Preserved Work data in the launchpad-data named volume. Verified container-name DNS across the user-defined bridge network launchpad-net.
  • Secret Mission: Ran isolated Work and Study profiles from one shared image. Confirmed that each profile keeps separate priorities through its own named volume.

Your feedback helps improve this project for the next learner.

Ready to quiz yourself?