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
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,
},
};
}
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 }));
}
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;
}
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;
}
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" }
]
}
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.
- 📦 Repo: https://github.com/sen-ltd/shopping-list
- 🌐 Live: https://sen.ltd/portfolio/shopping-list/
- 🏢 Company: https://sen.ltd/

Top comments (0)