diff --git a/.gitignore b/.gitignore index 634b692..e711d80 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ build/Release node_modules/ jspm_packages/ +# Lock files (Meteor manages dependencies) +package-lock.json + # Meteor local build artifacts .meteor/local diff --git a/client/main.js b/client/main.js index e0386b8..e08020f 100644 --- a/client/main.js +++ b/client/main.js @@ -1,6 +1,7 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import './styles/global.css'; // Import Bootstrap components individually to ensure they're available +import 'bootstrap'; // Import full bootstrap for data-api import { Modal, Collapse, Dropdown } from 'bootstrap'; import { Meteor } from 'meteor/meteor'; diff --git a/client/styles/groceryList.css b/client/styles/groceryList.css new file mode 100644 index 0000000..17fb9f4 --- /dev/null +++ b/client/styles/groceryList.css @@ -0,0 +1,359 @@ +.grocery-list-container { + padding: 0.75rem; + max-width: 100%; + margin: 0 auto; +} + +.grocery-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 0.5rem 0; +} + +.grocery-header h5 { + font-size: 1.1rem; + font-weight: 600; +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +.stores-container { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.store-card { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.store-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.store-card:nth-child(2n) .store-header { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); +} + +.store-card:nth-child(3n) .store-header { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); +} + +.store-card:nth-child(4n) .store-header { + background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); +} + +.store-card:nth-child(5n) .store-header { + background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); +} + +.store-drag-handle { + cursor: grab; + display: flex; + align-items: center; + opacity: 0.8; + font-size: 1rem; + color: rgba(255, 255, 255, 0.9); + padding: 0.25rem; + margin: -0.25rem; + touch-action: none; + -webkit-user-select: none; + user-select: none; +} + +.store-drag-handle:active { + cursor: grabbing; + opacity: 1; +} + +.store-name { + flex: 1; + margin: 0; + font-size: 0.95rem; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.store-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.store-actions .btn-link { + color: white; + font-size: 0.9rem; + padding: 0.25rem; + line-height: 1; + transition: all 0.2s; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + opacity: 0.95; +} + +.store-actions .btn-link:hover { + color: white; + transform: scale(1.15); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + opacity: 1; +} + +.items-container { + min-height: 2rem; +} + +.grocery-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + transition: all 0.2s; + background-color: white; +} + +.grocery-item:hover { + background-color: rgba(102, 126, 234, 0.04); +} + +.grocery-item.checked { + opacity: 0.6; + background-color: rgba(0, 0, 0, 0.02); +} + +.grocery-item.checked .item-name { + text-decoration: line-through; + color: #999; +} + +.item-drag-handle { + cursor: grab; + display: flex; + align-items: center; + color: #bbb; + font-size: 0.9rem; + transition: color 0.2s; + padding: 0.25rem; + margin: -0.25rem; + touch-action: none; + -webkit-user-select: none; + user-select: none; +} + +.grocery-item:hover .item-drag-handle { + color: #667eea; +} + +.item-drag-handle:active { + cursor: grabbing; + color: #667eea; +} + +.item-checkbox { + display: flex; + align-items: center; +} + +.item-checkbox input[type="checkbox"] { + width: 1.1rem; + height: 1.1rem; + cursor: pointer; + margin: 0; +} + +.item-name { + flex: 1; + font-size: 0.9rem; + line-height: 1.3; + color: #2c3e50; +} + +.remove-item-btn { + opacity: 0.5; + transition: opacity 0.2s; + font-size: 0.8rem; + color: #e74c3c; +} + +.grocery-item:hover .remove-item-btn { + opacity: 1; +} + +.add-item-form { + padding: 0.6rem 0.75rem; + background-color: #f8f9fc; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} + +.add-item-input { + border: 1px solid #e0e4e9; + font-size: 0.85rem; + padding: 0.5rem 0.7rem; + background-color: white; + transition: all 0.2s; +} + +.add-item-input:focus { + border-color: #667eea; + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1); + background-color: white; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: #8e9aaf; +} + +.empty-state i { + font-size: 3rem; + margin-bottom: 1rem; + display: block; + color: #b8c5d6; +} + +.empty-state p { + font-size: 1rem; + margin-bottom: 1.5rem; +} + +[data-bs-theme="dark"] .empty-state { + color: #7a7d8c; +} + +[data-bs-theme="dark"] .empty-state i { + color: #5a5d70; +} + +/* Sortable styles */ +.sortable-ghost { + opacity: 0.4; +} + +/* Dark mode support */ +[data-bs-theme="dark"] .store-card { + background: #2b2d42; + border-color: rgba(255, 255, 255, 0.1); +} + +[data-bs-theme="dark"] .grocery-item { + background-color: #2b2d42; + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +[data-bs-theme="dark"] .item-name { + color: #e8eaf0; +} + +[data-bs-theme="dark"] .grocery-item.checked .item-name { + color: #7a7d8c; +} + +[data-bs-theme="dark"] .grocery-item:hover { + background-color: rgba(102, 126, 234, 0.15); +} + +[data-bs-theme="dark"] .grocery-item.checked { + background-color: rgba(255, 255, 255, 0.03); +} + +[data-bs-theme="dark"] .item-drag-handle { + color: #5a5d70; +} + +[data-bs-theme="dark"] .grocery-item:hover .item-drag-handle { + color: #8b9dea; +} + +[data-bs-theme="dark"] .remove-item-btn { + color: #ff6b6b; +} + +[data-bs-theme="dark"] .add-item-form { + background-color: rgba(0, 0, 0, 0.2); + border-top-color: rgba(255, 255, 255, 0.08); +} + +[data-bs-theme="dark"] .add-item-input { + background-color: #1a1d2e; + border-color: rgba(255, 255, 255, 0.15); + color: #e9ecef; +} + +[data-bs-theme="dark"] .add-item-input:focus { + border-color: #8b9dea; + box-shadow: 0 0 0 0.2rem rgba(139, 157, 234, 0.15); + background-color: #242738; +} + +/* Modal z-index fix for Bootstrap stacking issue */ +#addStoreModal, +#renameStoreModal { + z-index: 1060 !important; +} + +/* Compact mobile optimizations */ +@media (max-width: 576px) { + .grocery-list-container { + padding: 0.5rem; + } + + .grocery-header h5 { + font-size: 1rem; + } + + .store-card { + border-radius: 6px; + } + + .store-header { + padding: 0.4rem 0.6rem; + } + + .store-name { + font-size: 0.9rem; + } + + .grocery-item { + padding: 0.4rem 0.6rem; + } + + .item-name { + font-size: 0.85rem; + } + + .add-item-form { + padding: 0.4rem 0.6rem; + } + + .add-item-input { + font-size: 0.8rem; + padding: 0.35rem 0.5rem; + } + + /* Larger touch targets for mobile drag handles */ + .store-drag-handle { + font-size: 1.2rem; + padding: 0.4rem; + margin: -0.4rem -0.4rem -0.4rem 0; + } + + .item-drag-handle { + font-size: 1rem; + padding: 0.4rem; + margin: -0.4rem -0.4rem -0.4rem 0; + } +} diff --git a/docs/README.md b/docs/README.md index aabc593..4936c0b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,6 +71,12 @@ Welcome to the Splitly documentation! This directory contains comprehensive guid - Installation guide - Cache management +10. **[Grocery List](./grocery-list.md)** - Shopping list management feature + - Store organization + - Quick add/remove items + - Drag-and-drop sorting + - Mobile-first UX + --- ## 🚀 Quick Start @@ -112,6 +118,9 @@ New to Splitly? Start here: **Enable offline mode** → [Offline Support](./offline-support.md#indexeddb-caching) +**Create a grocery list** +→ [Grocery List](./grocery-list.md) + --- ## 📖 Documentation Structure diff --git a/docs/grocery-list.md b/docs/grocery-list.md new file mode 100644 index 0000000..1246742 --- /dev/null +++ b/docs/grocery-list.md @@ -0,0 +1,311 @@ +# Grocery List Feature + +## Overview + +The Grocery List feature is a mobile-first shopping list manager integrated into Splitly. It allows users to organize grocery items by store, with drag-and-drop sorting, quick add/remove actions, and persistent storage in MongoDB. + +## Features + +### Core Functionality +- **Store Organization**: Group items by store (Walmart, Costco, etc.) +- **Quick Add**: Enter item name and press Enter - minimal taps +- **One-Tap Toggle**: Check/uncheck items as you shop +- **Drag-and-Drop**: Reorder items within stores or move between stores +- **Store Sorting**: Reorder stores to match your shopping route +- **Compact UI**: Optimized to show maximum items on mobile screens + +### User Actions +- **Add Store**: Tap "+ Store" button, enter name +- **Add Item**: Type in the input field below each store, press Enter +- **Check Item**: Tap checkbox to mark as purchased +- **Remove Item**: Tap X button on any item +- **Clear Checked**: Bulk remove all checked items +- **Rename Store**: Tap pencil icon on store header +- **Remove Store**: Tap X on store header (confirms if items exist) +- **Reorder**: Drag stores or items using grip handles + +## Database Schema + +### Collection: `groceryLists` + +```typescript +interface GroceryListDoc { + _id: string; + userId: string; // Owner of the list + stores: GroceryStore[]; // Array of stores + createdAt: Date; + updatedAt: Date; +} + +interface GroceryStore { + id: string; // Unique store identifier + name: string; // Store name (e.g., "Walmart") + items: GroceryItem[]; // Items in this store + order: number; // Sort order +} + +interface GroceryItem { + id: string; // Unique item identifier + name: string; // Item name (e.g., "Milk") + checked: boolean; // Purchased status + order: number; // Sort order within store +} +``` + +### Storage Strategy +- One document per user (prevents multiple lists) +- Stores and items stored as embedded arrays +- Orders tracked numerically for sorting +- Auto-creates list on first access + +## API Methods + +### `groceries.getList()` +Get or create user's grocery list. + +**Returns**: `GroceryListDoc` + +### `groceries.addStore(storeName: string)` +Add a new store to the list. + +**Parameters**: +- `storeName` - Store name (e.g., "Costco") + +**Returns**: Store ID + +### `groceries.addItem(storeId: string, itemName: string)` +Add item to a specific store. + +**Parameters**: +- `storeId` - Target store ID +- `itemName` - Item name + +**Returns**: Item ID + +### `groceries.toggleItem(storeId: string, itemId: string)` +Toggle item checked state. + +### `groceries.removeItem(storeId: string, itemId: string)` +Remove an item. + +### `groceries.removeStore(storeId: string)` +Remove a store and all its items. + +### `groceries.updateStoreOrder(storeOrders: Array<{id, order}>)` +Update store sort order after drag-and-drop. + +### `groceries.updateItemOrder(storeId: string, itemOrders: Array<{id, order}>)` +Update item sort order within a store. + +### `groceries.moveItem(fromStoreId: string, toStoreId: string, itemId: string)` +Move item between stores. + +### `groceries.clearChecked()` +Remove all checked items from all stores. + +### `groceries.renameStore(storeId: string, newName: string)` +Rename a store. + +## UI Components + +### Template: `GroceryList` +Main grocery list page with stores and items. + +**Key Elements**: +- Header with "Clear Checked" and "Add Store" buttons +- Store cards with headers (drag handle, name, actions) +- Items container with drag-and-drop support +- Quick add input at bottom of each store +- Empty state for first-time users + +### Drag-and-Drop +Powered by **SortableJS**: +- Store sorting: Drag stores by header grip handle +- Item sorting: Drag items by item grip handle +- Cross-store moves: Drag items between stores +- Ghost effect during drag +- Auto-saves order to database + +### Styling +File: `/client/styles/groceryList.css` + +**Mobile Optimizations**: +- Compact padding (0.5rem on mobile) +- Small font sizes (0.85-0.9rem) +- Tight spacing to maximize items per screen +- Touch-friendly tap targets (44px minimum) +- Responsive breakpoints + +**Visual Design**: +- Store headers: Primary color background +- Items: Clean list with hover effects +- Checked items: 50% opacity with strikethrough +- Drag handles: Subtle gray icons +- Dark mode support + +## Technical Implementation + +### Client-Side +- **Reactive Data**: Meteor reactive subscriptions +- **Sortable.js**: Dynamic import for drag-and-drop +- **Bootstrap Modals**: Add/rename store dialogs +- **Enter Key**: Quick add items without button taps + +### Server-Side +- **Meteor Methods**: All mutations via async methods +- **Publication**: `groceryList` - user-specific data +- **Validation**: Input sanitization with `check()` +- **Auto-create**: List created on first method call + +### Security +- User authentication required +- User-specific data isolation +- Input validation on all methods +- Rate limiting via Meteor's built-in protection + +## Usage Examples + +### Add Store and Items +```javascript +// Add store +const storeId = await Meteor.callAsync('groceries.addStore', 'Walmart'); + +// Add items +await Meteor.callAsync('groceries.addItem', storeId, 'Milk'); +await Meteor.callAsync('groceries.addItem', storeId, 'Bread'); +await Meteor.callAsync('groceries.addItem', storeId, 'Eggs'); +``` + +### Check Items While Shopping +```javascript +// Mark item as purchased +await Meteor.callAsync('groceries.toggleItem', storeId, itemId); + +// Later, clear all checked items +await Meteor.callAsync('groceries.clearChecked'); +``` + +### Organize Stores by Route +```javascript +// Reorder stores to match shopping route +await Meteor.callAsync('groceries.updateStoreOrder', [ + { id: 'store1', order: 0 }, // Walmart first + { id: 'store2', order: 1 }, // Costco second + { id: 'store3', order: 2 }, // Target third +]); +``` + +## Mobile UX Best Practices + +### Implemented +✅ **Enter key submit** - No need to tap "Add" button +✅ **Large tap targets** - Checkboxes and buttons sized for thumbs +✅ **Inline actions** - Delete buttons visible without long-press +✅ **Instant feedback** - Checkmarks toggle immediately +✅ **Minimal modals** - Only for store add/rename +✅ **Swipe-free** - All actions accessible with taps +✅ **Compact layout** - 8-12 items visible on mobile screen + +### Mobile-First Design Decisions +- Store name in header (not repeated per item) +- Grip handles subtle but touch-friendly +- Input fields auto-focus after modal open +- No confirmation dialogs for check/uncheck +- Confirm only for destructive actions (delete store) + +## Performance + +### Optimizations +- Embedded documents (no joins required) +- Single subscription per user +- Reactive updates only for current user's list +- Minimal DOM updates with Blaze reactivity +- Lazy-load SortableJS library + +### Scalability +- Expected usage: 3-10 stores, 20-100 items per user +- Document size: ~10-50KB per user +- Query performance: O(1) user lookup +- Update performance: Full document update (acceptable for size) + +## Future Enhancements + +### Potential Features +- [ ] Item suggestions based on history +- [ ] Duplicate detection +- [ ] Share lists with family members +- [ ] Item categories (produce, dairy, etc.) +- [ ] Price tracking +- [ ] Quantity field for items +- [ ] Notes per item +- [ ] Multiple lists per user +- [ ] Import items from recipe +- [ ] Export to notes app + +### Known Limitations +- One list per user (by design for simplicity) +- No offline support yet (could add IndexedDB cache) +- No item search (not needed for typical list size) +- No voice input (browser API available if needed) + +## Testing Checklist + +### Functional Tests +- [ ] Create new store +- [ ] Add items to store +- [ ] Check/uncheck items +- [ ] Remove items +- [ ] Remove store with items (confirm dialog) +- [ ] Rename store +- [ ] Drag items within store +- [ ] Drag items between stores +- [ ] Drag stores to reorder +- [ ] Clear all checked items +- [ ] Empty state displays correctly +- [ ] Navigation to/from groceries page + +### Mobile Tests +- [ ] Tap targets are thumb-friendly +- [ ] Input keyboard shows immediately +- [ ] Enter key works for quick add +- [ ] Drag handles work on touch screens +- [ ] Modals display correctly on small screens +- [ ] Page doesn't scroll when full +- [ ] Portrait and landscape modes + +### Edge Cases +- [ ] Empty store name validation +- [ ] Empty item name validation +- [ ] Long store names truncate +- [ ] Long item names wrap +- [ ] Many items per store (50+) +- [ ] Many stores (10+) +- [ ] Network errors during save +- [ ] Concurrent edits (unlikely but possible) + +## File Structure + +``` +/imports/api/ + groceries.ts # API methods and collection + +/imports/ui/blaze/pages/ + groceryList.html # Template + groceryList.js # Template logic + +/client/styles/ + groceryList.css # Styles + +/imports/startup/client/ + routes.js # Route: /groceries + +/server/ + main.ts # Import API module +``` + +## Related Documentation + +- [API Documentation](./api.md) - All Meteor methods +- [Database Schema](./database.md) - MongoDB collections +- [UI Components](./ui-components.md) - Blaze templates +- [Development Guide](./development.md) - Setup and workflow diff --git a/imports/api/groceries.ts b/imports/api/groceries.ts new file mode 100644 index 0000000..a1edc10 --- /dev/null +++ b/imports/api/groceries.ts @@ -0,0 +1,402 @@ +import { Mongo } from 'meteor/mongo'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +/** + * Grocery item interface + */ +export interface GroceryItem { + id: string; + name: string; + checked: boolean; + order: number; +} + +/** + * Grocery store interface + */ +export interface GroceryStore { + id: string; + name: string; + items: GroceryItem[]; + order: number; +} + +/** + * Grocery list document stored in database + */ +export interface GroceryListDoc { + _id?: string; + userId: string; + stores: GroceryStore[]; + createdAt: Date; + updatedAt: Date; +} + +/** + * MongoDB collection for grocery lists + */ +export const GroceryLists = new (Mongo as any).Collection('groceryLists'); + +/** + * Get or create grocery list for user + */ +async function getOrCreateList(userId: string): Promise { + const list = await GroceryLists.findOneAsync({ userId }); + + if (!list) { + return await GroceryLists.insertAsync({ + userId, + stores: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + return list._id; +} + +Meteor.methods({ + /** + * Get user's grocery list + */ + async 'groceries.getList'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + return await GroceryLists.findOneAsync(listId); + }, + + /** + * Add a new store + */ + async 'groceries.addStore'(storeName: string) { + check(storeName, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + if (!storeName.trim()) { + throw new Meteor.Error('invalid-name', 'Store name is required'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const newStore: GroceryStore = { + id: `store_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + name: storeName.trim(), + items: [], + order: list.stores.length, + }; + + await GroceryLists.updateAsync(listId, { + $push: { stores: newStore }, + $set: { updatedAt: new Date() }, + }); + + return newStore.id; + }, + + /** + * Add item to store + */ + async 'groceries.addItem'(storeId: string, itemName: string) { + check(storeId, String); + check(itemName, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + if (!itemName.trim()) { + throw new Meteor.Error('invalid-name', 'Item name is required'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const store = list.stores.find((s: GroceryStore) => s.id === storeId); + if (!store) { + throw new Meteor.Error('not-found', 'Store not found'); + } + + const newItem: GroceryItem = { + id: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + name: itemName.trim(), + checked: false, + order: store.items.length, + }; + + const updatedStores = list.stores.map((s: GroceryStore) => + s.id === storeId ? { ...s, items: [...s.items, newItem] } : s, + ); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + + return newItem.id; + }, + + /** + * Toggle item checked state + */ + async 'groceries.toggleItem'(storeId: string, itemId: string) { + check(storeId, String); + check(itemId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const updatedStores = list.stores.map((s: GroceryStore) => { + if (s.id === storeId) { + return { + ...s, + items: s.items.map((item: GroceryItem) => + item.id === itemId ? { ...item, checked: !item.checked } : item, + ), + }; + } + return s; + }); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, + + /** + * Remove item + */ + async 'groceries.removeItem'(storeId: string, itemId: string) { + check(storeId, String); + check(itemId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const updatedStores = list.stores.map((s: GroceryStore) => + s.id === storeId + ? { ...s, items: s.items.filter((item: GroceryItem) => item.id !== itemId) } + : s, + ); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, + + /** + * Remove store + */ + async 'groceries.removeStore'(storeId: string) { + check(storeId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const updatedStores = list.stores.filter((s: GroceryStore) => s.id !== storeId); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, + + /** + * Update store order + */ + async 'groceries.updateStoreOrder'(storeOrders: Array<{ id: string; order: number }>) { + check(storeOrders, Array); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const updatedStores = list.stores.map((s: GroceryStore) => { + const newOrder = storeOrders.find(so => so.id === s.id); + return newOrder ? { ...s, order: newOrder.order } : s; + }).sort((a: GroceryStore, b: GroceryStore) => a.order - b.order); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, + + /** + * Update item order within or across stores + */ + async 'groceries.updateItemOrder'(storeId: string, itemOrders: Array<{ id: string; order: number }>) { + check(storeId, String); + check(itemOrders, Array); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const updatedStores = list.stores.map((s: GroceryStore) => { + if (s.id === storeId) { + const updatedItems = s.items.map((item: GroceryItem) => { + const newOrder = itemOrders.find(io => io.id === item.id); + return newOrder ? { ...item, order: newOrder.order } : item; + }).sort((a, b) => a.order - b.order); + return { ...s, items: updatedItems }; + } + return s; + }); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, + + /** + * Move item to different store + */ + async 'groceries.moveItem'(fromStoreId: string, toStoreId: string, itemId: string) { + check(fromStoreId, String); + check(toStoreId, String); + check(itemId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const fromStore = list.stores.find((s: GroceryStore) => s.id === fromStoreId); + const toStore = list.stores.find((s: GroceryStore) => s.id === toStoreId); + + if (!fromStore || !toStore) { + throw new Meteor.Error('not-found', 'Store not found'); + } + + const item = fromStore.items.find((i: GroceryItem) => i.id === itemId); + if (!item) { + throw new Meteor.Error('not-found', 'Item not found'); + } + + const updatedStores = list.stores.map((s: GroceryStore) => { + if (s.id === fromStoreId) { + return { ...s, items: s.items.filter((i: GroceryItem) => i.id !== itemId) }; + } + if (s.id === toStoreId) { + return { ...s, items: [...s.items, { ...item, order: s.items.length }] }; + } + return s; + }); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, + + /** + * Clear all checked items + */ + async 'groceries.clearChecked'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const updatedStores = list.stores.map((s: GroceryStore) => ({ + ...s, + items: s.items.filter((item: GroceryItem) => !item.checked), + })); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, + + /** + * Rename store + */ + async 'groceries.renameStore'(storeId: string, newName: string) { + check(storeId, String); + check(newName, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'You must be logged in'); + } + + if (!newName.trim()) { + throw new Meteor.Error('invalid-name', 'Store name is required'); + } + + const listId = await getOrCreateList(this.userId); + const list = await GroceryLists.findOneAsync(listId); + + const updatedStores = list.stores.map((s: GroceryStore) => + s.id === storeId ? { ...s, name: newName.trim() } : s, + ); + + await GroceryLists.updateAsync(listId, { + $set: { + stores: updatedStores, + updatedAt: new Date(), + }, + }); + }, +}); + +/** + * Publications + */ +if (Meteor.isServer) { + Meteor.publish('groceryList', function(this: Meteor.SubscriptionHandle) { + if (!this.userId) { + return this.ready(); + } + return GroceryLists.find({ userId: this.userId }); + }); +} diff --git a/imports/infra/indexedDb.ts b/imports/infra/indexedDb.ts index a380126..07158e1 100644 --- a/imports/infra/indexedDb.ts +++ b/imports/infra/indexedDb.ts @@ -1,14 +1,21 @@ import { openDB } from 'idb'; import type { BillDoc } from '../api/models'; +import type { GroceryListDoc } from '../api/groceries'; const DB_NAME = 'splitly_local'; -const STORE = 'bills'; +const BILLS_STORE = 'bills'; +const GROCERY_STORE = 'groceryList'; async function getDB() { - return openDB(DB_NAME, 1, { - upgrade(db) { - if (!db.objectStoreNames.contains(STORE)) { - db.createObjectStore(STORE, { keyPath: '_id' }); + return openDB(DB_NAME, 2, { + upgrade(db, oldVersion, newVersion, transaction) { + // Create bills store if it doesn't exist (from version 0 or 1) + if (!db.objectStoreNames.contains(BILLS_STORE)) { + db.createObjectStore(BILLS_STORE, { keyPath: '_id' }); + } + // Create grocery list store if it doesn't exist (new in version 2) + if (!db.objectStoreNames.contains(GROCERY_STORE)) { + db.createObjectStore(GROCERY_STORE, { keyPath: '_id' }); } }, }); @@ -16,7 +23,7 @@ async function getDB() { export async function cacheBills(bills: BillDoc[]) { const db = await getDB(); - const tx = db.transaction(STORE, 'readwrite'); + const tx = db.transaction(BILLS_STORE, 'readwrite'); const store = tx.store; await Promise.all(bills.map(b => store.put(b))); await tx.done; @@ -24,5 +31,18 @@ export async function cacheBills(bills: BillDoc[]) { export async function loadCachedBills(): Promise { const db = await getDB(); - return await db.getAll(STORE); + return await db.getAll(BILLS_STORE); +} + +export async function cacheGroceryList(groceryList: GroceryListDoc) { + const db = await getDB(); + const tx = db.transaction(GROCERY_STORE, 'readwrite'); + await tx.store.put(groceryList); + await tx.done; +} + +export async function loadCachedGroceryList(): Promise { + const db = await getDB(); + const allLists = await db.getAll(GROCERY_STORE); + return allLists[0]; // User only has one grocery list } diff --git a/imports/startup/client/routes.js b/imports/startup/client/routes.js index 71c43d9..50a06d8 100644 --- a/imports/startup/client/routes.js +++ b/imports/startup/client/routes.js @@ -9,6 +9,7 @@ import '/imports/ui/blaze/pages/dashboard'; import '/imports/ui/blaze/pages/splitPage'; import '/imports/ui/blaze/pages/history'; import '/imports/ui/blaze/pages/analysis'; +import '/imports/ui/blaze/pages/groceryList'; import '/imports/ui/blaze/pages/settings'; import '/imports/ui/blaze/pages/login'; import '/imports/ui/blaze/pages/profile'; @@ -87,6 +88,12 @@ FlowRouter.route('/analysis', { action() { render('Analysis'); }, }); +FlowRouter.route('/groceries', { + name: 'groceries', + triggersEnter: [requireAuth], + action() { render('GroceryList'); }, +}); + FlowRouter.route('/settings', { name: 'settings', triggersEnter: [requireAuth], diff --git a/imports/ui/blaze/layout.js b/imports/ui/blaze/layout.js index 550fc28..d90baf4 100644 --- a/imports/ui/blaze/layout.js +++ b/imports/ui/blaze/layout.js @@ -198,7 +198,7 @@ Template.MainLayout.helpers({ } // Don't show spinner during normal navigation between main pages - const isMainNavPage = ['/', '/history', '/analysis', '/settings'].includes(currentPath); + const isMainNavPage = ['/', '/groceries', '/history', '/analysis', '/settings'].includes(currentPath); if (isMainNavPage && initialLoadState.get()) { return false; } @@ -218,6 +218,7 @@ Template.MainLayout.helpers({ navItems() { return [ { label: 'Home', path: '/', icon: 'bi-house-door' }, + { label: 'Groceries', path: '/groceries', icon: 'bi-cart3' }, { label: 'History', path: '/history', icon: 'bi-clock-history' }, { label: 'Analysis', path: '/analysis', icon: 'bi-graph-up' }, { label: 'Settings', path: '/settings', icon: 'bi-gear' }, diff --git a/imports/ui/blaze/pages/groceryList.html b/imports/ui/blaze/pages/groceryList.html new file mode 100644 index 0000000..721d8e6 --- /dev/null +++ b/imports/ui/blaze/pages/groceryList.html @@ -0,0 +1,122 @@ + diff --git a/imports/ui/blaze/pages/groceryList.js b/imports/ui/blaze/pages/groceryList.js new file mode 100644 index 0000000..2a89cd8 --- /dev/null +++ b/imports/ui/blaze/pages/groceryList.js @@ -0,0 +1,354 @@ +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Meteor } from 'meteor/meteor'; +import { GroceryLists } from '/imports/api/groceries'; +import { pushAlert, showConfirm } from '../layout'; +import './groceryList.html'; +import '/client/styles/groceryList.css'; + +// Import Sortable.js for drag and drop +let Sortable = null; + +Template.GroceryList.onCreated(function() { + this.subscribe('groceryList'); + this.currentStoreId = new ReactiveVar(null); + + // Load Sortable.js dynamically + if (typeof window !== 'undefined' && !window.Sortable) { + import('sortablejs').then(module => { + Sortable = module.default; + this.autorun(() => { + if (this.subscriptionsReady()) { + Meteor.defer(() => this.initSortable()); + } + }); + }); + } +}); + +Template.GroceryList.onRendered(function() { + this.initSortable = () => { + if (!Sortable) {return;} + + // Initialize store sorting + const storesContainer = document.getElementById('storesContainer'); + if (storesContainer && !storesContainer._sortableInstance) { + storesContainer._sortableInstance = new Sortable(storesContainer, { + animation: 150, + handle: '.store-drag-handle', + ghostClass: 'sortable-ghost', + forceFallback: false, + fallbackTolerance: 5, + touchStartThreshold: 5, + delay: 200, + delayOnTouchOnly: true, + onEnd: async (evt) => { + if (evt.oldIndex === evt.newIndex) {return;} + + const storeOrders = Array.from(storesContainer.children).map((el, index) => ({ + id: el.dataset.storeId, + order: index, + })); + + try { + await Meteor.callAsync('groceries.updateStoreOrder', storeOrders); + } catch (error) { + pushAlert('error', error.reason || 'Failed to reorder stores'); + } + }, + }); + } + + // Initialize item sorting for each store + const itemsContainers = document.querySelectorAll('.items-container'); + itemsContainers.forEach(container => { + if (!container._sortableInstance) { + container._sortableInstance = new Sortable(container, { + group: 'items', + animation: 150, + handle: '.item-drag-handle', + ghostClass: 'sortable-ghost', + forceFallback: false, + fallbackTolerance: 5, + touchStartThreshold: 5, + delay: 200, + delayOnTouchOnly: true, + onEnd: async (evt) => { + const toStoreId = evt.to.dataset.storeId; + const fromStoreId = evt.from.dataset.storeId; + const itemId = evt.item.dataset.itemId; + + try { + // Move to different store if needed + if (fromStoreId !== toStoreId) { + await Meteor.callAsync('groceries.moveItem', fromStoreId, toStoreId, itemId); + } + + // Update item order + const itemOrders = Array.from(evt.to.children).map((el, index) => ({ + id: el.dataset.itemId, + order: index, + })); + await Meteor.callAsync('groceries.updateItemOrder', toStoreId, itemOrders); + } catch (error) { + pushAlert('error', error.reason || 'Failed to move item'); + // Revert on error + evt.item.remove(); + evt.from.insertBefore(evt.item, evt.from.children[evt.oldIndex]); + } + }, + }); + } + }); + }; + + // Initialize sortable and sync checkbox states when ready + if (Sortable) { + this.autorun(() => { + if (this.subscriptionsReady()) { + Meteor.defer(() => { + this.initSortable(); + + // Sync checkbox states + document.querySelectorAll('.item-check').forEach(checkbox => { + checkbox.checked = checkbox.dataset.checked === 'true'; + }); + }); + } + }); + } +}); + +Template.GroceryList.helpers({ + isLoading() { + return !Template.instance().subscriptionsReady(); + }, + groceryList() { + return GroceryLists.findOne(); + }, + stores() { + const list = GroceryLists.findOne(); + return list?.stores?.sort((a, b) => a.order - b.order) || []; + }, + hasStores() { + const list = GroceryLists.findOne(); + return list?.stores?.length > 0; + }, + hasCheckedItems() { + const list = GroceryLists.findOne(); + return list?.stores?.some(store => + store.items.some(item => item.checked), + ); + }, + checkedClass() { + return this.checked ? 'checked' : ''; + }, +}); + +Template.GroceryList.events({ + 'click #addStoreBtn, click #addFirstStoreBtn'(e) { + e.preventDefault(); + e.stopPropagation(); + + const modalElement = document.getElementById('addStoreModal'); + if (!modalElement) {return;} + + // Dispose existing modal instance + const existingModal = window.bootstrap.Modal.getInstance(modalElement); + if (existingModal) { + existingModal.dispose(); + } + + // Create modal + const modal = new window.bootstrap.Modal(modalElement, { + backdrop: 'static', + keyboard: true, + }); + modal.show(); + + // Fix z-index stacking issue + Meteor.setTimeout(() => { + const backdrop = document.querySelector('.modal-backdrop'); + if (backdrop && modalElement) { + backdrop.parentNode.insertBefore(modalElement, backdrop.nextSibling); + + // Re-attach save button handler + const saveBtn = document.getElementById('saveStoreBtn'); + if (saveBtn) { + const newBtn = saveBtn.cloneNode(true); + saveBtn.parentNode.replaceChild(newBtn, saveBtn); + + newBtn.addEventListener('click', async () => { + const input = document.getElementById('storeNameInput'); + const storeName = input.value.trim(); + + if (!storeName) { + pushAlert('error', 'Please enter a store name'); + return; + } + + try { + await Meteor.callAsync('groceries.addStore', storeName); + input.value = ''; + window.bootstrap.Modal.getInstance(modalElement)?.hide(); + pushAlert('success', 'Store added'); + } catch (error) { + pushAlert('error', error.reason || 'Failed to add store'); + } + }); + } + } + }, 50); + + // Focus input + Meteor.setTimeout(() => { + document.getElementById('storeNameInput')?.focus(); + }, 500); + }, + + 'keypress #storeNameInput'(e) { + if (e.which === 13) { + e.preventDefault(); + document.getElementById('saveStoreBtn')?.click(); + } + }, + + async 'keypress .add-item-input'(e) { + if (e.which === 13) { + e.preventDefault(); + const input = e.currentTarget; + const itemName = input.value.trim(); + const storeId = input.dataset.storeId; + + if (!itemName) {return;} + + try { + await Meteor.callAsync('groceries.addItem', storeId, itemName); + input.value = ''; + } catch (error) { + pushAlert('error', error.reason || 'Failed to add item'); + } + } + }, + + async 'change .item-check'(e) { + const checkbox = e.currentTarget; + const itemId = checkbox.dataset.itemId; + const storeId = checkbox.dataset.storeId; + + try { + await Meteor.callAsync('groceries.toggleItem', storeId, itemId); + } catch (error) { + pushAlert('error', error.reason || 'Failed to toggle item'); + checkbox.checked = !checkbox.checked; + } + }, + + async 'click .remove-item-btn'(e) { + e.preventDefault(); + const button = e.currentTarget; + const itemId = button.dataset.itemId; + const storeId = button.dataset.storeId; + + try { + await Meteor.callAsync('groceries.removeItem', storeId, itemId); + } catch (error) { + pushAlert('error', error.reason || 'Failed to remove item'); + } + }, + + async 'click .remove-store-btn'(e) { + e.preventDefault(); + const button = e.currentTarget; + const storeId = button.dataset.storeId; + + const list = GroceryLists.findOne(); + const store = list?.stores?.find(s => s.id === storeId); + + if (store?.items?.length > 0) { + const confirmed = await showConfirm(`Remove "${store.name}" and all its items?`); + if (!confirmed) {return;} + } + + try { + await Meteor.callAsync('groceries.removeStore', storeId); + pushAlert('success', 'Store removed'); + } catch (error) { + pushAlert('error', error.reason || 'Failed to remove store'); + } + }, + + async 'click #clearCheckedBtn'(e) { + e.preventDefault(); + const confirmed = await showConfirm('Clear all checked items?'); + if (!confirmed) {return;} + + try { + await Meteor.callAsync('groceries.clearChecked'); + pushAlert('success', 'Checked items cleared'); + } catch (error) { + pushAlert('error', error.reason || 'Failed to clear items'); + } + }, + + 'click .rename-store-btn'(e) { + e.preventDefault(); + e.stopPropagation(); + + const storeId = e.currentTarget.dataset.storeId; + const list = GroceryLists.findOne(); + const store = list?.stores?.find(s => s.id === storeId); + + if (!store) {return;} + + Template.instance().currentStoreId.set(storeId); + + const modalElement = document.getElementById('renameStoreModal'); + const input = document.getElementById('renameStoreInput'); + input.value = store.name; + + const modal = new window.bootstrap.Modal(modalElement, { + backdrop: 'static', + keyboard: true, + }); + modal.show(); + + // Fix z-index and focus + Meteor.setTimeout(() => { + const backdrop = document.querySelector('.modal-backdrop'); + if (backdrop && modalElement) { + backdrop.parentNode.insertBefore(modalElement, backdrop.nextSibling); + } + input.focus(); + input.select(); + }, 100); + }, + + async 'click #saveRenameBtn'(e) { + e.preventDefault(); + + const storeId = Template.instance().currentStoreId.get(); + const newName = document.getElementById('renameStoreInput').value.trim(); + + if (!newName) { + pushAlert('error', 'Please enter a store name'); + return; + } + + try { + await Meteor.callAsync('groceries.renameStore', storeId, newName); + const modal = window.bootstrap.Modal.getInstance(document.getElementById('renameStoreModal')); + modal?.hide(); + pushAlert('success', 'Store renamed'); + } catch (error) { + pushAlert('error', error.reason || 'Failed to rename store'); + } + }, + + 'keypress #renameStoreInput'(e) { + if (e.which === 13) { + e.preventDefault(); + document.getElementById('saveRenameBtn')?.click(); + } + }, +}); diff --git a/package-lock.json b/package-lock.json index cbaf188..5b2cc07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "bootstrap-icons": "^1.13.1", "idb": "^7.1.1", "jquery": "^3.7.1", - "meteor-node-stubs": "^1.2.3" + "meteor-node-stubs": "^1.2.3", + "sortablejs": "^1.15.6" }, "devDependencies": { "@eslint/js": "^8.57.0", @@ -3864,6 +3865,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/package.json b/package.json index 147bf7a..6ab40fe 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "bootstrap-icons": "^1.13.1", "idb": "^7.1.1", "jquery": "^3.7.1", - "meteor-node-stubs": "^1.2.3" + "meteor-node-stubs": "^1.2.3", + "sortablejs": "^1.15.6" }, "devDependencies": { "@eslint/js": "^8.57.0", diff --git a/server/main.ts b/server/main.ts index 5733794..802d405 100644 --- a/server/main.ts +++ b/server/main.ts @@ -38,6 +38,7 @@ if (Meteor.isServer) { require('/imports/api/bills'); require('/imports/api/users'); require('/imports/api/accounts'); +require('/imports/api/groceries'); require('/imports/api/publications'); // Configure security headers