Skip to main content

πŸ•οΈ 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).

LocalAdventures layout at a glance Three panels: a profile form on the left, a scrollable list of draggable activity cards in the middle, and an itinerary drop zone with a Canvas map on the right. LocalAdventures Profile Form Save Suggested Activities Eagle Peak Trail Β· drag me β†’ Blue Lake Kayaking Β· drag me β†’ Mountain Cycling Β· drag me β†’ My Plan Canvas route map drop activities here
Figure 1 β€” The finished app: a validated form feeds preferences, draggable cards feed the plan, and the plan is drawn live on a Canvas. Every box maps to one HTML5 API you learned this module.

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:

PΓ³lya's four-step problem-solving cycle Four connected stages: Understand, Plan, Execute, and Review, with Review feeding back into Understand. 1. Understand requirements 2. Plan architecture 3. Execute build & test 4. Review evaluate & extend what you learn feeds the next round
Figure 2 β€” PΓ³lya's loop. It isn't a straight line: reviewing your first version teaches you what to understand better next time.

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:

RequirementAPI / feature it maps to
Collect user preferences with validationHTML5 form inputs + Constraint Validation
Suggest activities relative to the userGeolocation API + distance math
Let the user assemble a plan by handDrag & Drop API
Visualize the plan as a routeCanvas 2D context
Remember everything between visitsWeb Storage (localStorage)
Work on phones and desktopsCSS 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:

sequenceDiagram participant U as User participant App participant Store as localStorage participant Geo as Geolocation U->>App: Open app App->>Store: Load saved profile Store-->>App: Preferences (or none) U->>App: Submit / confirm preferences App->>Geo: Request position Geo-->>App: Coordinates (or denial) App->>App: Filter & sort activities App-->>U: Show draggable suggestions U->>App: Drag activities into plan App->>App: Redraw Canvas route U->>App: Save plan App->>Store: Persist itinerary

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.

flowchart TD App[App<br/>orchestrator] --> Store[StorageManager<br/>localStorage wrapper] App --> Profile[UserProfile<br/>preferences & saved plans] App --> Form[FormHandler<br/>validation & submit] App --> Loc[LocationService<br/>Geolocation + distance] App --> Finder[ActivityFinder<br/>filter & render cards] App --> DnD[DragDropManager<br/>drag & drop wiring] App --> Viz[Visualizer<br/>Canvas map] Profile --> Store Finder --> Loc

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.

flowchart LR M1[M1 Shell + Storage] --> M2[M2 Location + Activities] M2 --> M3[M3 Drag & Drop] M3 --> M4[M4 Canvas Map] M4 --> M5[M5 Polish + Fallbacks]

πŸ“– 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
  • ☐ StorageManager with save/load/remove and try/catch
  • ☐ Form uses required and validates via checkValidity()
  • ☐ 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 draggable and carry their id in dataTransfer
  • ☐ Drop zone calls preventDefault() on dragover
  • ☐ 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.

DimensionJust workingWhat 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)

graph TD A[LocalAdventures v1] --> B[SVG activity icons<br/>overlaid on the map] A --> C[Weather per activity<br/>via a public forecast API] A --> D[Reorder the plan<br/>by dragging items] A --> E[Progressive Web App<br/>installable + offline]

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

πŸš€ 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.