DEV Community

SEN LLC
SEN LLC

Posted on

A Shopping List PWA That Learns Your Frequently-Bought Items

A Shopping List PWA That Learns Your Frequently-Bought Items

Every time you add an item, the app increments a frequency counter for that item name. The top 10 become "frequently bought" suggestions that appear as one-tap chips. Plus: categories, voice input via Web Speech API, multiple lists, and a PWA manifest so you can install it to your phone's home screen.

A shopping list is a simple app. What makes one genuinely useful is learning from your behavior: if you buy milk every week, the app should make adding "milk" a single tap. That's what the frequency-tracking system does.

🔗 Live demo: https://sen.ltd/portfolio/shopping-list/
📦 GitHub: https://github.com/sen-ltd/shopping-list

Screenshot

Features:

  • 9 categories (Produce, Dairy, Meat, Bakery, Pantry, Frozen, Beverage, Household, Other)
  • Multiple named lists (Weekly, Party, etc.)
  • Frequently-bought suggestions based on history
  • Voice input via Web Speech API
  • PWA manifest (installable)
  • localStorage persistence
  • Import / Export JSON
  • Japanese / English UI
  • Mobile-first responsive
  • Zero dependencies, 44 tests

Frequency tracking

Every addItem call increments the history counter:

export function addItem(state, listId, item) {
  const newItem = { ...item, id: generateId(), checked: false, addedAt: Date.now() };
  return {
    ...state,
    lists: state.lists.map(list =>
      list.id === listId ? { ...list, items: [...list.items, newItem] } : list
    ),
    history: {
      ...state.history,
      [item.name.toLowerCase()]: (state.history[item.name.toLowerCase()] || 0) + 1,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Normalizing to lowercase means "Milk" and "milk" count as the same item. Over time, the history map grows with one entry per distinct item and a count of how often it's been added.

Getting the top N

export function getFrequentItems(state, limit = 10) {
  return Object.entries(state.history)
    .sort((a, b) => b[1] - a[1])
    .slice(0, limit)
    .map(([name, count]) => ({ name, count }));
}
Enter fullscreen mode Exit fullscreen mode

Simple sort-and-slice. Renders as one-tap chips at the top of the UI: click a suggestion, it gets added to the current list with a default category and quantity.

Voice input with Web Speech API

function startVoiceInput(onResult) {
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  if (!SR) return false;
  const recog = new SR();
  recog.lang = getLang() === 'ja' ? 'ja-JP' : 'en-US';
  recog.continuous = false;
  recog.interimResults = false;
  recog.onresult = (e) => onResult(e.results[0][0].transcript);
  recog.start();
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The API is not supported in Firefox or some Safari versions, so the function returns false and the UI falls back to a text input. When it works, you hold the mic button and say "milk" — the transcript populates the name field.

Category grouping

The rendering groups items by category so you can shop section-by-section in the store:

export function groupByCategory(items) {
  const groups = {};
  for (const item of items) {
    const cat = item.category || 'other';
    if (!groups[cat]) groups[cat] = [];
    groups[cat].push(item);
  }
  return groups;
}
Enter fullscreen mode Exit fullscreen mode

Rendering iterates CATEGORIES in display order so the sections appear consistently even if you add items in a different order.

PWA manifest

{
  "name": "Shopping List",
  "short_name": "Shopping",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4caf50",
  "icons": [
    { "src": "assets/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml" },
    { "src": "assets/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Paired with an HTML <link rel="manifest" href="manifest.json">. Chrome on Android offers "Add to Home Screen" and the app launches in standalone mode without browser chrome. For a shopping list this is perfect — it feels native, syncs nothing, stores everything in localStorage.

Series

This is entry #64 in my 100+ public portfolio series.

Top comments (0)