Build a Windows Focus Timer

Build and package a Windows focus timer with Electron notifications.

Introduction

30 Second Summary

A focus session can end while your attention is inside another app. A message trapped inside the timer window is easy to miss.

In this project, you will build Focus Timer as a Windows desktop app for focused work sessions. Electron will connect the countdown interface to a notification that reaches you outside the app.

What You'll Build

You will start a five-second demo before switching away to see Windows deliver the completion notification.

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

  • A desktop focus timer with a live countdown under your control.
  • A native Windows notification that tells you when a session ends while another app is active.
  • A local Windows installer that lets you install FocusTimer on your own PC for a desktop demo.
  • Secret Mission: Add a guarded Spacebar shortcut that starts or pauses the timer without disrupting buttons or the duration selector.

Are there any prerequisites?

You need a Windows PC with Git installed. You also need Visual Studio Code plus confidence building web pages.

Before We Start

This is your moment to commit to building a Windows Focus Timer that uses a native notification to keep session completion visible outside the app window.

Set Up the Electron Project

A desktop build needs a compatible runtime before its tools can launch. Node.js runs the project tooling for Electron and Electron Forge.

This step pins the environment before you write application code. You will verify npm and Git before creating the project configuration.

In this step, get ready to:
  • Confirm that Node.js is compatible with the pinned Electron tools.
  • Create the focus-timer folder with its required configuration files.
  • Install the four pinned project dependencies.
Check your local tool versions

Electron Forge 8.0.1 requires Node.js 22.13.0 or newer. Checking first prevents an outdated runtime from causing installation failures.

  • Press the Windows key to open search.
  • Type Visual Studio Code and press Enter to open it.
  • Press the Windows key again to open search.
  • Type PowerShell and press Enter to open it.
  • Check the installed Node.js, npm, and Git versions by running:
node --version
npm --version
git --version

What do these checks show?

  • The first command prints the Node.js runtime version used by Electron Forge.
  • The second command confirms that the npm package manager is available.
  • The third command confirms that Git is available for project version control.

✔️ I see version 22.13.0 or higher

Your Node.js runtime can support the pinned Electron Forge dependencies. That compatibility removes a common source of installation failures.

  • Confirm that the npm check printed a version number.
  • Confirm that the git check printed a version number.

ⓧ I see an older version

Your current Node.js runtime is below the project minimum. Install Node.js 24.21.0 LTS so the Electron tools have a compatible runtime.

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js 24.21.0 LTS.
  • Close Visual Studio Code before running the installer.
  • Complete the installer with its default options.
  • Press the Windows key to reopen search.
  • Type Visual Studio Code and press Enter to restart it.
  • Press the Windows key to reopen search.
  • Type PowerShell and press Enter to open a fresh window.

The fresh applications can now detect the updated runtime.

  • Repeat the three version checks by running:
node --version
npm --version
git --version

You should now see Node.js 24.21.0. The npm and Git checks should each print a version number.

ⓧ Command not found

PowerShell cannot currently find Node.js. Installing Node.js 24.21.0 LTS also provides npm 11.19.0.

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js 24.21.0 LTS.
  • Close Visual Studio Code before running the installer.
  • Complete the installer with its default options.
  • Press the Windows key to reopen search.
  • Type Visual Studio Code and press Enter to restart it.
  • Press the Windows key to reopen search.
  • Type PowerShell and press Enter to open a fresh window.

The fresh PowerShell window can now read the updated command path.

  • Repeat the three version checks by running:
node --version
npm --version
git --version

You should now see Node.js 24.21.0. The npm and Git checks should each print a version number.

Still missing a version number?

Close PowerShell after an installation finishes. Open a fresh PowerShell window so it can detect the updated command path.

If only the Git check fails, confirm that Git is installed before continuing.

Help me diagnose my version checks.

Create the project files

The focus-timer folder keeps the source files and project dependencies in one workspace. Its configuration also defines how Electron Forge starts and packages the app.

  • Create the focus-timer folder on your Desktop by running these commands in PowerShell:
Set-Location -Path "$env:USERPROFILE\Desktop"
New-Item -Path "focus-timer" -ItemType Directory
Set-Location -Path "focus-timer"

What do these commands do?

  • The first command moves PowerShell to your Desktop.
  • The second command creates the focus-timer folder.
  • The final command makes focus-timer the active PowerShell location.
  • Confirm that PowerShell printed a directory entry named focus-timer.

Folder already exists?

Use the existing folder only if it is empty. Remove any old Focus Timer files before continuing so they cannot conflict with this project.

Help me check my project folder.

  • Switch back to Visual Studio Code from earlier.
  • Click File in the top menu.
  • Click Open Folder....
  • Select the focus-timer folder on your Desktop.
  • Click Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

You should see focus-timer at the top of the Explorer sidebar. The folder is now your Visual Studio Code workspace.

The package.json file stores the app metadata and pinned dependency versions. It also maps the development and packaging tasks to Electron Forge.

  • Click New File... in the Explorer sidebar.
  • Enter package.json and press Enter.
  • Add the project metadata by copying this content into package.json:
{
  "name": "focus-timer",
  "productName": "FocusTimer",
  "version": "1.0.0",
  "description": "A starter Electron focus timer",
  "main": "src/index.js",
  "scripts": {
    "start": "electron-forge start",
    "make": "electron-forge make"
  },
  "keywords": [],
  "author": "NextWork Learner",
  "license": "MIT",
  "dependencies": {
    "electron-squirrel-startup": "1.0.1"
  },
  "devDependencies": {
    "@electron-forge/cli": "8.0.1",
    "@electron-forge/maker-squirrel": "8.0.1",
    "electron": "44.7.0"
  }
}

What does package.json configure?

  • The main field points Electron to the future src/index.js main-process file.
  • The start script launches the app through Electron Forge.
  • The make script creates the Windows distributable.
  • The dependency sections pin every direct package used by this project.
  • Press Ctrl+S to save package.json.
  • Confirm that package.json is listed directly under focus-timer in the Explorer sidebar.

Seeing a JSON warning?

Compare every quote and comma with the code block. The final property in each object has no trailing comma.

Help me fix my package.json file.

The Forge configuration selects the Squirrel.Windows maker. This maker produces the Windows installer later in the project.

  • Click New File... in the Explorer sidebar.
  • Enter forge.config.js and press Enter.
  • Configure the Windows maker by copying this content into forge.config.js:
module.exports = {
  makers: [
    {
      name: '@electron-forge/maker-squirrel',
      config: {},
    },
  ],
};

What does this configuration do?

The makers array tells Electron Forge which packaging target to use. The Squirrel maker creates the Windows installer output.

  • Press Ctrl+S to save forge.config.js.
  • Confirm that forge.config.js appears beside package.json in the Explorer sidebar.

Maker configuration showing an error?

Check the braces and brackets against the code block. Keep the package name inside single quotes.

Help me fix my Forge configuration.

The .gitignore file keeps downloaded dependencies and generated build output outside Git tracking. Those files can be recreated from the project configuration.

  • Click New File... in the Explorer sidebar.
  • Enter .gitignore and press Enter.
  • Define the ignored project paths by copying this content into .gitignore:
node_modules/
out/

What does this file exclude?

  • The node_modules/ entry excludes downloaded packages.
  • The out/ entry excludes generated Forge build output.
  • Press Ctrl+S to save .gitignore.
  • Confirm that .gitignore appears beside the two configuration files in the Explorer sidebar.

Missing the .gitignore file?

Check that the filename begins with a period. Remove any extra file extension that Visual Studio Code shows.

Help me find my .gitignore file.

✔️ Awesome, I've got everything!

Your three saved project files now define the Focus Timer metadata and Windows packaging path.

ⓧ I'd like to double check the full code

The complete versions of all three project files are shown below for reference.

{
  "name": "focus-timer",
  "productName": "FocusTimer",
  "version": "1.0.0",
  "description": "A starter Electron focus timer",
  "main": "src/index.js",
  "scripts": {
    "start": "electron-forge start",
    "make": "electron-forge make"
  },
  "keywords": [],
  "author": "NextWork Learner",
  "license": "MIT",
  "dependencies": {
    "electron-squirrel-startup": "1.0.1"
  },
  "devDependencies": {
    "@electron-forge/cli": "8.0.1",
    "@electron-forge/maker-squirrel": "8.0.1",
    "electron": "44.7.0"
  }
}
module.exports = {
  makers: [
    {
      name: '@electron-forge/maker-squirrel',
      config: {},
    },
  ],
};
node_modules/
out/
Install the pinned dependencies

The project files now describe the required packages. Installing them creates node_modules with the Electron runtime and Forge packaging tools.

The first installation is the slowest because npm downloads Electron. A quiet pause during that download is normal.

Before you run the installs, do you expect npm to record these packages inside the Focus Timer project?

  • Switch back to the PowerShell window inside focus-timer from earlier.
  • Install the four pinned dependencies by running these commands one line at a time:
npm install electron-squirrel-startup@1.0.1
npm install --save-dev electron@44.7.0
npm install --save-dev @electron-forge/cli@8.0.1
npm install --save-dev @electron-forge/maker-squirrel@8.0.1

What did you install?

  • The electron-squirrel-startup package handles Windows startup and installer events.
  • The electron package provides the desktop application runtime.
  • The @electron-forge/cli package provides the development and packaging tasks.
  • The @electron-forge/maker-squirrel package creates the Squirrel.Windows installer.

Each command should finish without errors. You should see output reporting packages as added or audited.

Installation failed or stalled?

Confirm that PowerShell is still inside the focus-timer folder. Repeat the version checks from the first substep if the output mentions an unavailable runtime.

Check your internet connection if the download stops progressing. Run the affected installation command again after the connection recovers.

Help me fix my dependency installation.

You have pinned the complete development environment. The Focus Timer now has consistent runtime and packaging dependencies.

Your Electron workspace is ready. Next up, you will build the in-window timer and watch its five-second demo complete.

Build the In-Window Focus Timer

Your pinned Electron project is ready. You can now turn it into a desktop countdown with controls you can test.

A completion message inside the timer is easy to miss after you switch to another app. You will build that limited version first so you can experience the problem it creates.

In this step, get ready to:
  • Create the countdown interface with three duration presets.
  • Add the timer state plus its Start, Pause, Reset, and completion behavior.
  • Launch the desktop window to test the five-second session.
Create and style the timer interface

The renderer process displays the app interface using HTML. A restrictive Content Security Policy keeps the page focused on local resources.

  • Use the folder control at the top of the Visual Studio Code file sidebar to create src inside focus-timer.

You should see the new src folder beneath the existing project files.

  • Use the file control beside src to create renderer.js.

You should see an empty renderer.js file inside src.

  • Use the file control beside src to create index.html.
  • Build the first half of the document by adding this fragment to src/index.html:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Focus Timer</title>
    <link rel="stylesheet" href="./index.css" />
  </head>
  <body>
    <main class="timer-card">
      <p class="eyebrow">Desktop focus session</p>
      <h1>Focus Timer</h1>

      <label for="duration">Session length</label>
      <select id="duration">
        <option value="5">5 seconds, demo</option>
        <option value="1500" selected>25 minutes</option>
        <option value="3000">50 minutes</option>
      </select>

What Does This Markup Prepare?

  • The policy limits the page to resources bundled with the app.
  • The stylesheet link connects the page to index.css.
  • The duration selector stores each preset as a number of seconds.
  • Complete the document by adding this fragment directly below the closing select tag:

      <p id="timer" class="timer" aria-live="polite">25:00</p>
      <p id="status" class="status" aria-live="polite">Ready to focus</p>

      <div class="controls">
        <button id="start" type="button">Start</button>
        <button id="pause" type="button" disabled>Pause</button>
        <button id="reset" type="button">Reset</button>
      </div>
    </main>

    <script src="./renderer.js"></script>
  </body>
</html>

What Completes the Interface?

  • The timer element displays the remaining time.
  • The status element communicates the current session state.
  • The controls provide stable IDs for the timer functions.
  • The final script tag loads the behavior from renderer.js.
  • Save src/index.html.

The page structure now contains the presets, countdown display, status text, and three controls.

Missing Part of the Markup?

Check that both fragments appear in src/index.html in the same order. Confirm that the file ends with the closing body plus document tags.

Help me compare the timer markup.

✔️ Awesome, I've got everything!

Your timer structure is complete. Keep src/index.html saved while you add its visual design.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Focus Timer</title>
    <link rel="stylesheet" href="./index.css" />
  </head>
  <body>
    <main class="timer-card">
      <p class="eyebrow">Desktop focus session</p>
      <h1>Focus Timer</h1>

      <label for="duration">Session length</label>
      <select id="duration">
        <option value="5">5 seconds, demo</option>
        <option value="1500" selected>25 minutes</option>
        <option value="3000">50 minutes</option>
      </select>

      <p id="timer" class="timer" aria-live="polite">25:00</p>
      <p id="status" class="status" aria-live="polite">Ready to focus</p>

      <div class="controls">
        <button id="start" type="button">Start</button>
        <button id="pause" type="button" disabled>Pause</button>
        <button id="reset" type="button">Reset</button>
      </div>
    </main>

    <script src="./renderer.js"></script>
  </body>
</html>

What Should Match?

Compare the complete file with your saved src/index.html. The element IDs plus file paths must match exactly.

The structure is ready for CSS. Each styling layer produces a visible change you can check in the browser.

  • Use the file control beside src to create index.css.
  • Set the page palette plus centered layout by adding this first section to src/index.css:
:root {
  color-scheme: dark;
  font-family: "Segoe UI", sans-serif;
  background: #10141f;
  color: #f7f8fc;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  display: grid;
  place-items: center;
  background: radial-gradient(circle at top, #26375f, #10141f 65%);
}

What Does This Styling Do?

  • The root rules establish the dark color palette plus Segoe UI font.
  • The universal selector includes borders plus padding in element dimensions.
  • The body grid centers the timer against a radial background.
  • Save src/index.css.
  • Use your browser to open the local src/index.html file.

You should see the timer content centered on a dark blue background.

Still Seeing a White Background?

Confirm that index.css is inside src. Check that the stylesheet path in index.html is ./index.css.

Help me trace the stylesheet.

  • Turn the timer content into a raised card by adding these selectors below the body rules:

.timer-card {
  width: min(350px, calc(100vw - 40px));
  padding: 32px;
  border: 1px solid #3c4b70;
  border-radius: 24px;
  background: rgba(17, 23, 38, 0.94);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
  text-align: center;
}

.eyebrow {
  margin: 0;
  color: #9fb4ff;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

How Does the Card Take Shape?

  • The .timer-card selector constrains the width plus adds the rounded surface.
  • The .eyebrow selector turns the introductory line into a compact label.
  • Save src/index.css.
  • Refresh the browser preview.

You should see the timer content inside a rounded card with a soft shadow.

Card Still Looks Like Plain Text?

Check that .timer-card matches class="timer-card" in index.html.

Help me compare the card class names.

  • Style the heading plus duration selector by adding these rules below .eyebrow:

h1 {
  margin: 8px 0 24px;
  font-size: 2rem;
}

label {
  display: block;
  margin-bottom: 8px;
  color: #c7d1ec;
  font-weight: 600;
}

select,
button {
  border: 1px solid #52658f;
  border-radius: 10px;
  font: inherit;
}

select {
  width: 100%;
  padding: 10px 12px;
  background: #182038;
  color: #f7f8fc;
}

What Changes Around the Selector?

  • The heading receives space above the session controls.
  • The label occupies its own line above the selector.
  • The selector plus buttons share consistent borders and typography.
  • Save src/index.css.
  • Refresh the browser preview.

You should see a full-width dark selector below the Session length label.

Selector Width Looks Uneven?

Confirm that the select rule uses 100% for its width. Check that each selector block has matching braces.

Help me inspect the selector styles.

  • Emphasize the countdown plus arrange the controls by adding these rules below the selector styles:

.timer {
  margin: 30px 0 4px;
  font-size: 4.5rem;
  font-variant-numeric: tabular-nums;
  font-weight: 750;
  letter-spacing: -0.06em;
}

.status {
  min-height: 24px;
  margin: 0 0 28px;
  color: #b9c5e3;
}

.controls {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}

How Does This Support a Timer?

  • The .timer selector makes the countdown the strongest visual element.
  • Tabular numerals keep the digits at consistent widths.
  • The status reserves space for each message.
  • The controls grid gives every button an equal column.
  • Save src/index.css.
  • Refresh the browser preview.

You should see a large 25:00 countdown above three evenly spaced controls.

Countdown or Controls Misaligned?

Check that .timer, .status, plus .controls each have a complete rule block.

Help me find the incomplete CSS block.

  • Finish the control states by adding these rules at the bottom of src/index.css:

button {
  padding: 11px 8px;
  background: #304b94;
  color: white;
  cursor: pointer;
}

button:hover:not(:disabled) {
  background: #3e60ba;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

What Do the Control States Show?

  • Enabled buttons use a blue background plus pointer cursor.
  • The hover rule brightens buttons that can receive a click.
  • Disabled buttons become translucent.
  • Save src/index.css.
  • Refresh the browser preview.

You should see blue Start plus Reset controls. The disabled Pause control should appear faded.

Pause Does Not Look Disabled?

Confirm that the Pause button in index.html includes disabled. Check that the final selector is button:disabled.

Help me check the disabled state.

✔️ Awesome, I've got everything!

Your timer now looks like a compact desktop interface. Confirm that src/index.css is saved.

ⓧ I'd like to double check the full code

:root {
  color-scheme: dark;
  font-family: "Segoe UI", sans-serif;
  background: #10141f;
  color: #f7f8fc;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  display: grid;
  place-items: center;
  background: radial-gradient(circle at top, #26375f, #10141f 65%);
}

.timer-card {
  width: min(350px, calc(100vw - 40px));
  padding: 32px;
  border: 1px solid #3c4b70;
  border-radius: 24px;
  background: rgba(17, 23, 38, 0.94);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
  text-align: center;
}

.eyebrow {
  margin: 0;
  color: #9fb4ff;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1 {
  margin: 8px 0 24px;
  font-size: 2rem;
}

label {
  display: block;
  margin-bottom: 8px;
  color: #c7d1ec;
  font-weight: 600;
}

select,
button {
  border: 1px solid #52658f;
  border-radius: 10px;
  font: inherit;
}

select {
  width: 100%;
  padding: 10px 12px;
  background: #182038;
  color: #f7f8fc;
}

.timer {
  margin: 30px 0 4px;
  font-size: 4.5rem;
  font-variant-numeric: tabular-nums;
  font-weight: 750;
  letter-spacing: -0.06em;
}

.status {
  min-height: 24px;
  margin: 0 0 28px;
  color: #b9c5e3;
}

.controls {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}

button {
  padding: 11px 8px;
  background: #304b94;
  color: white;
  cursor: pointer;
}

button:hover:not(:disabled) {
  background: #3e60ba;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

What Should Match?

Compare every selector with your saved src/index.css. The final file should end with the disabled button rule.

Wire the countdown controls

The interface needs JavaScript state that tracks the remaining seconds plus active interval. Keeping one interval ID prevents duplicate countdowns.

  • Connect the page elements to the timer state by adding this first section to src/renderer.js:
const durationSelect = document.getElementById('duration');
const timerDisplay = document.getElementById('timer');
const statusDisplay = document.getElementById('status');
const startButton = document.getElementById('start');
const pauseButton = document.getElementById('pause');
const resetButton = document.getElementById('reset');

let remainingSeconds = Number(durationSelect.value);
let intervalId = null;

function renderTime() {
  const minutes = Math.floor(remainingSeconds / 60);
  const seconds = remainingSeconds % 60;
  timerDisplay.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

function setRunningState(isRunning) {
  startButton.disabled = isRunning;
  pauseButton.disabled = !isRunning;
  durationSelect.disabled = isRunning;
}

How Is the Timer State Represented?

  • The six element references connect the script to the selector, displays, plus controls.
  • The remainingSeconds value begins with the selected duration.
  • The intervalId value records whether a countdown is active.
  • The helper functions format the time plus synchronize the controls.
  • Save src/renderer.js.
  • Refresh the browser preview.

You should still see the initial 25:00 display with no script error shown on the page.

Page Stops Loading?

Check the closing braces in renderTime() plus setRunningState(). Confirm that each element ID matches src/index.html.

Help me check the renderer state.

  • Add the countdown function below setRunningState() in src/renderer.js:

function startTimer() {
  if (intervalId !== null) {
    return;
  }

  if (remainingSeconds <= 0) {
    remainingSeconds = Number(durationSelect.value);
    renderTime();
  }

  statusDisplay.textContent = 'Focus session in progress';
  setRunningState(true);

  intervalId = setInterval(() => {
    remainingSeconds -= 1;
    renderTime();

    if (remainingSeconds <= 0) {
      clearInterval(intervalId);
      intervalId = null;
      statusDisplay.textContent = 'Session complete';
      setRunningState(false);
    }
  }, 1000);
}

How Does the Countdown Run?

  • The first guard prevents a second interval from starting.
  • A completed timer reloads the selected duration before restarting.
  • The interval subtracts one second before refreshing the display.
  • Reaching zero clears the interval plus sets the status to Session complete.
  • Save src/renderer.js.
  • Refresh the browser preview.
  • Open the browser developer console.
  • Start the timer from the console by entering this expression:
startTimer()

What Should This Trigger?

The function begins the one-second interval. It also changes the status plus disables the duration selector.

You should see the display move from 25:00 to 24:59. The status should read Focus session in progress.

Countdown Stays on 25:00?

Check the browser developer console for a syntax error in startTimer(). Compare the interval value plus function braces with the code above.

Help me debug the countdown.

  • Add the pause plus reset functions directly below startTimer():

function pauseTimer() {
  if (intervalId === null) {
    return;
  }

  clearInterval(intervalId);
  intervalId = null;
  statusDisplay.textContent = 'Session paused';
  setRunningState(false);
}

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

  remainingSeconds = Number(durationSelect.value);
  statusDisplay.textContent = 'Ready to focus';
  setRunningState(false);
  renderTime();
}

How Do Pause and Reset Differ?

  • The pauseTimer() function clears the interval while preserving the remaining time.
  • The resetTimer() function restores the selected duration.
  • Both functions synchronize the status plus controls.
  • Save src/renderer.js.
  • Refresh the browser preview.
  • Choose the five-second preset in the duration selector.
  • Reset the timer from the developer console by entering this expression:
resetTimer()

What Does This Check Prove?

The function reads the selected duration plus redraws the display. You should see 00:05 with Ready to focus below it.

Reset Does Not Show 00:05?

Confirm that the five-second option is selected before you call resetTimer(). Check that the function assigns durationSelect.value to remainingSeconds.

Help me debug the reset behavior.

  • Connect the controls by adding these event listeners at the bottom of src/renderer.js:

startButton.addEventListener('click', startTimer);
pauseButton.addEventListener('click', pauseTimer);
resetButton.addEventListener('click', resetTimer);
durationSelect.addEventListener('change', resetTimer);

renderTime();

What Connects the Controls?

  • Each button sends its click to the matching timer function.
  • Changing the duration runs resetTimer().
  • The final renderTime() call formats the initial display.
  • Save src/renderer.js.
  • Refresh the browser preview.
  • Select the five-second demo.
  • Click Start.
  • Click Pause after the display decreases.

You should see the countdown stop. The status should read Session paused.

  • Click Reset.

You should see 00:05 with the status Ready to focus.

Controls Do Not Update the Timer?

Confirm that the event listeners sit below the timer functions. Check that every element ID matches src/index.html.

Help me trace the control events.

✔️ Awesome, I've got everything!

Your renderer can start, pause, reset, plus change the countdown duration. Keep src/renderer.js saved.

ⓧ I'd like to double check the full code

const durationSelect = document.getElementById('duration');
const timerDisplay = document.getElementById('timer');
const statusDisplay = document.getElementById('status');
const startButton = document.getElementById('start');
const pauseButton = document.getElementById('pause');
const resetButton = document.getElementById('reset');

let remainingSeconds = Number(durationSelect.value);
let intervalId = null;

function renderTime() {
  const minutes = Math.floor(remainingSeconds / 60);
  const seconds = remainingSeconds % 60;
  timerDisplay.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

function setRunningState(isRunning) {
  startButton.disabled = isRunning;
  pauseButton.disabled = !isRunning;
  durationSelect.disabled = isRunning;
}

function startTimer() {
  if (intervalId !== null) {
    return;
  }

  if (remainingSeconds <= 0) {
    remainingSeconds = Number(durationSelect.value);
    renderTime();
  }

  statusDisplay.textContent = 'Focus session in progress';
  setRunningState(true);

  intervalId = setInterval(() => {
    remainingSeconds -= 1;
    renderTime();

    if (remainingSeconds <= 0) {
      clearInterval(intervalId);
      intervalId = null;
      statusDisplay.textContent = 'Session complete';
      setRunningState(false);
    }
  }, 1000);
}

function pauseTimer() {
  if (intervalId === null) {
    return;
  }

  clearInterval(intervalId);
  intervalId = null;
  statusDisplay.textContent = 'Session paused';
  setRunningState(false);
}

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

  remainingSeconds = Number(durationSelect.value);
  statusDisplay.textContent = 'Ready to focus';
  setRunningState(false);
  renderTime();
}

startButton.addEventListener('click', startTimer);
pauseButton.addEventListener('click', pauseTimer);
resetButton.addEventListener('click', resetTimer);
durationSelect.addEventListener('change', resetTimer);

renderTime();

What Should Match?

Compare your saved renderer with this complete file. The completion branch should end after setRunningState(false) in this version.

Launch the Electron window

The browser check proves the renderer works. The main process now creates a native desktop window for that interface.

  • Use the file control beside src to create an empty preload.js file.

You should see preload.js beside the other source files. This preload script remains empty for the in-window version.

  • Use the file control beside src to create index.js.
  • Create the fixed desktop window by adding this first section to src/index.js:
const { app, BrowserWindow } = require('electron');
const path = require('node:path');

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 420,
    height: 560,
    resizable: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  });

  mainWindow.loadFile(path.join(__dirname, 'index.html'));
}

What Does the Window Configuration Protect?

  • The window uses fixed dimensions of 420 by 560 pixels.
  • The preload path prepares a controlled boundary for later functionality.
  • Context isolation plus sandboxing separate page code from privileged behavior.
  • Disabling Node integration keeps Node.js APIs outside the renderer.
  • Save src/index.js.

The main process can now construct the configured window once Electron becomes ready.

Window Configuration Looks Incomplete?

Check that webPreferences is nested inside the BrowserWindow options. Confirm that loadFile() appears after the window is created.

Help me inspect the window configuration.

  • Add the app lifecycle handlers directly below createWindow() in src/index.js:

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

How Does the App Lifecycle Work?

  • The app waits for Electron before creating the window.
  • The activate handler recreates the window when no window remains.
  • The final handler quits the app after every window closes on Windows.
  • Save src/index.js.
  • Save the empty src/preload.js file.

All five source files should now appear together inside src.

Source File Missing?

Confirm that index.html, index.css, renderer.js, index.js, plus preload.js are inside src.

Help me check the source layout.

✔️ Awesome, I've got everything!

Your main process is ready to launch the desktop window. Keep src/index.js plus the empty src/preload.js saved.

ⓧ I'd like to double check the full code

const { app, BrowserWindow } = require('electron');
const path = require('node:path');

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 420,
    height: 560,
    resizable: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  });

  mainWindow.loadFile(path.join(__dirname, 'index.html'));
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

What Should Match?

Compare this file with your saved src/index.js. Keep src/preload.js empty for this step.

Before you launch the app, do you expect the timer to appear in a browser tab or its own desktop window?

  • Return to the integrated PowerShell terminal from the previous step.
  • Launch the configured Electron app by running this command:
npm start

What Does This Command Launch?

The configured start script asks Electron Forge to run the source project. The main process creates a desktop window plus loads src/index.html.

The terminal remains attached while the development app is running.

You should see Focus Timer in its own fixed-size desktop window.

  • Select the five-second demo in the Focus Timer window.
  • Click Start.
  • Click Pause after the display decreases.

You should see the countdown stop with Session paused below it.

  • Click Reset.

You should see the display return to 00:05. The status should return to Ready to focus.

Electron Window Did Not Open?

Confirm that the integrated terminal is inside focus-timer. Check that package.json points to src/index.js.

Help me diagnose the app launch.

Before the final check, where do you think the completion signal will remain after you switch away from Focus Timer?

  • Select the five-second demo.
  • Click Start.
  • Switch to the Visual Studio Code window before the countdown finishes.

Windows shows no completion notification. This shortfall is the intended result of the in-window version.

  • Return to the Focus Timer window.

You should see 00:00 with the status Session complete.

That is the limitation you set out to experience. The countdown completes correctly while its message stays inside the app window.

Timer Does Not Reach Session Complete?

Confirm that the five-second demo is selected before you press Start. Check that the completion branch clears intervalId plus updates the status inside startTimer().

Help me trace the completion branch.

You have a working desktop timer with reliable controls plus a visible completion state. Next, you will carry that signal beyond the app window with a native Windows notification.

Add a Native Completion Notification

Your focus timer now handles complete sessions inside its own window. That result fades from view when you switch to another app.

In this step, your Electron app gains a native notification through a narrow IPC bridge. The main process keeps control of the privileged notification capability.

In this step, get ready to:
  • Prepare Windows to identify the development app correctly.
  • Connect the renderer to the main process through a secure preload bridge.
  • Complete the five-second demo while another app has focus.
Prepare Windows for development notifications

Windows needs to associate development notifications with the Electron executable. This setup is a little fiddly because the development app does not have its packaged identity yet.

  • In the Visual Studio Code Explorer sidebar, locate node_modules\electron\dist\electron.exe.
  • Right-click electron.exe.
  • Use the context-menu action that reveals the executable in Windows File Explorer.
  • Right-click electron.exe in Windows File Explorer.
  • Select the menu option that pins the executable to the Windows Start menu.
  • Open the Windows Start menu.
  • Confirm that Electron appears in the pinned area.

That is the fiddly part complete. Windows can now associate development notifications with the Electron executable.

The main process also needs a temporary application identity during development. Electron supplies the executable path through process.execPath.

  • Return to Visual Studio Code.
  • In src/index.js, find app.whenReady().then(() => {.
  • Add the application identity at the start of the callback using this code:
app.whenReady().then(() => {
  app.setAppUserModelId(process.execPath);

What does this line do?

The callback waits until Electron is ready. app.setAppUserModelId(process.execPath) gives the development executable an identity that Windows can use for notifications.

  • Save src/index.js.
Connect the main process and preload bridge

The renderer should request a notification without gaining direct access to Electron. A preload script exposes one purpose-built function across the isolated boundary.

  • In src/index.js, select the first two lines.
  • Replace the selected lines with this startup code:
const { app, BrowserWindow, ipcMain, Notification } = require('electron');
const path = require('node:path');

if (require('electron-squirrel-startup')) {
  app.quit();
}

What changes at startup?

  • ipcMain receives the completion request in the main process.
  • Notification gives the main process access to Windows notifications.
  • electron-squirrel-startup handles startup events used by the Windows installer.
  • Save src/index.js.
  • Open a PowerShell terminal in Visual Studio Code.
  • Check that the startup changes still launch the app by running this command:
npm start

What does this command check?

The command starts the app through Electron Forge. A successful launch proves that the updated imports and startup handler load without a syntax error.

You should see the Focus Timer window open with the countdown set to 25:00.

  • Return to the PowerShell terminal.
  • Press Ctrl+C to stop the development app.

The main process now needs a listener for the timer's completion message. Sender validation ensures that only the app's own window can trigger the notification.

  • In src/index.js, find app.setAppUserModelId(process.execPath);.
  • Insert this listener directly below that line:
  ipcMain.on('timer:complete', (event) => {
    if (event.sender.id !== mainWindow?.webContents.id) {
      return;
    }

    new Notification({
      title: 'Focus session complete',
      body: 'Time for a short break.',
    }).show();
  });

How does the listener protect the app?

  • ipcMain.on('timer:complete', ...) listens for one completion channel.
  • event.sender.id identifies the web content that sent the message.
  • mainWindow?.webContents.id identifies the app's expected renderer.
  • Notification shows the title Focus session complete with the body Time for a short break..
  • Save src/index.js.
  • Return to the PowerShell terminal.
  • Confirm that the listener loads successfully by running this command:
npm start

What does this launch prove?

The app starts after registering the IPC listener. This confirms that the main process accepts the new notification code without a startup error.

You should see the Focus Timer window open normally. The controls should remain responsive.

  • Return to the PowerShell terminal.
  • Press Ctrl+C to stop the development app.

Does the app close during launch?

Check that the first line of src/index.js imports both ipcMain and Notification.

Confirm that the listener sits inside app.whenReady() before createWindow().

Help me debug the main-process notification listener.

The preload bridge defines the only notification action available to the renderer. It carries no general Electron object across the isolation boundary.

  • Select src/preload.js in the Visual Studio Code Explorer sidebar.
  • Add the secure bridge using this code:
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('timerAPI', {
  notifyComplete: () => ipcRenderer.send('timer:complete'),
});

What does the preload bridge expose?

  • contextBridge.exposeInMainWorld('timerAPI', ...) creates window.timerAPI in the renderer.
  • notifyComplete() sends the single timer:complete message.
  • The bridge keeps all other Electron capabilities outside the renderer.
  • Save src/preload.js.
Trigger and verify the notification

The bridge is ready to carry a request. The completion branch must call it after the countdown reaches zero.

  • In src/renderer.js, find the completion branch inside startTimer().
  • Find the line setRunningState(false); inside that branch.
  • Add the notification request directly below it using this code:
      setRunningState(false);
      window.timerAPI.notifyComplete();

What completes the notification path?

The renderer calls window.timerAPI.notifyComplete() only after the timer reaches zero. The preload bridge forwards the request to the validated main-process listener.

  • Save src/renderer.js.

✔️ Awesome, I've got everything!

Your three notification files are saved. You are ready to test the complete path.

ⓧ I'd like to double check the full code

Compare each modified file with these complete versions.

const { app, BrowserWindow, ipcMain, Notification } = require('electron');
const path = require('node:path');

if (require('electron-squirrel-startup')) {
  app.quit();
}

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 420,
    height: 560,
    resizable: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  });

  mainWindow.loadFile(path.join(__dirname, 'index.html'));
}

app.whenReady().then(() => {
  app.setAppUserModelId(process.execPath);

  ipcMain.on('timer:complete', (event) => {
    if (event.sender.id !== mainWindow?.webContents.id) {
      return;
    }

    new Notification({
      title: 'Focus session complete',
      body: 'Time for a short break.',
    }).show();
  });

  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

This complete src/index.js includes startup handling, sender validation, and the native notification.

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('timerAPI', {
  notifyComplete: () => ipcRenderer.send('timer:complete'),
});

This complete src/preload.js exposes only the notification request.

const durationSelect = document.getElementById('duration');
const timerDisplay = document.getElementById('timer');
const statusDisplay = document.getElementById('status');
const startButton = document.getElementById('start');
const pauseButton = document.getElementById('pause');
const resetButton = document.getElementById('reset');

let remainingSeconds = Number(durationSelect.value);
let intervalId = null;

function renderTime() {
  const minutes = Math.floor(remainingSeconds / 60);
  const seconds = remainingSeconds % 60;
  timerDisplay.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

function setRunningState(isRunning) {
  startButton.disabled = isRunning;
  pauseButton.disabled = !isRunning;
  durationSelect.disabled = isRunning;
}

function startTimer() {
  if (intervalId !== null) {
    return;
  }

  if (remainingSeconds <= 0) {
    remainingSeconds = Number(durationSelect.value);
    renderTime();
  }

  statusDisplay.textContent = 'Focus session in progress';
  setRunningState(true);

  intervalId = setInterval(() => {
    remainingSeconds -= 1;
    renderTime();

    if (remainingSeconds <= 0) {
      clearInterval(intervalId);
      intervalId = null;
      statusDisplay.textContent = 'Session complete';
      setRunningState(false);
      window.timerAPI.notifyComplete();
    }
  }, 1000);
}

function pauseTimer() {
  if (intervalId === null) {
    return;
  }

  clearInterval(intervalId);
  intervalId = null;
  statusDisplay.textContent = 'Session paused';
  setRunningState(false);
}

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

  remainingSeconds = Number(durationSelect.value);
  statusDisplay.textContent = 'Ready to focus';
  setRunningState(false);
  renderTime();
}

startButton.addEventListener('click', startTimer);
pauseButton.addEventListener('click', pauseTimer);
resetButton.addEventListener('click', resetTimer);
durationSelect.addEventListener('change', resetTimer);

renderTime();

This complete src/renderer.js requests the notification from the existing completion branch.

Before you run the final test, consider whether Windows can show the notification while another app has focus.

  • Return to the PowerShell terminal.
  • Launch the completed development app by running this command:
npm start

What is running now?

Electron Forge launches the main process. The main process creates the window with the preload bridge attached.

  • Select 5 seconds, demo from the session length selector.
  • Click Start.
  • Switch to another Windows app from the taskbar.
  • Wait for the five-second countdown to finish.

Windows should show a notification titled Focus session complete. Its body should say Time for a short break..

That closes the attention gap from the previous step. Your timer can now reach you while another app is active.

No Windows notification?

Confirm that electron.exe is pinned to the Windows Start menu. Check that app.setAppUserModelId(process.execPath) runs inside app.whenReady().

Check that src/preload.js sends timer:complete. Confirm that src/index.js listens on the same channel.

Help me troubleshoot the missing Windows notification.

Your timer now crosses the boundary from a web interface into native Windows behavior. Next up, you will package it as an installable desktop app.

Package the Windows App

Your focus timer now reaches beyond its own window through a native Windows notification. The final numbered step turns that working development app into something you can install.

The development app still depends on your focus-timer source folder. Electron Forge packages that code with Squirrel.Windows to produce a local installer.

In this step, get ready to:
  • Remove the temporary development identity from the main process.
  • Generate a Squirrel.Windows installer from the pinned Forge configuration.
  • Install the packaged app on your Windows PC.
Prepare the app for packaging

The development identity line helped Windows associate notifications with the pinned Electron executable. Squirrel.Windows configures the packaged app through its installed shortcut.

  • In the Visual Studio Code file list, select src/index.js.
  • Find app.setAppUserModelId(process.execPath); inside the app.whenReady() callback.
  • Delete that line.
  • Save src/index.js by pressing Ctrl+S.

✔️ Awesome, I've got everything!

Your final src/index.js no longer contains the temporary development identity line. The secure window settings remain intact.

ⓧ I'd like to double check the full code

  • Compare your saved src/index.js with this final version:
const { app, BrowserWindow, ipcMain, Notification } = require('electron');
const path = require('node:path');

if (require('electron-squirrel-startup')) {
  app.quit();
}

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 420,
    height: 560,
    resizable: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  });

  mainWindow.loadFile(path.join(__dirname, 'index.html'));
}

app.whenReady().then(() => {
  ipcMain.on('timer:complete', (event) => {
    if (event.sender.id !== mainWindow?.webContents.id) {
      return;
    }

    new Notification({
      title: 'Focus session complete',
      body: 'Time for a short break.',
    }).show();
  });

  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

What changed in the main process?

The temporary development identity line is gone. The validated notification listener remains inside app.whenReady().

The early electron-squirrel-startup check remains in place. It handles Squirrel startup events before the main window opens.

Packaging also depends on the project metadata already stored in package.json. The Forge configuration selects the maker that produces the installer.

  • Select package.json from the Visual Studio Code file list.
  • Confirm that productName is set to FocusTimer.
  • Confirm that author is set to NextWork Learner.
  • Confirm that description is set to A starter Electron focus timer.
  • Confirm that electron-squirrel-startup remains pinned to 1.0.1.
  • Select forge.config.js from the Visual Studio Code file list.
  • Confirm that the configured maker is @electron-forge/maker-squirrel.

Why Keep This Metadata?

The Squirrel.Windows maker reads author from package.json. It also reads description from the same file.

The productName value supplies the packaged app's name. The maker configuration turns that app into Windows distributables.

Build the Squirrel installer

The Forge make task packages the app before passing it to the configured Windows maker. Its generated output includes the installer you can run on your own PC.

  • Return to the integrated PowerShell terminal from earlier.
  • Stop the development app by pressing Ctrl+C.

Before you build, which installer filename do you expect the FocusTimer product name to produce?

  • Build the Windows installer by running this command:
npm run make

What Does This Command Do?

  • The make script invokes Electron Forge through the script in package.json.
  • Electron Forge packages your source files into a desktop application.
  • The configured Squirrel.Windows maker creates the Windows distributables under out.
  • Wait for PowerShell to return to its prompt.
  • Expand out in the Visual Studio Code file list.
  • Search the generated folders for FocusTimer Setup.exe.

The terminal completes the Make task. You will see FocusTimer Setup.exe inside the generated Forge output.

Did the Make Task Fail?

  • Confirm that PowerShell is inside the focus-timer folder.
  • Check that the development app stopped before you started the make task.
  • Compare the metadata in package.json with the values confirmed above.

Ask for help with my Forge build

That is a substantial milestone. FocusTimer now exists as a locally installable Windows artifact.

Install FocusTimer for a final test

The installer registers FocusTimer as a local desktop app on your own PC. This local build is ready for learning or demonstration.

Public distribution requires additional release work. Code signing is outside this starter project.

  • Return to the generated FocusTimer Setup.exe file.
  • Open the installer by double-clicking it.
  • Wait for the local installation to finish.
  • Press the Windows key to search your installed apps.
  • Type FocusTimer into the search field.
  • Press Enter to launch the installed app.

Before the final test, do you expect the native notification behavior to survive the move from development to the installed app?

  • Select 5 seconds, demo from the Session length selector.
  • Click Start.
  • Switch to another open Windows app.
  • Wait five seconds.

The timer reaches 00:00. Its status changes to Session complete.

Windows shows a notification titled Focus session complete with the body Time for a short break.

Missing the Installed Notification?

  • Confirm that you launched FocusTimer from the installed apps search.
  • Confirm that the timer reached 00:00.
  • Run FocusTimer Setup.exe again if FocusTimer does not appear in the installed apps search.

Help me test the installed notification

You have completed the full desktop loop. FocusTimer now runs from a local Windows installation while preserving its five-second timer and native completion notification.

Secret mission

Add a Spacebar Shortcut

Give FocusTimer a guarded Spacebar shortcut for faster control from the keyboard. The shortcut starts or pauses the countdown while preserving normal keyboard behavior on buttons and the duration selector.

Clean Up Your Resources

Clean Up Your Resources

Everything you created stays on your Windows PC with no ongoing charges. Decide whether to keep your resources, pause your work, or delete everything.

Resources you used:

  • Your focus-timer source folder with its pinned dependencies in node_modules.
  • Build output generated by Electron Forge in out, including FocusTimer Setup.exe.
  • Your optional locally installed FocusTimer app.

Keep everything running

No action is needed. Choose this option if you want to keep using or improving your focus timer.

  • Keep the focus-timer folder so its source code remains available.
  • Retain FocusTimer Setup.exe so you can install the current build again.
  • Leave FocusTimer installed so you can launch the timer from Windows.

Pause - I'll come back to this later

Shut down the open apps to free their system resources. Your source code, dependencies, installer, and installed app remain available.

  • Save any open files in Visual Studio Code.
  • Close FocusTimer.
  • Close Visual Studio Code.

Your focus-timer folder remains ready for your next session. Pausing the project creates no ongoing charge.

Delete - I don't want to use this again

This cleanup is permanent, so take a moment before continuing. Windows removes only the local app and files created for this project.

  • Close FocusTimer if it is running.
  • Press the Windows key to open Windows search.
  • Type FocusTimer.
  • Right-click the FocusTimer result.
  • Click Uninstall.
  • Confirm the removal when Windows asks.

That's the installed copy cleared. Your remaining project files are together in the focus-timer folder.

  • Right-click the focus-timer folder in the Visual Studio Code Explorer sidebar.
  • Choose the option that reveals the folder in File Explorer.
  • Close Visual Studio Code.
  • Right-click the focus-timer folder in File Explorer.
  • Select the delete option.
  • Confirm deletion if Windows asks.
  • Confirm that focus-timer is no longer listed in File Explorer.

Your source code and pinned dependencies are now removed. The out folder and FocusTimer Setup.exe are removed with them.

Nice Work!

Nice Work!

You did it! You built FocusTimer as an installable Windows desktop app with Electron.

You've learned how to:

  • Build a live countdown interface with Start, Pause, Reset, duration presets, and a five-second demo option.
  • Separate renderer logic from privileged Electron behavior through a secure preload bridge with validated IPC. This narrow API lets the main process trigger a native Windows notification when a session ends.
  • Package FocusTimer with Electron Forge and Squirrel.Windows. The generated FocusTimer Setup.exe gives you a locally installable Windows app.
  • Secret Mission: Control the timer with a guarded Spacebar shortcut from the page background. The shortcut preserves normal button and duration-selector behavior. It reuses the existing timer state to avoid a second interval.

Ready to quiz yourself?