diff --git a/src/app.ts b/src/app.ts
index 18ac418..ea27cd0 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -2,7 +2,7 @@
// Main Application Logic
// ========================
-import { StorageManager, storage, STORAGE_VERSION, Task, Habit, FinanceItem, WishItem, Note, getDaysUntilDueText } from './storage.js';
+import { StorageManager, storage, STORAGE_VERSION, Task, Habit, FinanceItem, WishItem, Note, ShoppingItem, getDaysUntilDueText } from './storage.js';
const FILTER_SETTINGS_KEY = 'taskManagerFilterSettings';
@@ -25,6 +25,7 @@ class TaskManager {
currentEditingFinanceType: string | null = null;
currentEditingWishItemId: string | null = null;
currentEditingNoteId: string | null = null;
+ currentEditingShoppingItemId: string | null = null;
dragSrcWishId: string | null = null;
selectedDate: Date = new Date();
tasksExpanded: boolean = false;
@@ -137,6 +138,13 @@ class TaskManager {
document.getElementById('cancelWishItemBtn')!.addEventListener('click', () => this.closeWishItemModal());
document.getElementById('deleteWishItemBtn')!.addEventListener('click', () => this.deleteWishItem());
+ // Shopping List section
+ document.getElementById('addShoppingItemBtn')!.addEventListener('click', () => this.openShoppingItemModal());
+ document.getElementById('shoppingItemForm')!.addEventListener('submit', (e) => this.saveShoppingItem(e));
+ document.getElementById('cancelShoppingItemBtn')!.addEventListener('click', () => this.closeShoppingItemModal());
+ document.getElementById('deleteShoppingItemBtn')!.addEventListener('click', () => this.deleteShoppingItem());
+ document.getElementById('clearCompletedShoppingBtn')!.addEventListener('click', () => this.clearCompletedShoppingItems());
+
// Notes section
document.getElementById('addNoteBtn')!.addEventListener('click', () => this.openNoteModal());
document.getElementById('noteForm')!.addEventListener('submit', (e) => this.saveNote(e));
@@ -257,6 +265,8 @@ class TaskManager {
this.renderFinances();
} else if (tabName === 'wishlist') {
this.renderWishList();
+ } else if (tabName === 'shopping') {
+ this.renderShoppingList();
} else if (tabName === 'notes') {
this.renderNotes();
} else if (tabName === 'settings') {
@@ -1952,6 +1962,109 @@ class TaskManager {
}
}
+ // ========================
+ // Shopping List
+ // ========================
+ renderShoppingList(): void {
+ const items = storage.getShoppingItems();
+ const container = document.getElementById('shoppingList')!;
+
+ if (items.length === 0) {
+ container.innerHTML = '
No items in your shopping list. Add one to get started!
';
+ return;
+ }
+
+ container.innerHTML = items.map(item => this.renderShoppingItem(item)).join('');
+
+ container.querySelectorAll
('.shopping-item').forEach(el => {
+ el.querySelector('.shopping-item-checkbox')!.addEventListener('change', (e) => {
+ const checkbox = e.target as HTMLInputElement;
+ storage.updateShoppingItem(el.dataset.shoppingId!, { completed: checkbox.checked });
+ this.renderShoppingList();
+ });
+
+ el.querySelector('.edit-shopping-btn')!.addEventListener('click', () => {
+ this.openShoppingItemModal(el.dataset.shoppingId!);
+ });
+ });
+ }
+
+ renderShoppingItem(item: ShoppingItem): string {
+ const completedClass = item.completed ? ' completed' : '';
+ const checkedAttr = item.completed ? ' checked' : '';
+ const quantityHtml = item.quantity
+ ? `${this.escapeHtml(item.quantity)}
`
+ : '';
+ return `
+
+ `;
+ }
+
+ openShoppingItemModal(itemId: string | null = null): void {
+ this.currentEditingShoppingItemId = itemId;
+ const modal = document.getElementById('shoppingItemModal')!;
+ const form = document.getElementById('shoppingItemForm') as HTMLFormElement;
+ const deleteBtn = document.getElementById('deleteShoppingItemBtn') as HTMLElement;
+
+ form.reset();
+ deleteBtn.style.display = 'none';
+
+ document.getElementById('shoppingItemModalTitle')!.textContent = itemId ? 'Edit Shopping Item' : 'Add Shopping Item';
+
+ if (itemId) {
+ const item = storage.getShoppingItems().find(s => s.id === itemId);
+ if (item) {
+ (document.getElementById('shoppingItemName') as HTMLInputElement).value = item.name;
+ (document.getElementById('shoppingItemQuantity') as HTMLInputElement).value = item.quantity || '';
+ deleteBtn.style.display = 'inline-block';
+ }
+ }
+
+ modal.classList.add('active');
+ }
+
+ closeShoppingItemModal(): void {
+ document.getElementById('shoppingItemModal')!.classList.remove('active');
+ this.currentEditingShoppingItemId = null;
+ }
+
+ saveShoppingItem(e: Event): void {
+ e.preventDefault();
+ const name = (document.getElementById('shoppingItemName') as HTMLInputElement).value.trim();
+ const quantity = (document.getElementById('shoppingItemQuantity') as HTMLInputElement).value.trim() || undefined;
+
+ if (this.currentEditingShoppingItemId) {
+ storage.updateShoppingItem(this.currentEditingShoppingItemId, { name, quantity });
+ } else {
+ storage.addShoppingItem({ name, quantity });
+ }
+
+ this.closeShoppingItemModal();
+ this.renderShoppingList();
+ }
+
+ deleteShoppingItem(): void {
+ if (this.currentEditingShoppingItemId) {
+ if (confirm('Are you sure you want to delete this shopping item?')) {
+ storage.deleteShoppingItem(this.currentEditingShoppingItemId);
+ this.closeShoppingItemModal();
+ this.renderShoppingList();
+ }
+ }
+ }
+
+ clearCompletedShoppingItems(): void {
+ storage.clearCompletedShoppingItems();
+ this.renderShoppingList();
+ }
+
// ========================
// Notes
// ========================
diff --git a/src/storage.ts b/src/storage.ts
index ef0fa53..96d30eb 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -84,6 +84,14 @@ export interface Note {
updatedDate?: string;
}
+export interface ShoppingItem {
+ id: string;
+ name: string;
+ quantity?: string;
+ completed: boolean;
+ createdDate: string;
+}
+
export interface UserStats {
level: number;
dailyStreak: number;
@@ -115,6 +123,7 @@ export interface AppData {
settings: Settings;
wishList: WishItem[];
notes: Note[];
+ shoppingList: ShoppingItem[];
}
export interface ValidationResult {
@@ -168,7 +177,8 @@ export class StorageManager {
tasksPerLevel: 30
},
wishList: [],
- notes: []
+ notes: [],
+ shoppingList: []
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialData));
@@ -594,6 +604,55 @@ export class StorageManager {
);
}
+ // Shopping List Management
+ addShoppingItem(item: Partial): ShoppingItem {
+ const data = this.getData();
+ if (!data.shoppingList) data.shoppingList = [];
+ const newItem: ShoppingItem = {
+ id: this.generateId(),
+ name: item.name || '',
+ quantity: item.quantity,
+ completed: false,
+ createdDate: new Date().toISOString(),
+ };
+ data.shoppingList.push(newItem);
+ this.saveData(data);
+ return newItem;
+ }
+
+ updateShoppingItem(itemId: string, updates: Partial): ShoppingItem | undefined {
+ const data = this.getData();
+ if (!data.shoppingList) data.shoppingList = [];
+ const item = data.shoppingList.find(s => s.id === itemId);
+ if (item) {
+ Object.assign(item, updates);
+ this.saveData(data);
+ }
+ return item;
+ }
+
+ deleteShoppingItem(itemId: string): void {
+ const data = this.getData();
+ if (!data.shoppingList) data.shoppingList = [];
+ data.shoppingList = data.shoppingList.filter(s => s.id !== itemId);
+ this.saveData(data);
+ }
+
+ getShoppingItems(): ShoppingItem[] {
+ const data = this.getData();
+ if (!data.shoppingList) return [];
+ return data.shoppingList.slice().sort((a, b) =>
+ new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime()
+ );
+ }
+
+ clearCompletedShoppingItems(): void {
+ const data = this.getData();
+ if (!data.shoppingList) return;
+ data.shoppingList = data.shoppingList.filter(s => !s.completed);
+ this.saveData(data);
+ }
+
updateLevel(): void {
const data = this.getData();
const settings = this.getSettings();
@@ -720,6 +779,7 @@ export class StorageManager {
settings: data.settings || { tasksPerLevel: 30 },
wishList: Array.isArray(data.wishList) ? data.wishList : [],
notes: Array.isArray(data.notes) ? data.notes : [],
+ shoppingList: Array.isArray(data.shoppingList) ? data.shoppingList : [],
};
}
diff --git a/tests/storage.test.ts b/tests/storage.test.ts
index 3681e0c..3bd884f 100644
--- a/tests/storage.test.ts
+++ b/tests/storage.test.ts
@@ -748,6 +748,81 @@ describe('StorageManager', () => {
expect(storage.getNotes()).toEqual([]);
});
});
+
+ // ========================
+ // Shopping List Management
+ // ========================
+ describe('shopping list management', () => {
+ it('should add a shopping item', () => {
+ const item = storage.addShoppingItem({ name: 'Milk', quantity: '2 litres' });
+ expect(item.id).toBeDefined();
+ expect(item.name).toBe('Milk');
+ expect(item.quantity).toBe('2 litres');
+ expect(item.completed).toBe(false);
+ expect(item.createdDate).toBeDefined();
+ });
+
+ it('should add a shopping item with only name', () => {
+ const item = storage.addShoppingItem({ name: 'Bread' });
+ expect(item.name).toBe('Bread');
+ expect(item.quantity).toBeUndefined();
+ });
+
+ it('should set default name to empty string when not provided', () => {
+ const item = storage.addShoppingItem({});
+ expect(item.name).toBe('');
+ expect(item.completed).toBe(false);
+ });
+
+ it('should get all shopping items sorted by creation date', () => {
+ storage.addShoppingItem({ name: 'Item A' });
+ storage.addShoppingItem({ name: 'Item B' });
+ storage.addShoppingItem({ name: 'Item C' });
+ const items = storage.getShoppingItems();
+ expect(items.length).toBe(3);
+ });
+
+ it('should update a shopping item', () => {
+ const item = storage.addShoppingItem({ name: 'Old Name', quantity: '1' });
+ const updated = storage.updateShoppingItem(item.id, { name: 'New Name', quantity: '3' });
+ expect(updated?.name).toBe('New Name');
+ expect(updated?.quantity).toBe('3');
+ });
+
+ it('should return undefined when updating non-existent shopping item', () => {
+ const result = storage.updateShoppingItem('nonexistent', { name: 'Test' });
+ expect(result).toBeUndefined();
+ });
+
+ it('should toggle completed on a shopping item', () => {
+ const item = storage.addShoppingItem({ name: 'Eggs' });
+ expect(item.completed).toBe(false);
+ const checked = storage.updateShoppingItem(item.id, { completed: true });
+ expect(checked?.completed).toBe(true);
+ const unchecked = storage.updateShoppingItem(item.id, { completed: false });
+ expect(unchecked?.completed).toBe(false);
+ });
+
+ it('should delete a shopping item', () => {
+ const item = storage.addShoppingItem({ name: 'To delete' });
+ storage.deleteShoppingItem(item.id);
+ expect(storage.getShoppingItems().length).toBe(0);
+ });
+
+ it('should clear only completed shopping items', () => {
+ storage.addShoppingItem({ name: 'Keep me' });
+ const done = storage.addShoppingItem({ name: 'Done item' });
+ storage.updateShoppingItem(done.id, { completed: true });
+ storage.clearCompletedShoppingItems();
+ const items = storage.getShoppingItems();
+ expect(items.length).toBe(1);
+ expect(items[0].name).toBe('Keep me');
+ });
+
+ it('should return empty array when no shopping items exist', () => {
+ expect(storage.getShoppingItems()).toEqual([]);
+ });
+ });
});
// ========================