Build a Time-Blocking Planner
Build and deploy a drag-and-drop time-blocking planner with Vite and Vercel.
Introduction
30 Second Summary
You write a to-do list every morning. By 3pm, nothing is done. The list told you what to do, but it never told you when. Without a time commitment, every task feels optional.
In this project, you will build a time-blocking planner that turns vague intentions into scheduled appointments with yourself. You will deploy it live on the web so you can open it every day and actually follow through.
What You'll Build
You open your planner, dump today's tasks into an inbox, then drag each one onto a specific hour in your day. Suddenly your afternoon has structure, and you can share the live URL with anyone.
By the end of this project, you'll have:
- A live productivity app deployed on Vercel where you type in tasks, drag them onto hourly time slots, and watch your day take shape.
- A drag-and-drop scheduling flow that transforms "I should do this" into "I am doing this at 2pm."
- Persistent data powered by localStorage so your plan survives page refreshes and is always there when you come back.
- Secret Mission: Add a Focus Mode that shows only your current time block, hiding everything else to eliminate distraction.
Are there any prerequisites?
You should be comfortable with basic JavaScript (variables, functions, and DOM manipulation). You will also need Node.js installed on your machine. The project walks you through everything else from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you personally.
Set Up the Project
You need a local development environment that gives you instant feedback as you code. Without fast feedback, your effort goes toward fighting tooling instead of building the product.
In this step, you will scaffold a project using Vite (a modern build tool that hot-reloads changes instantly), clean up the starter files, and create a free Vercel account for deployment later.
In this step, get ready to:
- Scaffold a Vite vanilla JavaScript project and install dependencies.
- Clean up the starter files and verify the dev server runs.
- Create a Vercel account for free deployment later.
Scaffold the project with Vite
Vite scaffolds a ready-to-run project in seconds. You will use the vanilla JavaScript template so there is no framework overhead.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Vite project by running this command:
npm create vite@latest timeblock -- --template vanilla
What will you see?
If this is your first time using create-vite, npm will ask: "Need to install the following packages: create-vite@9.1.1. Ok to proceed? (y)".
Type y and press Enter to continue. After that, Vite scaffolds the project with no further prompts.
- Move into the new project folder and install dependencies by running these commands:
cd timeblock
npm install
You should see output confirming packages were added, ending with a line like "added X packages".
- Start the dev server to confirm everything works by running:
npm run dev
Before you look at the browser, what do you expect to see at localhost:5173?
Open http://localhost:5173 in your browser. You should see the Vite starter page with the Vite logo and a counter button.
Don't see the page?
- Make sure npm run dev is still running in your terminal. If you closed it, run the command again.
- Check that you are visiting http://localhost:5173 (not a different port).
- If you see a port conflict, Vite picks the next available port and prints it in the terminal output.
Help me troubleshoot my Vite dev server not loading in the browser.
Clean up the scaffold and replace the starter code
The Vite template comes with demo files you do not need. You will strip it down to a blank slate so your time-blocking planner starts clean.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Open the timeblock project in your code editor. In VS Code, press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the timeblock folder, and click Open.
Now clean up the starter files.
- In the Explorer sidebar, right-click src/counter.js and select Delete.
- Open src/style.css, select all the content (Cmd+A on macOS or Ctrl+A on Windows), and delete it so the file is empty. Save the file.
- Open src/main.js, select all the content, delete it so the file is empty, and save.
- Open index.html and replace its entire contents with this minimal shell:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TimeBlock</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this HTML do?
- The <div id="app"> is the empty container where your entire planner will be built with JavaScript.
- The <script type="module" src="/src/main.js"> tag loads your JavaScript entry point.
- The <title>TimeBlock</title> sets the browser tab title to your app name.
- Save index.html.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Start the dev server again by running:
npm run dev
Open http://localhost:5173 in your browser. You should see a blank white page with the browser tab reading "TimeBlock".
Still seeing the old Vite page?
- Do a hard refresh: Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
- Make sure you saved all three files (index.html, src/main.js, src/style.css).
- Confirm src/counter.js is deleted. If main.js still imports it, you will see a console error.
Help me figure out why my browser is still showing the old Vite starter page after I cleaned up the files.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and the dev server is running.
ⓧ I'd like to double check the full code
Here is the complete index.html you should have:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TimeBlock</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Your src/main.js and src/style.css should both be completely empty (no content at all).
Create a Vercel account
You will deploy your finished app to Vercel in a later step. For now, just confirm you can sign in so deployment is frictionless when you get there.
- Open https://vercel.com in your browser.
- Click Sign Up.
- Select Continue with GitHub and authorize the connection.
- When asked to choose a plan, select the Hobby plan (free forever for personal projects).
- Confirm you can see the Vercel dashboard. No deployment needed yet.
Why Vercel?
Vercel lets you deploy a static site by dragging a folder onto a webpage. No CLI setup, no Git integration required for the first deploy.
The Hobby plan is free forever for personal, non-commercial use. Your time-blocking planner fits perfectly.
Having trouble signing up?
- Make sure you are signed into GitHub in your browser first. Vercel uses GitHub OAuth.
- If you see a permissions prompt, click Authorize to let Vercel access your GitHub account.
Help me sign up for Vercel using my GitHub account.
Your environment is ready and your blank canvas is live. Next up, you will build the task inbox and see your first feature appear in the browser.
Build the Naive Task List
A product engineer ships the simplest version that could work first. Your hypothesis right now is: "maybe a task list is enough to stop procrastinating."
You will build it, use it, and observe whether it creates urgency. Spoiler: it will not. But that gap is the whole point. You need to feel the limitation before you know what to build next.
In this step, get ready to:
- Build a task inbox with an input field and add button.
- Wire up localStorage so tasks survive a page refresh.
- Style the app with a dark theme so it feels like a real tool.
Build the task inbox HTML
Every product starts with structure. Your HTML shell gives the browser the skeleton of your app: a header, an input form, and two containers for the inbox and timeline.
- In VS Code, open index.html and replace its entire contents with this markup:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TimeBlock</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
</head>
<body>
<div id="app">
<header>
<h1>TimeBlock</h1>
<p class="subtitle">Commit your tasks to time.</p>
</header>
<div class="add-task">
<input type="text" id="task-input" placeholder="What needs doing today?" />
<button id="add-btn">Add Task</button>
</div>
<div class="layout">
<div class="inbox">
<h2>Inbox</h2>
<ul id="inbox-list"></ul>
</div>
<div class="timeline">
<h2>Today</h2>
<div id="timeline-slots"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this HTML do?
The header gives the app its name and subtitle. The .add-task div holds the input form for capturing new tasks.
The .layout div sets up two sections: an inbox (where unscheduled tasks live) and a timeline (for later, when you add time-blocking). You are building the skeleton for both now, even though only the inbox works in this step.
- Save index.html.
- Check your browser at localhost:5173. You should see a white page with the text "TimeBlock", "Commit your tasks to time.", an input field, an "Add Task" button, and the headings "Inbox" and "Today".
Don't see anything in the browser?
- Make sure your dev server is still running (npm run dev in the terminal). If you stopped it, restart it now.
- Check that you replaced the entire file contents, not just the <body> section.
Still stuck? Why isn't my index.html showing in the browser?
Wire up the task logic in JavaScript
The HTML is just a skeleton. JavaScript brings it to life by managing your task data, saving it to localStorage, and rendering the list.
- Open src/main.js and add the data layer that stores and retrieves tasks:
import './style.css';
// Key used to store tasks in the browser's localStorage
const STORAGE_KEY = 'timeblock-tasks';
// Load any previously saved tasks (or start with an empty array)
let tasks = loadTasks();
function loadTasks() {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
}
function saveTasks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
// Generate a unique ID for each task
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
What does this code do?
- loadTasks() reads the saved JSON string from localStorage and parses it back into an array. If nothing is saved yet, it returns an empty array.
- saveTasks() writes the current tasks array to localStorage as a JSON string. This runs every time a task is added or toggled.
- generateId() creates a unique string using the current timestamp and a random suffix. Each task needs its own ID so you can find and update it later.
- Below the code you just added, paste the functions that create and toggle tasks:
function addTask(title) {
// Create a task object with all the properties we'll need
const task = {
id: generateId(),
title: title.trim(),
scheduledHour: null,
done: false,
};
tasks.push(task);
saveTasks();
render();
}
function toggleDone(id) {
const task = tasks.find((t) => t.id === id);
if (task) {
task.done = !task.done;
saveTasks();
render();
}
}
What does this code do?
- addTask() builds a task object with an ID, title, a scheduledHour (null for now, used in a later step), and a done flag. It pushes the task into the array, saves to localStorage, and re-renders the UI.
- toggleDone() finds a task by ID and flips its done boolean. This powers the checkbox behavior.
- Below that, add the function that turns a task object into a DOM element:
function createTaskElement(task) {
const li = document.createElement('li');
li.className = `task-item${task.done ? ' done' : ''}`;
li.dataset.id = task.id;
// Checkbox to mark tasks complete
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.done;
checkbox.addEventListener('change', () => toggleDone(task.id));
// Task title text
const span = document.createElement('span');
span.textContent = task.title;
li.appendChild(checkbox);
li.appendChild(span);
return li;
}
What does this code do?
createTaskElement() builds a list item with a checkbox and a text span for each task. When the checkbox changes, it calls toggleDone() to flip the task's status and re-render.
- Finally, add the render function and event listeners that tie everything together:
function render() {
// Clear the inbox and re-populate it from the tasks array
const inboxList = document.getElementById('inbox-list');
inboxList.innerHTML = '';
tasks.forEach((task) => {
inboxList.appendChild(createTaskElement(task));
});
}
// Listen for clicks on the Add Task button
document.getElementById('add-btn').addEventListener('click', () => {
const input = document.getElementById('task-input');
if (input.value.trim()) {
addTask(input.value);
input.value = '';
input.focus();
}
});
// Also allow pressing Enter to add a task
document.getElementById('task-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const input = e.target;
if (input.value.trim()) {
addTask(input.value);
input.value = '';
}
}
});
// Initial render on page load
render();
What does this code do?
- render() clears the inbox list and rebuilds it from the tasks array. Every time data changes, this function runs to keep the UI in sync.
- The two event listeners handle both the button click and the Enter key, so adding a task feels fast and natural.
- The final render() call runs once on page load to display any previously saved tasks.
- Save src/main.js.
- Check your browser. Type a task (like "Write project report") and click Add Task or press Enter. You should see the task appear as a list item with a checkbox.
Tasks not appearing?
- Open DevTools (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows) and check the Console tab for errors. A typo in a function name will show as "is not defined".
- Confirm you added all four code blocks in order to src/main.js. Missing the render() call at the bottom means nothing will display.
Need help? My tasks aren't showing up in the inbox list after clicking Add Task.
Verify localStorage persistence
Your task list already saves to localStorage on every change. Let's confirm that your data survives a page refresh, which proves the persistence layer works.
- Add 3 tasks to your inbox (type each one and press Enter).
- Refresh the page (Cmd+R on macOS, Ctrl+R on Windows).
- Confirm all 3 tasks still appear in the inbox after the refresh.
Why localStorage?
For a personal tool like this, localStorage is the perfect persistence layer. It stores data in the browser with zero setup, zero cost, and zero backend. Your tasks survive refreshes and even closing the tab.
A product engineer buys infrastructure only when the simple solution fails. localStorage will not fail for a single-user planner.
Style the app with a dark theme
The app works, but it looks raw. A minimal dark theme makes it feel like a real tool without burning time on polish. Ship speed over perfection.
- Open src/style.css and add the base reset and layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
}
#app {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1rem;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 1.8rem;
color: #ffffff;
}
.subtitle {
color: #888;
font-size: 0.9rem;
margin-top: 0.25rem;
}
What do these styles do?
The reset (*) removes default browser spacing so you start from a clean slate. The dark background (#0f0f0f) with light text (#e0e0e0) creates a focused, low-distraction look. The #app container centers the content with a max width.
- Below the code you just added, paste the input and button styles:
.add-task {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.add-task input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 1rem;
}
.add-task input:focus {
outline: none;
border-color: #646cff;
}
.add-task button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
background: #646cff;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
white-space: nowrap;
}
.add-task button:hover {
background: #535bf2;
}
- Below that, add the layout grid and task item styles:
.layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
}
.inbox h2,
.timeline h2 {
font-size: 1rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
#inbox-list {
list-style: none;
}
.task-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 6px;
margin-bottom: 0.5rem;
cursor: grab;
transition: border-color 0.15s;
}
- Finally, add the hover states, checkbox sizing, and responsive breakpoint:
.task-item:hover {
border-color: #646cff;
}
.task-item.done span {
text-decoration: line-through;
color: #666;
}
.task-item input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
}
.task-item span {
flex: 1;
}
@media (max-width: 600px) {
.layout {
grid-template-columns: 1fr;
}
}
- Save src/style.css.
- Check your browser. You should see a dark-themed app with a centered layout, a purple "Add Task" button, and your previously added tasks displayed as styled cards with checkboxes.
Styles not applying?
- Confirm the very first line of src/main.js is import './style.css'; — this tells Vite to load your stylesheet.
- Check that you pasted all four CSS blocks into src/style.css in order, with no stray characters between them.
Still off? My dark theme CSS isn't showing up in the browser.
Before you move on, do a quick predict-then-check. What do you think happens when you check a task's checkbox and then refresh the page? Will it still be checked?
- Check a task's checkbox, then refresh the page.
- Confirm the task still shows as checked with a strikethrough. Your persistence is working end to end.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: index.html, src/main.js, and src/style.css.
ⓧ I'd like to double check the full code
Scroll up and confirm each code block is present in the correct file, in order:
- index.html: the full HTML shell (one block).
- src/main.js: data layer, then addTask/toggleDone, then createTaskElement, then render/event listeners (four blocks in order).
- src/style.css: reset/layout, then input/button, then grid/task-item base, then hover/checkbox/responsive (four blocks in order).
You have a working task list deployed locally with persistence. It does the job of capturing tasks. But look at it. Does this list tell you what to do at 2pm? Does it create any urgency? The next step puts this question to the test.
Add the Day Timeline and Drag-and-Drop
You felt the limitation firsthand. Your task list tells you what to do but says nothing about when. There is no urgency, no commitment, no structure.
Now you build the feature that actually fights procrastination: a visual day timeline where dragging a task onto "2:00 PM" transforms a vague intention into a concrete appointment with yourself.
In this step, get ready to:
- Build a visual day timeline with hourly slots from 6 AM to 10 PM.
- Implement drag-and-drop so tasks move from the inbox to specific time slots.
- Persist the schedule so time-blocked tasks survive a page refresh.
Add hour constants and the format helper
The timeline needs to know which hours to display. You will define the range (6 AM to 10 PM) as constants and write a helper that converts 24-hour numbers into readable labels like "2:00 PM".
- In src/main.js, find the line that says const STORAGE_KEY = 'timeblock-tasks';.
- Add these two constants directly below it:
const START_HOUR = 6;
const END_HOUR = 22;
- Next, find the addTask function. Add the following formatHour function above it:
function formatHour(hour) {
const period = hour >= 12 ? 'PM' : 'AM';
const display = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
return `${display}:00 ${period}`;
}
What does this code do?
START_HOUR and END_HOUR define the day range your timeline covers: 6 AM to 10 PM (16 hourly slots).
formatHour converts a 24-hour number (like 14) into a readable string (like "2:00 PM") for display in the timeline.
Render the timeline grid
The current render() function dumps every task into the inbox. You need it to split tasks into two groups: unscheduled tasks stay in the inbox, and the timeline shows 16 hourly slots.
- In src/main.js, find the existing render() function and replace it entirely with this code:
function render() {
const inboxList = document.getElementById('inbox-list');
const timelineSlots = document.getElementById('timeline-slots');
const inboxTasks = tasks.filter((t) => t.scheduledHour === null);
const scheduledTasks = tasks.filter((t) => t.scheduledHour !== null);
inboxList.innerHTML = '';
inboxTasks.forEach((task) => {
inboxList.appendChild(createTaskElement(task));
});
timelineSlots.innerHTML = '';
for (let hour = START_HOUR; hour < END_HOUR; hour++) {
const slot = document.createElement('div');
slot.className = 'time-slot';
slot.dataset.hour = hour;
const label = document.createElement('div');
label.className = 'time-label';
label.textContent = formatHour(hour);
const dropZone = document.createElement('div');
dropZone.className = 'drop-zone';
slot.appendChild(label);
slot.appendChild(dropZone);
timelineSlots.appendChild(slot);
}
}
What does this code do?
- tasks.filter((t) => t.scheduledHour === null) separates unscheduled tasks (inbox) from time-blocked tasks.
- The for loop creates 16 hourly slots (6 AM to 10 PM), each with a label and an empty drop zone where tasks will land.
- Only unscheduled tasks appear in the inbox now. Once you add drag-and-drop, tasks will move out of the inbox and into their assigned slot.
- Save src/main.js and check your browser.
You should see your tasks still in the inbox on the left. On the right, 16 lines of text appear showing hours from "6:00 AM" to "9:00 PM". They look plain because we have not styled them yet.
- Add the timeline styles to src/style.css. Place this code above the @media query at the bottom of the file:
.time-slot {
display: flex;
gap: 1rem;
align-items: flex-start;
min-height: 3.5rem;
border-bottom: 1px solid #1a1a1a;
padding: 0.5rem 0;
}
.time-label {
width: 5rem;
font-size: 0.8rem;
color: #666;
padding-top: 0.6rem;
flex-shrink: 0;
}
.drop-zone {
flex: 1;
min-height: 2.5rem;
border: 1px dashed transparent;
border-radius: 6px;
padding: 0.25rem;
transition: border-color 0.15s, background 0.15s;
}
.drop-zone.drag-over {
border-color: #646cff;
background: rgba(100, 108, 255, 0.05);
}
What does this code do?
- .time-slot lays each hour out as a row with a fixed-width label on the left and a flexible drop zone on the right.
- .drop-zone starts with a transparent dashed border. When a task is dragged over it, the .drag-over class reveals a purple highlight to show "drop here".
- Save src/style.css and check your browser.
The right column now shows a clean timeline grid. Each row has a dim hour label on the left and an empty space on the right where tasks will eventually land.
Don't see the timeline slots?
- Check that START_HOUR and END_HOUR are declared right after STORAGE_KEY. If they are inside a function, the render() loop cannot find them.
- Make sure you replaced the entire old render() function, including its closing }. A duplicate function definition causes a syntax error.
- Open DevTools (F12) and check the console for errors. A typo in formatHour or a missing function will show up there.
Why aren't my timeline slots showing up in the browser?
Make tasks draggable and time slots droppable
The timeline exists visually, but you cannot interact with it yet. The HTML5 Drag and Drop API lets you grab a task from the inbox and drop it onto a time slot using native browser events.
- In src/main.js, find the toggleDone function. Add these two new functions directly below it:
function scheduleTask(id, hour) {
const task = tasks.find((t) => t.id === id);
if (task) {
task.scheduledHour = hour;
saveTasks();
render();
}
}
function unscheduleTask(id) {
const task = tasks.find((t) => t.id === id);
if (task) {
task.scheduledHour = null;
saveTasks();
render();
}
}
What does this code do?
- scheduleTask assigns a task to a specific hour by setting its scheduledHour property. This moves it out of the inbox and into a time slot.
- unscheduleTask reverses the process by resetting scheduledHour to null, moving the task back to the inbox.
- Both call saveTasks() and render() so the change persists and the UI updates immediately.
- Now replace the existing createTaskElement function with this updated version that makes tasks draggable and adds an unschedule button:
function createTaskElement(task) {
const li = document.createElement('li');
li.className = `task-item${task.done ? ' done' : ''}`;
li.draggable = true;
li.dataset.id = task.id;
li.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', task.id);
li.classList.add('dragging');
});
li.addEventListener('dragend', () => {
li.classList.remove('dragging');
});
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.done;
checkbox.addEventListener('change', () => toggleDone(task.id));
const span = document.createElement('span');
span.textContent = task.title;
li.appendChild(checkbox);
li.appendChild(span);
- Continue the function by adding the unschedule button and the closing return directly below:
if (task.scheduledHour !== null) {
const removeBtn = document.createElement('button');
removeBtn.className = 'unschedule-btn';
removeBtn.textContent = '\u2715';
removeBtn.title = 'Move back to inbox';
removeBtn.addEventListener('click', () => unscheduleTask(task.id));
li.appendChild(removeBtn);
}
return li;
}
What does this code do?
- li.draggable = true tells the browser this element can be picked up and dragged.
- The dragstart event stores the task's ID in the drag payload using dataTransfer.setData(). The drop zone reads this ID to know which task landed.
- The unschedule button (the ✕ icon) only appears on tasks that have been assigned to a time slot. Clicking it sends the task back to the inbox.
- Save src/main.js and check your browser.
Try clicking and dragging a task from the inbox. Your cursor should change to a grab icon and the task fades (opacity drops). You cannot drop it anywhere useful yet because the time slots do not listen for drops.
- In src/main.js, find this line inside the render() function's for loop:
const dropZone = document.createElement('div');
dropZone.className = 'drop-zone';
- Add the following code between dropZone.className = 'drop-zone'; and slot.appendChild(label);:
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const taskId = e.dataTransfer.getData('text/plain');
scheduleTask(taskId, hour);
});
const slotTasks = scheduledTasks.filter((t) => t.scheduledHour === hour);
slotTasks.forEach((task) => {
dropZone.appendChild(createTaskElement(task));
});
What does this code do?
- dragover calls e.preventDefault() to allow dropping (browsers block drops by default) and adds a highlight class.
- drop reads the task ID from the drag payload and calls scheduleTask(taskId, hour) to assign that task to this time slot.
- The slotTasks loop renders any tasks already scheduled for this hour inside the drop zone.
- Finally, add the drag-and-drop styles to src/style.css. Place this code after the .task-item:hover rule:
.task-item.dragging {
opacity: 0.5;
}
.unschedule-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 0.9rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.unschedule-btn:hover {
color: #ff6b6b;
background: #2a1a1a;
}
What does this code do?
.task-item.dragging fades the task while you drag it, giving visual feedback that something is in motion. .unschedule-btn styles the small ✕ button that appears on scheduled tasks, turning red on hover to signal "remove from slot".
- Save both src/main.js and src/style.css.
- Drag a task from the inbox and hover it over a time slot. You should see the drop zone highlight purple.
- Release the task onto the slot. It should disappear from the inbox and appear inside that time slot with a small ✕ button.
Task won't drop into a slot?
- The most common cause is a missing e.preventDefault() in the dragover handler. Browsers block drops by default, so you must explicitly allow them.
- Check that scheduleTask is defined above the render() function. If it is below render(), the drop handler cannot call it.
- Make sure li.draggable = true is set in createTaskElement. Without it, the drag never starts.
Help me debug why drag-and-drop is not working in my TimeBlock app.
Verify the schedule persists
Because scheduleTask calls saveTasks(), your time-blocked layout already saves to localStorage every time you drop a task. The render() function already reads scheduledHour and places tasks in the correct slot on page load. No new code needed.
Before you test this, what do you think will happen when you refresh the page? Will your time-blocked tasks stay in their slots, or revert to the inbox?
- Make sure you have at least one task dragged into a time slot.
- Refresh the page (F5 or Cmd+R).
Your time-blocked tasks should still be sitting in their assigned time slots. Unscheduled tasks remain in the inbox. The schedule survives a refresh because the full task state (including scheduledHour) is persisted in localStorage.
- Click the ✕ button on a scheduled task. It should jump back to the inbox.
- Refresh again to confirm the unschedule also persists.
Tasks disappear after refresh?
- Open DevTools, go to the **Application** tab, and expand **Local Storage**. Click your localhost:5173 entry. You should see a timeblock-tasks key with JSON data that includes scheduledHour values.
- If scheduledHour is missing from the stored JSON, make sure scheduleTask calls saveTasks() after setting the property.
My tasks are not persisting after a page refresh. How do I debug localStorage?
✔️ Awesome, I've got everything!
Great. Make sure you have saved both src/main.js and src/style.css.
ⓧ I'd like to double check the full code
Here is the complete src/main.js at this point. Scroll through and confirm each section is present in order: constants, loadTasks/saveTasks/generateId/formatHour, addTask/toggleDone/scheduleTask/unscheduleTask, createTaskElement, render, and the event listeners at the bottom.
Your index.html has not changed in this step.
Check your src/main.js top half (constants through unscheduleTask):
import './style.css';
const STORAGE_KEY = 'timeblock-tasks';
const START_HOUR = 6;
const END_HOUR = 22;
let tasks = loadTasks();
function loadTasks() {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
}
function saveTasks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
function formatHour(hour) {
const period = hour >= 12 ? 'PM' : 'AM';
const display = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
return `${display}:00 ${period}`;
}
Task functions (addTask through unscheduleTask):
function addTask(title) {
const task = {
id: generateId(),
title: title.trim(),
scheduledHour: null,
done: false,
};
tasks.push(task);
saveTasks();
render();
}
function toggleDone(id) {
const task = tasks.find((t) => t.id === id);
if (task) {
task.done = !task.done;
saveTasks();
render();
}
}
function scheduleTask(id, hour) {
const task = tasks.find((t) => t.id === id);
if (task) {
task.scheduledHour = hour;
saveTasks();
render();
}
}
The unscheduleTask and createTaskElement functions:
function unscheduleTask(id) {
const task = tasks.find((t) => t.id === id);
if (task) {
task.scheduledHour = null;
saveTasks();
render();
}
}
function createTaskElement(task) {
const li = document.createElement('li');
li.className = `task-item${task.done ? ' done' : ''}`;
li.draggable = true;
li.dataset.id = task.id;
li.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', task.id);
li.classList.add('dragging');
});
li.addEventListener('dragend', () => {
li.classList.remove('dragging');
});
The rest of createTaskElement:
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.done;
checkbox.addEventListener('change', () => toggleDone(task.id));
const span = document.createElement('span');
span.textContent = task.title;
li.appendChild(checkbox);
li.appendChild(span);
if (task.scheduledHour !== null) {
const removeBtn = document.createElement('button');
removeBtn.className = 'unschedule-btn';
removeBtn.textContent = '\u2715';
removeBtn.title = 'Move back to inbox';
removeBtn.addEventListener('click', () => unscheduleTask(task.id));
li.appendChild(removeBtn);
}
return li;
}
The render() function (inbox section):
function render() {
const inboxList = document.getElementById('inbox-list');
const timelineSlots = document.getElementById('timeline-slots');
const inboxTasks = tasks.filter((t) => t.scheduledHour === null);
const scheduledTasks = tasks.filter((t) => t.scheduledHour !== null);
inboxList.innerHTML = '';
inboxTasks.forEach((task) => {
inboxList.appendChild(createTaskElement(task));
});
The render() function (timeline section):
timelineSlots.innerHTML = '';
for (let hour = START_HOUR; hour < END_HOUR; hour++) {
const slot = document.createElement('div');
slot.className = 'time-slot';
slot.dataset.hour = hour;
const label = document.createElement('div');
label.className = 'time-label';
label.textContent = formatHour(hour);
const dropZone = document.createElement('div');
dropZone.className = 'drop-zone';
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
The drop handler and closing of the render() loop:
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const taskId = e.dataTransfer.getData('text/plain');
scheduleTask(taskId, hour);
});
const slotTasks = scheduledTasks.filter((t) => t.scheduledHour === hour);
slotTasks.forEach((task) => {
dropZone.appendChild(createTaskElement(task));
});
slot.appendChild(label);
slot.appendChild(dropZone);
timelineSlots.appendChild(slot);
}
}
The event listeners at the bottom (unchanged from the prior step):
document.getElementById('add-btn').addEventListener('click', () => {
const input = document.getElementById('task-input');
if (input.value.trim()) {
addTask(input.value);
input.value = '';
input.focus();
}
});
document.getElementById('task-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const input = e.target;
if (input.value.trim()) {
addTask(input.value);
input.value = '';
}
}
});
render();
Check your src/style.css new rules (these should appear after .task-item:hover and before @media):
.task-item.dragging {
opacity: 0.5;
}
.task-item.done span {
text-decoration: line-through;
color: #666;
}
.task-item input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
}
.task-item span {
flex: 1;
}
.unschedule-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 0.9rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.unschedule-btn:hover {
color: #ff6b6b;
background: #2a1a1a;
}
.time-slot {
display: flex;
gap: 1rem;
align-items: flex-start;
min-height: 3.5rem;
border-bottom: 1px solid #1a1a1a;
padding: 0.5rem 0;
}
.time-label {
width: 5rem;
font-size: 0.8rem;
color: #666;
padding-top: 0.6rem;
flex-shrink: 0;
}
.drop-zone {
flex: 1;
min-height: 2.5rem;
border: 1px dashed transparent;
border-radius: 6px;
padding: 0.25rem;
transition: border-color 0.15s, background 0.15s;
}
.drop-zone.drag-over {
border-color: #646cff;
background: rgba(100, 108, 255, 0.05);
}
Your planner now has the core anti-procrastination mechanic. Tasks are no longer vague intentions. They are appointments locked to specific hours. Next up, you will ship this to a live URL so you can actually use it every day.
Deploy to Vercel
A product that only lives on your laptop is not a product. Right now your time-blocking planner runs only on localhost:5173. Nobody else can see it, and you cannot open it on your phone.
Deploying to a live URL makes your planner real. You can bookmark it as your daily planning page, open it on any device, or share it with a friend. This is the "ship daily" discipline of product engineering: if it is not live, it does not count.
In this step, get ready to:
- Build a production bundle that is optimized for the web.
- Deploy your app to Vercel using Vercel Drop for a live URL.
- Verify the full flow works at your live URL.
Build the production bundle
During development, Vite serves your code unminified with hot-reload features. For production, you need a smaller, faster bundle with all files optimized and ready to upload.
- Return to your terminal and stop the dev server by pressing Ctrl+C.
- Generate the production bundle by running this command:
npm run build
- Confirm the dist/ folder was created by running:
ls dist
You should see index.html and an assets/ folder listed. These are your production-ready files.
What does npm run build do?
Vite bundles your JavaScript and CSS into minified files, hashes them for cache-busting, and outputs everything into a self-contained dist/ folder. This folder is a complete static site that any hosting service can serve.
- Preview the production build locally by running:
npm run preview
Your terminal displays a local URL at http://localhost:4173/. Open it in your browser.
- Add a task, drag it to a time slot, and refresh the page to confirm persistence works.
- Stop the preview server with Ctrl+C once you have confirmed it works.
Preview showing a blank page?
Make sure you ran npm run build first. The preview command serves the dist/ folder, so it must exist before you preview.
If the page loads but tasks do not persist, check your browser console for errors. A typo in localStorage calls is the most common cause.
Help me debug this
Deploy to Vercel using Vercel Drop
Vercel Drop lets you deploy a static site by dragging a folder onto a web page. No CLI install, no Git integration required for your first deploy.
- Open your browser and go to vercel.com/drop.
- Open Finder (macOS) or File Explorer (Windows) and navigate to your timeblock project folder.
- Find the dist folder inside it.
- Drag the entire dist folder from your file browser onto the Vercel Drop page.
- Choose your team (your personal account).
- Enter timeblock as the project name.
- Click Deploy.
Vercel uploads your files and publishes them in seconds. When the deploy finishes, you see a live URL.
Why Vercel Drop instead of the CLI?
Vercel Drop is the fastest path from local folder to live URL. It requires zero configuration and no terminal setup.
The goal of product engineering is to ship fast. You can always move to Git-based deploys later when you need continuous deployment.
Vercel says the project name is taken?
Project names must be unique across Vercel. Try adding your initials or a number, like timeblock-jd or timeblock-42. The URL will match whatever name you choose.
Help me troubleshoot my deploy
Verify the live deployment
A deploy is not done until you prove it works from a fresh browser with no local state.
- Copy the live URL that Vercel gave you after deployment.
- Open it in a new browser tab, an incognito window, or on your phone.
Before you test, what do you expect to happen when you add a task and drag it to a time slot on this fresh device? Will your tasks from earlier be there?
- Add a test task using the input field.
- Drag the task onto a time slot in the timeline.
- Refresh the page to confirm the task persists.
Your tasks from localhost will not be there. That is expected. localStorage is per-device, per-origin. Your live URL is a new origin, so it starts fresh. New tasks you add on this device persist here.
App not loading at the live URL?
Make sure you dragged the dist folder itself, not the entire timeblock project folder. Vercel needs the folder that contains index.html at its root.
If the page loads but looks broken, check that you ran npm run build successfully before dragging. The dist folder should contain index.html and an assets folder with .js and .css files.
Help me fix my Vercel deployment
Your time-blocking planner is live on the internet. You built a real product, observed its limitation, fixed it with the right feature, and shipped it to a URL you can open anywhere. That is the product engineering loop in action.
Secret mission
Add Focus Mode
Time-blocking tells you when to do things, but your screen still shows all 16 hours at once. Can you build a Focus Mode that shows only your current time block, hiding everything else to eliminate distraction?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally with a free Vercel Hobby deployment, so there are no ongoing costs.
Resources you used:
- Local timeblock project folder on your Desktop (source code, node_modules, dist).
- Vercel project named timeblock (live deployment on the free Hobby plan).
- Vercel account (free Hobby plan, linked to your GitHub account).
Keep everything running
No action needed. Choose this if you plan to keep using your time-blocking planner daily or want to continue building on it.
- Your Vercel deployment stays live at its URL for free on the Hobby plan.
- Your local timeblock folder stays on your Desktop for future edits.
- Your Vercel account remains active with no charges.
Pause - I'll come back to this later
Free up disk space while keeping your deployment live. You can always re-clone or recreate the local project later.
- Stop the dev server if it is still running by pressing Ctrl+C in the terminal.
- Delete the node_modules folder to reclaim disk space. You can reinstall later with npm install.
Remove node_modules on macOS/Linux by running:
cd ~/Desktop/timeblock
rm -rf node_modules
Remove node_modules on Windows by running:
cd ~\Desktop\timeblock
Remove-Item -Recurse -Force node_modules
- Your Vercel deployment stays live at no cost. When you return, run npm install inside the timeblock folder to restore packages.
Delete - I don't want to use this again
Remove all project resources from your machine and Vercel account. This cannot be undone.
- Stop the dev server if it is still running by pressing Ctrl+C in the terminal.
Delete the local project folder on macOS/Linux by running:
cd ~/Desktop
rm -rf timeblock
Delete the local project folder on Windows by running:
cd ~\Desktop
Remove-Item -Recurse -Force timeblock
- Delete the Vercel project:
- Open the Vercel dashboard at vercel.com and select your timeblock project.
- Click Settings in the sidebar.
- Scroll to the bottom of the General page and click Delete in the Delete Project section.
- Type the project name and confirmation prompt, then click Continue to confirm.
What about my Vercel account?
Your Vercel Hobby account is free and incurs no charges. You can leave it active for future projects.
If you want to delete it entirely, go to your profile icon in the Vercel dashboard, select Account Settings, scroll to the Delete Account section, and click Delete Personal Account. This is permanent and removes all projects and deployments.
Nice Work!
Nice Work!
You just built a personal time-blocking planner from scratch and shipped it to a live URL. This is a real productivity tool you can open on any device and use every single day.
Here's what you learned:
- Built a time-blocking planner with drag-and-drop scheduling, deployed live on Vercel at a shareable URL you can open on your phone.
- Experienced the product engineering loop: shipped a naive task list, observed it created zero urgency, then built the time-blocking feature that actually fights procrastination.
- Used Vite for instant local development feedback and localStorage for zero-cost data persistence that survives page refreshes.
- Secret Mission: Implemented a Focus Mode inspired by Akiflow that hides everything except your current time block, eliminating distraction.
Ready to quiz yourself?