ποΈ Weekend Project: HTML Forms & HTML5 APIs
Time to put the whole module to work. Over one focused weekend you'll build LocalAdventures β a small but genuinely useful app that collects preferences through an HTML5 form, finds nearby activities with the Geolocation API, lets you drag them into a plan, draws that plan on a Canvas, and remembers everything with localStorage. You'll do it the way real teams do: with a plan, milestones, and a definition of "done."
π― Learning Objectives
By the end of this project, you will be able to:
- Combine HTML5 forms,
localStorage, Geolocation, Drag & Drop, and Canvas into one coherent application - Apply Polya's four-step method (understand β plan β execute β review) to a real build
- Decompose a feature-rich app into small, testable milestones you can ship one at a time
- Structure browser JavaScript into focused modules with clear responsibilities
- Judge your own work against a concrete "what good looks like" rubric
Estimated Time: 4β8 hours (a weekend) β’ Difficulty: Intermediate
Hands-on: Build LocalAdventures milestone by milestone, checking each one off before moving on.
In This Lesson
What You're Building
Individual APIs are easy to demo in isolation. The real skill β the one this project trains β is gluing several of them together into something a person would actually use. Meet LocalAdventures, an app that helps someone discover, plan, and visualize outdoor activities near them.
A user can:
- Enter their name, interests, difficulty, and travel distance in a validated HTML5 form
- Share their position through the Geolocation API to see what's nearby
- Drag & drop suggested activities into a personal itinerary
- See that itinerary drawn as a route on a Canvas map
- Have every preference and saved plan persist across sessions via localStorage
π Why this project matters
It is entirely client-side β no server, no accounts, no build tools. That makes it the perfect capstone for a forms & interactive-HTML module: every line runs in the browser you already have, yet the finished app touches five different platform APIs and a real architectural decision (how to split the code).
A Method to Build By
A project this size fails if you open your editor and start typing. Instead we'll borrow a framework from mathematician George PΓ³lya, whose 1945 book How to Solve It distilled problem-solving into four steps. They map perfectly onto software:
We'll walk the four steps in order, and the rest of this lesson is organized around them. Naming the step you're in is a surprisingly powerful habit β it stops you from writing code before you know what "done" means.
Step 1 β Understand the Problem
Before a line of code, pin down what success requires. For LocalAdventures the core requirements are:
| Requirement | API / feature it maps to |
|---|---|
| Collect user preferences with validation | HTML5 form inputs + Constraint Validation |
| Suggest activities relative to the user | Geolocation API + distance math |
| Let the user assemble a plan by hand | Drag & Drop API |
| Visualize the plan as a route | Canvas 2D context |
| Remember everything between visits | Web Storage (localStorage) |
| Work on phones and desktops | CSS Grid + responsive layout |
Equally important are the constraints β knowing them now prevents rework later:
- Client-side only. No backend, so activity data is a local array and all persistence is
localStorage. - Permissions are not guaranteed. Geolocation can be denied; the app must still work without it.
- Modern browsers. Chrome, Firefox, Safari, Edge β but feature-detect rather than assume.
- Graceful degradation. If one API is missing, the rest of the app keeps functioning.
β οΈ Geolocation needs a secure context
Browsers only expose navigator.geolocation over HTTPS or on localhost. Opening the file directly with a file:// URL will silently disable location. Serve the folder with a tiny dev server (e.g. python3 -m http.server) while you build.
Finally, sketch the happy path so you know how the pieces talk to each other before you design them:
Step 2 β Plan & Milestones
With the problem understood, decide how the code is organized and in what order you'll build it. Both decisions keep a weekend build from turning into spaghetti.
Architecture: one module per responsibility
Rather than one giant script, split behavior into focused objects, each owning one concern. This is the single most valuable habit the project teaches.
Each box becomes an object in your script. The rule of thumb: if you can't describe a module's job in one sentence, it's doing too much.
Milestones: five shippable slices
Break the build into phases where each phase leaves the app runnable. You should be able to open the page and see progress after every one.
π The data model, decided up front
Two shapes drive everything. Agreeing on them now means the modules can be built independently.
// A saved user profile
const profile = {
name: 'Jane',
preferences: {
activityTypes: ['hiking', 'kayaking'],
difficultyLevel: 'intermediate', // beginner | intermediate | advanced
maxDistance: 15, // miles
useLocation: true,
},
savedItineraries: [], // array of plans
};
// One activity in the catalog
const activity = {
id: 'act-001',
name: 'Eagle Peak Trail',
type: 'hiking',
location: { latitude: 37.7749, longitude: -122.4194 },
difficulty: 'intermediate',
duration: 2.5, // hours
rating: 4.5,
};
Step 3 β Build It, Milestone by Milestone
Now execute the plan. Below is the key code for each milestone in modern JavaScript. Type it, run it, confirm the milestone works, then move on. (Full HTML scaffolding and CSS are yours to write β that's part of the exercise.)
Milestone 1 β Shell + storage
Start with a thin, safe wrapper around localStorage. Every read and write goes through it, so error handling lives in exactly one place.
const StorageManager = {
save(key, data) {
try {
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch (err) {
console.error('Save failed:', err); // e.g. quota exceeded / private mode
return false;
}
},
load(key) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch (err) {
console.error('Load failed:', err);
return null;
}
},
remove(key) {
localStorage.removeItem(key);
},
};
The profile module builds on it. Note the use of selectedOptions and modern array methods to read the form:
const UserProfile = {
data: {
name: '',
preferences: { activityTypes: [], difficultyLevel: 'beginner', maxDistance: 15, useLocation: true },
savedItineraries: [],
},
load() {
const saved = StorageManager.load('userProfile');
if (saved) this.data = saved;
return Boolean(saved);
},
save() {
return StorageManager.save('userProfile', this.data);
},
readFromForm(form) {
const fd = new FormData(form);
this.data.name = fd.get('userName')?.trim() ?? '';
this.data.preferences.activityTypes =
[...form.elements.activityTypes.selectedOptions].map((o) => o.value);
this.data.preferences.difficultyLevel = fd.get('difficultyLevel');
this.data.preferences.maxDistance = Number(fd.get('maxDistance'));
this.data.preferences.useLocation = form.elements.useLocation.checked;
},
};
Wire the form with the browser's built-in Constraint Validation API instead of hand-rolled checks:
const FormHandler = {
init() {
const form = document.getElementById('profile-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!form.checkValidity()) {
form.reportValidity(); // browser shows the native messages
return;
}
UserProfile.readFromForm(form);
UserProfile.save();
App.refreshSuggestions();
});
},
};
β Milestone 1 done whenβ¦
You can fill the form, reload the page, and see your values still there. Persistence working end-to-end is the whole point of this slice.
Milestone 2 β Location + activities
Wrap Geolocation's callback API in a promise so the rest of your code can await it. This is a pattern you'll reuse constantly.
const LocationService = {
position: null,
getPosition() {
return new Promise((resolve, reject) => {
if (!('geolocation' in navigator)) {
reject(new Error('Geolocation not supported'));
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
this.position = { latitude: pos.coords.latitude, longitude: pos.coords.longitude };
resolve(this.position);
},
(err) => reject(err),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
);
});
},
// Haversine great-circle distance, in miles
distanceTo(lat, lon) {
if (!this.position) return null;
const toRad = (d) => (d * Math.PI) / 180;
const R = 3958.8;
const dLat = toRad(lat - this.position.latitude);
const dLon = toRad(lon - this.position.longitude);
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(this.position.latitude)) * Math.cos(toRad(lat)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
},
};
Filtering is just array methods over your catalog. Keep the logic declarative:
const ActivityFinder = {
catalog: [/* the activity objects from the data model */],
suggestFor(prefs) {
const allowed = { beginner: ['beginner'], intermediate: ['beginner', 'intermediate'],
advanced: ['beginner', 'intermediate', 'advanced'] };
return this.catalog
.filter((a) => prefs.activityTypes.length === 0 || prefs.activityTypes.includes(a.type))
.filter((a) => allowed[prefs.difficultyLevel].includes(a.difficulty))
.map((a) => ({ ...a, distance: LocationService.distanceTo(a.location.latitude, a.location.longitude) }))
.filter((a) => a.distance == null || a.distance <= prefs.maxDistance)
.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
},
};
β Milestone 2 done whenβ¦
Granting location shows a sorted, distance-labelled list; denying it still shows a sensible list filtered by interest. Both paths must work.
Milestone 3 β Drag & drop
Make cards draggable, then teach the drop zone to accept them. The data being dragged is just the activity's id, passed through dataTransfer.
// On each rendered card:
card.draggable = true;
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', activity.id);
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
const DragDropManager = {
init() {
const zone = document.getElementById('itinerary-drop');
zone.addEventListener('dragover', (e) => {
e.preventDefault(); // REQUIRED β without this, drop never fires
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
if (zone.querySelector(`[data-id="${id}"]`)) return; // no duplicates
const activity = ActivityFinder.catalog.find((a) => a.id === id);
if (activity) {
zone.appendChild(App.buildPlanItem(activity));
App.redrawMap();
}
});
},
};
β οΈ The #1 drag-and-drop bug
Forgetting e.preventDefault() in the dragover handler. By default elements reject drops, so without it your drop event never fires β and it looks like nothing is wired up. Add it first.
Milestone 4 β Canvas map
Draw the plan as connected points. Even without a real basemap, a normalized layout of the itinerary reads clearly.
const Visualizer = {
init() {
this.canvas = document.getElementById('activity-map');
this.ctx = this.canvas.getContext('2d');
this.drawEmpty();
},
drawEmpty() {
const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#94a3b8';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Add activities to see them on the map', canvas.width / 2, canvas.height / 2);
},
draw(activities) {
const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (activities.length === 0) return this.drawEmpty();
// Normalize lat/lon into the canvas box
const lats = activities.map((a) => a.location.latitude);
const lons = activities.map((a) => a.location.longitude);
const [minLat, maxLat] = [Math.min(...lats), Math.max(...lats)];
const [minLon, maxLon] = [Math.min(...lons), Math.max(...lons)];
const pad = 40;
const toXY = ({ latitude, longitude }) => ({
x: pad + ((longitude - minLon) / ((maxLon - minLon) || 1)) * (canvas.width - 2 * pad),
y: pad + (1 - (latitude - minLat) / ((maxLat - minLat) || 1)) * (canvas.height - 2 * pad),
});
const points = activities.map((a) => toXY(a.location));
// Route line
ctx.beginPath();
points.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2;
ctx.stroke();
// Numbered stops
points.forEach((p, i) => {
ctx.beginPath();
ctx.arc(p.x, p.y, 12, 0, Math.PI * 2);
ctx.fillStyle = '#6366f1';
ctx.fill();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), p.x, p.y);
});
},
};
β Milestone 4 done whenβ¦
Dropping and removing activities redraws the numbered route immediately, and an empty plan shows the placeholder rather than a blank box.
Milestone 5 β Polish & fallbacks
Turn a demo into something trustworthy. Feature-detect before you use an API, and give the user real feedback instead of an alert().
function checkSupport() {
const missing = [];
if (!window.localStorage) missing.push('Saving preferences');
if (!('geolocation' in navigator)) missing.push('Nearby suggestions');
if (!document.createElement('canvas').getContext) missing.push('The map');
if (!('draggable' in document.createElement('div'))) missing.push('Drag & drop planning');
return missing; // show a friendly banner if non-empty
}
function toast(message, type = 'info') {
const el = document.getElementById('toast');
el.textContent = message;
el.className = `toast ${type} visible`;
setTimeout(() => el.classList.remove('visible'), 3000);
}
Now use async/await for the location flow so success and failure read top-to-bottom:
App.refreshSuggestions = async function () {
const status = document.getElementById('location-status');
if (UserProfile.data.preferences.useLocation) {
status.textContent = 'Finding your locationβ¦';
try {
await LocationService.getPosition();
status.textContent = 'Activities near you';
} catch {
status.textContent = 'Location unavailable β showing by interest';
UserProfile.data.preferences.useLocation = false;
}
} else {
status.textContent = 'Activities by interest';
}
const list = ActivityFinder.suggestFor(UserProfile.data.preferences);
App.renderCards(list);
};
Your Build Checklist
Work top to bottom. Don't start a milestone until the one above it runs. Tick each box (mentally or in a notebook) so you always know exactly where you are.
Milestone 1 β Shell + storage
- β HTML page with header, profile form, suggestions panel, and plan panel
- β
StorageManagerwithsave/load/removeand try/catch - β Form uses
requiredand validates viacheckValidity() - β Preferences survive a page reload
Milestone 2 β Location + activities
- β Geolocation wrapped in a promise, served over
localhost/HTTPS - β Haversine distance computed for each activity
- β Suggestions filter by type, difficulty, and distance
- β Denying location still yields a useful list
Milestone 3 β Drag & drop
- β Cards are
draggableand carry their id indataTransfer - β Drop zone calls
preventDefault()ondragover - β Dropping adds an item; duplicates are rejected; items are removable
Milestone 4 β Canvas map
- β Empty state placeholder renders
- β Plan draws as a numbered route that updates on add/remove
Milestone 5 β Polish & fallbacks
- β Feature detection with a friendly banner for anything missing
- β Toast feedback replaces raw
alert()calls - β Layout collapses to one column on narrow screens
- β Saved plans appear in the profile and reload on return
What Good Looks Like
Anyone can make the features "sort of work." Here is the bar that separates a submission from a strong one β use it to grade yourself.
| Dimension | Just working | What good looks like |
|---|---|---|
| Structure | One long script, everything global | Focused modules, each with a one-sentence job; no module reaches into another's internals |
| Failure handling | Breaks if location is denied | Every external call (storage, geolocation) is guarded; the app degrades instead of crashing |
| Feedback | alert() everywhere |
Inline validation, loading states, and unobtrusive toasts |
| Persistence | Loses state on reload | Preferences and saved plans reload exactly as left |
| Responsiveness | Desktop only | Single-column, touch-usable layout on phones |
| Accessibility | Mouse-only, unlabeled | Labelled form controls, keyboard-reachable actions, sufficient contrast |
β The tell-tale sign of a good build
You can delete any one module's file (say, disable the Visualizer) and the rest of the app keeps running. Loose coupling like that is exactly what the module-per-responsibility plan buys you β and it's what reviewers look for.
Step 4 β Review & Extend
PΓ³lya's final step is where growth happens. Once your five milestones pass, evaluate honestly and pick one extension to push further.
Review questions
- Does each requirement from Step 1 have a working feature? Which is weakest?
- What happens on the unhappy paths β no location, empty plan, storage full?
- Could a new developer read one module and understand it without the others?
Extension ideas (pick one)
For example, fetching weather for each stop is a natural next step and reinforces Promise.all:
async function weatherForPlan(activities) {
const results = await Promise.all(
activities.map(async (a) => {
const { latitude, longitude } = a.location;
const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=temperature_2m_max&forecast_days=1`);
if (!res.ok) throw new Error(`Weather ${res.status}`);
const data = await res.json();
return { id: a.id, highC: data.daily.temperature_2m_max[0] };
}),
);
return results;
}
π‘ Ship, then iterate
Resist adding extensions before the five milestones pass. A complete, humble v1 beats a half-finished ambitious one every time β and the review step is exactly where the ambitious ideas earn their place.
Summary & Quiz
π Key Takeaways
- Integration is the real skill. Forms, storage, geolocation, drag & drop, and Canvas each shine on their own, but the value is wiring them together.
- PΓ³lya's four steps β understand, plan, execute, review β turn a daunting build into a sequence of decisions.
- Milestones keep the app runnable at every stage, so you always have something to test and show.
- Module-per-responsibility is what makes the code readable, testable, and loosely coupled.
- "What good looks like" is about failure handling, feedback, persistence, and accessibility β not just features that "sort of work."
π― Quick Quiz
Question 1: In the HTML5 Drag & Drop API, which handler must call e.preventDefault() for a drop to succeed?
Question 2: Why does the project wrap navigator.geolocation.getCurrentPosition in a Promise?
Question 3: What is the point of splitting the app into modules like StorageManager, LocationService, and Visualizer?
π Further Reading
- MDN β HTML Drag and Drop API
- MDN β Geolocation API
- MDN β Canvas API tutorial
- MDN β Web Storage API
- PΓ³lya β How to Solve It
π What's Next?
You've now built a full interactive app with raw HTML5 and vanilla JavaScript β and you've felt where styling by hand gets tedious. Next module we rewind to the beginning of CSS and trace how it evolved into the powerful layout system you'll lean on from here on.
π You shipped a real app!
Five APIs, one plan, one weekend. That's exactly how professional features get built.