From d6c5e6fcfddf0a4963f61d90740d095a2c2dbafe Mon Sep 17 00:00:00 2001 From: mohamad Date: Sat, 28 Jun 2025 21:37:26 +0200 Subject: [PATCH] chore: Remove package-lock.json and enhance financials API with user summaries This commit includes the following changes: - Deleted the `package-lock.json` file to streamline dependency management. - Updated the `financials.py` endpoint to return a comprehensive user financial summary, including net balance, total group spending, debts, and credits. - Enhanced the `expense.py` CRUD operations to handle enum values and improve error handling during expense deletion. - Introduced new schemas in `financials.py` for user financial summaries and debt/credit tracking. - Refactored the costs service to improve group balance summary calculations. These changes aim to improve the application's financial tracking capabilities and maintain cleaner dependency management. --- be/app/api/v1/endpoints/financials.py | 128 +- be/app/crud/expense.py | 31 +- be/app/schemas/financials.py | 31 +- be/app/services/costs_service.py | 6 +- fe/README.md | 7 +- fe/package-lock.json | 337 +++++- fe/package.json | 9 +- fe/src/App.vue | 4 +- fe/src/components/AuthenticationSheet.vue | 624 ++++++++-- fe/src/components/ChoreCard.vue | 410 +++++++ fe/src/components/ChoreDetailSheet.vue | 875 +++++++++++++- fe/src/components/ChoreItem.vue | 281 +++-- fe/src/components/ChoresList.vue | 560 +++++++++ fe/src/components/InviteManager.vue | 246 +++- fe/src/components/ListCard.vue | 247 ++++ fe/src/components/ListDirectory.vue | 599 +++++++++ fe/src/components/OnboardingCarousel.vue | 907 ++++++++++++++ fe/src/components/QuickChoreAdd.vue | 648 +++++++++- fe/src/components/ReceiptScannerModal.vue | 227 ++++ fe/src/components/SmartShoppingItem.vue | 339 ++++++ fe/src/components/SmartShoppingList.vue | 828 +++++++++++++ .../__tests__/InviteManager.spec.ts | 19 + .../__tests__/ReceiptScannerModal.spec.ts | 16 + fe/src/components/dashboard/ActivityFeed.vue | 518 +++++++- fe/src/components/dashboard/ActivityItem.vue | 632 +++++++++- .../dashboard/PersonalStatusCard.vue | 302 ++++- .../dashboard/QuickWinSuggestion.vue | 513 ++++++++ fe/src/components/dashboard/UniversalFAB.vue | 455 ++++++- .../expenses/ExpenseCreationSheet.vue | 1076 ++++++++++++++++- .../components/expenses/ExpenseOverview.vue | 276 ++++- fe/src/components/expenses/SettlementFlow.vue | 713 ++++++++++- .../global/ConflictResolutionDialog.vue | 492 ++++++++ fe/src/components/list-detail/ItemsList.vue | 40 +- fe/src/components/list-detail/ListItem.vue | 68 +- .../list-detail/ListItemComposer.vue | 545 +++++++++ .../PurchaseConfirmationDialog.vue | 32 + fe/src/components/ui/Button.vue | 221 +++- fe/src/components/ui/Card.vue | 139 ++- fe/src/components/ui/Checkbox.vue | 153 +++ fe/src/components/ui/Input.vue | 398 +++++- fe/src/components/ui/Textarea.vue | 73 ++ fe/src/components/ui/index.ts | 4 +- fe/src/composables/useFairness.ts | 29 +- fe/src/composables/useOfflineSync.ts | 35 +- fe/src/composables/useOptimisticUpdates.ts | 38 +- fe/src/composables/usePersonalStatus.ts | 147 ++- fe/src/composables/useSocket.ts | 82 ++ fe/src/config/api-config.ts | 3 + fe/src/pages/DashboardPage.vue | 222 +++- fe/src/pages/HouseholdSettings.vue | 482 ++++++-- fe/src/pages/LoginPage.vue | 5 +- fe/src/sw.ts | 257 ++-- fe/src/types/list.ts | 3 + fe/src/utils/analytics.ts | 15 + fe/src/utils/offlineQueue.ts | 45 + fe/tailwind.config.ts | 218 +++- fe/tsconfig.app.json | 25 +- package-lock.json | 6 - 58 files changed, 14792 insertions(+), 849 deletions(-) create mode 100644 fe/src/components/ChoreCard.vue create mode 100644 fe/src/components/ChoresList.vue create mode 100644 fe/src/components/ListCard.vue create mode 100644 fe/src/components/ListDirectory.vue create mode 100644 fe/src/components/OnboardingCarousel.vue create mode 100644 fe/src/components/ReceiptScannerModal.vue create mode 100644 fe/src/components/SmartShoppingItem.vue create mode 100644 fe/src/components/SmartShoppingList.vue create mode 100644 fe/src/components/__tests__/InviteManager.spec.ts create mode 100644 fe/src/components/__tests__/ReceiptScannerModal.spec.ts create mode 100644 fe/src/components/dashboard/QuickWinSuggestion.vue create mode 100644 fe/src/components/global/ConflictResolutionDialog.vue create mode 100644 fe/src/components/list-detail/ListItemComposer.vue create mode 100644 fe/src/components/list-detail/PurchaseConfirmationDialog.vue create mode 100644 fe/src/components/ui/Checkbox.vue create mode 100644 fe/src/components/ui/Textarea.vue create mode 100644 fe/src/composables/useSocket.ts create mode 100644 fe/src/utils/analytics.ts create mode 100644 fe/src/utils/offlineQueue.ts delete mode 100644 package-lock.json diff --git a/be/app/api/v1/endpoints/financials.py b/be/app/api/v1/endpoints/financials.py index 80435bf..8c29c0d 100644 --- a/be/app/api/v1/endpoints/financials.py +++ b/be/app/api/v1/endpoints/financials.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import joinedload from typing import List as PyList, Optional, Sequence, Union +from decimal import Decimal from app.database import get_transactional_session from app.auth import current_active_user @@ -23,7 +24,7 @@ from app.schemas.expense import ( SettlementCreate, SettlementPublic, ExpenseUpdate, SettlementUpdate ) -from app.schemas.financials import FinancialActivityResponse +from app.schemas.financials import FinancialActivityResponse, UserFinancialSummary, DebtCredit, SummaryUser from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added from app.crud import expense as crud_expense from app.crud import settlement as crud_settlement @@ -35,7 +36,8 @@ from app.core.exceptions import ( InvalidOperationError, GroupPermissionError, ListPermissionError, ItemNotFoundError, GroupMembershipError, OverpaymentError, FinancialConflictError ) -from app.services import financials_service +from app.services import financials_service, costs_service +from app.schemas.cost import GroupBalanceSummary logger = logging.getLogger(__name__) router = APIRouter() @@ -298,7 +300,7 @@ async def delete_expense_record( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot delete this expense (must be payer or group owner)") try: - await crud_expense.delete_expense(db=db, expense_db=expense_db, expected_version=expected_version) + await crud_expense.delete_expense(db=db, expense_db=expense_db, current_user_id=current_user.id, expected_version=expected_version) logger.info(f"Expense ID {expense_id} deleted successfully.") # No need to return content on 204 except InvalidOperationError as e: @@ -687,4 +689,122 @@ async def get_user_financial_activity( # The service returns a mix of ExpenseModel and SettlementModel objects. # We need to wrap it in our response schema. Pydantic will handle the Union type. - return FinancialActivityResponse(activities=activities) \ No newline at end of file + return FinancialActivityResponse(activities=activities) + +@router.get( + "/summary/group/{group_id}", + response_model=UserFinancialSummary, + summary="Get Financial Summary for a Specific Group (alias)", + tags=["Financials", "Groups"], +) +async def alias_group_balance_summary( + group_id: int, + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """Returns a financial summary formatted for the frontend for a single group, leveraging + the existing costs_service balance logic.""" + group_summary = await costs_service.get_group_balance_summary_logic( + db=db, group_id=group_id, current_user_id=current_user.id + ) + + net_balance = Decimal("0.00") + debts: PyList[DebtCredit] = [] + credits: PyList[DebtCredit] = [] + + # Find the current user's balance within the group + for ub in group_summary.user_balances: + if ub.user_id == current_user.id: + net_balance = ub.net_balance + break + + # Translate suggested settlements + if group_summary.suggested_settlements: + for s in group_summary.suggested_settlements: + if s.from_user_id == current_user.id: + debts.append( + DebtCredit( + user=SummaryUser(id=s.to_user_id, name=s.to_user_identifier), + amount=float(s.amount), + ) + ) + elif s.to_user_id == current_user.id: + credits.append( + DebtCredit( + user=SummaryUser(id=s.from_user_id, name=s.from_user_identifier), + amount=float(s.amount), + ) + ) + + return UserFinancialSummary( + net_balance=float(net_balance.quantize(Decimal("0.01"))), + total_group_spending=float(group_summary.overall_total_expenses.quantize(Decimal("0.01"))), + debts=debts, + credits=credits, + currency="USD", + ) + +@router.get( + "/summary/user", + response_model=UserFinancialSummary, + summary="Get Consolidated Financial Summary for Current User", + tags=["Financials"], +) +async def get_current_user_financial_summary( + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """Aggregates the user's financial position across all groups they belong to and returns + a concise summary structure expected by the frontend (net balance, debts, credits, etc.).""" + # 1. Retrieve groups where the user is a member + groups_result = await db.execute( + select(GroupModel).join(UserGroupModel).where(UserGroupModel.user_id == current_user.id) + ) + groups: Sequence[GroupModel] = groups_result.scalars().all() + + net_balance = Decimal("0.00") + total_group_spending = Decimal("0.00") + debts: PyList[DebtCredit] = [] + credits: PyList[DebtCredit] = [] + + # 2. Iterate through each group and accumulate balances & settlements + for group in groups: + group_summary = await costs_service.get_group_balance_summary_logic( + db=db, group_id=group.id, current_user_id=current_user.id + ) + + total_group_spending += group_summary.overall_total_expenses + + # Find current user's balance within the group + for ub in group_summary.user_balances: + if ub.user_id == current_user.id: + net_balance += ub.net_balance + break + + # Translate suggested_settlements into debts/credits relative to current user + if group_summary.suggested_settlements: + for s in group_summary.suggested_settlements: + if s.from_user_id == current_user.id: + # Current user owes another household member + debts.append( + DebtCredit( + user=SummaryUser(id=s.to_user_id, name=s.to_user_identifier), + amount=float(s.amount), + ) + ) + elif s.to_user_id == current_user.id: + # Another member owes current user + credits.append( + DebtCredit( + user=SummaryUser(id=s.from_user_id, name=s.from_user_identifier), + amount=float(s.amount), + ) + ) + + return UserFinancialSummary( + net_balance=float(net_balance.quantize(Decimal("0.01"))), + total_group_spending=float(total_group_spending.quantize(Decimal("0.01"))), + debts=debts, + credits=credits, + currency="USD", + ) \ No newline at end of file diff --git a/be/app/crud/expense.py b/be/app/crud/expense.py index ea05eef..1cf8c1a 100644 --- a/be/app/crud/expense.py +++ b/be/app/crud/expense.py @@ -678,6 +678,8 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: for k, v in before_state.items(): if isinstance(v, (datetime, Decimal)): before_state[k] = str(v) + elif hasattr(v, 'value'): # Handle enums + before_state[k] = v.value update_data = expense_in.dict(exclude_unset=True, exclude={"version"}) @@ -705,6 +707,8 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: for k, v in after_state.items(): if isinstance(v, (datetime, Decimal)): after_state[k] = str(v) + elif hasattr(v, 'value'): # Handle enums + after_state[k] = v.value await create_financial_audit_log( db=db, @@ -740,6 +744,8 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_use for k, v in details.items(): if isinstance(v, (datetime, Decimal)): details[k] = str(v) + elif hasattr(v, 'value'): # Handle enums + details[k] = v.value expense_id_for_log = expense_db.id @@ -751,11 +757,34 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_use details=details ) + # Manually delete related records in correct order to avoid foreign key constraint issues + from app.models import ExpenseSplit as ExpenseSplitModel, SettlementActivity as SettlementActivityModel + + # First, get all the splits for this expense + splits_result = await db.execute( + select(ExpenseSplitModel).where(ExpenseSplitModel.expense_id == expense_db.id) + ) + splits_to_delete = splits_result.scalars().all() + + # Then, delete all settlement activities that reference these splits + for split in splits_to_delete: + settlement_activities_result = await db.execute( + select(SettlementActivityModel).where(SettlementActivityModel.expense_split_id == split.id) + ) + settlement_activities_to_delete = settlement_activities_result.scalars().all() + + for settlement_activity in settlement_activities_to_delete: + await db.delete(settlement_activity) + + # Now we can safely delete the splits + for split in splits_to_delete: + await db.delete(split) + await db.delete(expense_db) await db.flush() except IntegrityError as e: logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True) - raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e + raise DatabaseIntegrityError() from e except SQLAlchemyError as e: logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True) raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e diff --git a/be/app/schemas/financials.py b/be/app/schemas/financials.py index 3d8cb40..b67a3e1 100644 --- a/be/app/schemas/financials.py +++ b/be/app/schemas/financials.py @@ -1,9 +1,38 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Union, List from .expense import ExpensePublic, SettlementPublic +from decimal import Decimal class FinancialActivityResponse(BaseModel): activities: List[Union[ExpensePublic, SettlementPublic]] + class Config: + orm_mode = True + +class SummaryUser(BaseModel): + """Lightweight representation of a user for summary responses.""" + id: int + name: str + + class Config: + orm_mode = True + +class DebtCredit(BaseModel): + """Represents a debt or credit entry in the user financial summary.""" + user: SummaryUser + amount: float + + class Config: + orm_mode = True + +class UserFinancialSummary(BaseModel): + """Aggregated financial summary for the authenticated user across all groups.""" + + net_balance: float = 0.0 + total_group_spending: float = 0.0 + debts: List[DebtCredit] = Field(default_factory=list) + credits: List[DebtCredit] = Field(default_factory=list) + currency: str = "USD" + class Config: orm_mode = True \ No newline at end of file diff --git a/be/app/services/costs_service.py b/be/app/services/costs_service.py index df74adf..ee6130f 100644 --- a/be/app/services/costs_service.py +++ b/be/app/services/costs_service.py @@ -262,7 +262,7 @@ async def get_group_balance_summary_logic( settlements_result = await db.execute( select(SettlementModel).where(SettlementModel.group_id == group_id) - .options(selectinload(SettlementModel.paid_by_user), selectinload(SettlementModel.paid_to_user)) + .options(selectinload(SettlementModel.payer), selectinload(SettlementModel.payee)) ) settlements = settlements_result.scalars().all() @@ -330,8 +330,8 @@ async def get_group_balance_summary_logic( final_user_balances.sort(key=lambda x: x.user_identifier) suggested_settlements = calculate_suggested_settlements(final_user_balances) - overall_total_expenses = sum(expense.total_amount for expense in expenses) - overall_total_settlements = sum(settlement.amount for settlement in settlements) + overall_total_expenses = sum((expense.total_amount for expense in expenses), start=Decimal("0.00")) + overall_total_settlements = sum((settlement.amount for settlement in settlements), start=Decimal("0.00")) return GroupBalanceSummary( group_id=db_group.id, diff --git a/fe/README.md b/fe/README.md index 90bf698..f215094 100644 --- a/fe/README.md +++ b/fe/README.md @@ -23,7 +23,12 @@ npm install ### Compile and Hot-Reload for Development ```sh -npm run dev +# Frontend only +npm run dev:fe + +# Start both frontend & backend in two terminals +npm run dev:be +npm run dev:fe ``` ### Type-Check, Compile and Minify for Production diff --git a/fe/package-lock.json b/fe/package-lock.json index 128d942..e93c873 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -12,10 +12,15 @@ "@iconify/vue": "^4.1.1", "@sentry/tracing": "^7.120.3", "@sentry/vue": "^7.120.3", + "@types/dom-speech-recognition": "^0.0.6", + "@types/qrcode": "^1.5.5", "@vueuse/core": "^13.1.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "idb-keyval": "^6.2.2", + "mock-socket": "^9.3.1", "pinia": "^3.0.2", + "qrcode": "^1.5.4", "qs": "^6.14.0", "vue": "^3.5.13", "vue-i18n": "^9.9.1", @@ -4103,6 +4108,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-speech-recognition": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.6.tgz", + "integrity": "sha512-o7pAVq9UQPJL5RDjO1f/fcpfFHdgiMnR4PoIU2N/ZQrYOS3C5rzdOJMsrpqeBCbii2EE9mERXgqspQqPDdPahw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -4133,12 +4144,20 @@ "version": "22.15.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -5041,7 +5060,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5601,6 +5619,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -5692,11 +5719,76 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5709,7 +5801,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6030,6 +6121,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", @@ -6194,6 +6294,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -7497,6 +7603,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7860,6 +7975,12 @@ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8166,7 +8287,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9154,6 +9274,15 @@ "ufo": "^1.5.4" } }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -9602,6 +9731,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -9669,7 +9807,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9878,6 +10015,15 @@ "node": ">=18" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -10183,6 +10329,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -10449,6 +10612,15 @@ "node": ">=6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -10459,6 +10631,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -10787,6 +10965,12 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12109,7 +12293,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -12925,6 +13108,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -13575,6 +13764,12 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -13611,6 +13806,134 @@ "url": "https://github.com/sponsors/ota-meshi" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/fe/package.json b/fe/package.json index 5bb2ad3..5757d9e 100644 --- a/fe/package.json +++ b/fe/package.json @@ -16,17 +16,24 @@ "lint": "run-s lint:*", "format": "prettier --write src/", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "dev:fe": "vite", + "dev:be": "cd ../be && uvicorn app.main:app --reload" }, "dependencies": { "@headlessui/vue": "^1.7.23", "@iconify/vue": "^4.1.1", "@sentry/tracing": "^7.120.3", "@sentry/vue": "^7.120.3", + "@types/dom-speech-recognition": "^0.0.6", + "@types/qrcode": "^1.5.5", "@vueuse/core": "^13.1.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "idb-keyval": "^6.2.2", + "mock-socket": "^9.3.1", "pinia": "^3.0.2", + "qrcode": "^1.5.4", "qs": "^6.14.0", "vue": "^3.5.13", "vue-i18n": "^9.9.1", diff --git a/fe/src/App.vue b/fe/src/App.vue index 03562d1..5b0ec97 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -1,10 +1,12 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/fe/src/components/ChoreCard.vue b/fe/src/components/ChoreCard.vue new file mode 100644 index 0000000..bf3c8d5 --- /dev/null +++ b/fe/src/components/ChoreCard.vue @@ -0,0 +1,410 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/ChoreDetailSheet.vue b/fe/src/components/ChoreDetailSheet.vue index fdb47ea..8e727ab 100644 --- a/fe/src/components/ChoreDetailSheet.vue +++ b/fe/src/components/ChoreDetailSheet.vue @@ -1,61 +1,301 @@ \ No newline at end of file + + + \ No newline at end of file diff --git a/fe/src/components/ChoreItem.vue b/fe/src/components/ChoreItem.vue index 9e31f84..d520b43 100644 --- a/fe/src/components/ChoreItem.vue +++ b/fe/src/components/ChoreItem.vue @@ -1,84 +1,90 @@ @@ -159,4 +171,117 @@ function getDueDateStatus(chore: ChoreWithCompletion) { export default { name: 'ChoreItem' } - \ No newline at end of file + + + \ No newline at end of file diff --git a/fe/src/components/ChoresList.vue b/fe/src/components/ChoresList.vue new file mode 100644 index 0000000..781334a --- /dev/null +++ b/fe/src/components/ChoresList.vue @@ -0,0 +1,560 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/InviteManager.vue b/fe/src/components/InviteManager.vue index e6b4558..5962fc2 100644 --- a/fe/src/components/InviteManager.vue +++ b/fe/src/components/InviteManager.vue @@ -1,84 +1,252 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/fe/src/components/ListCard.vue b/fe/src/components/ListCard.vue new file mode 100644 index 0000000..1a0e705 --- /dev/null +++ b/fe/src/components/ListCard.vue @@ -0,0 +1,247 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/ListDirectory.vue b/fe/src/components/ListDirectory.vue new file mode 100644 index 0000000..d23ef56 --- /dev/null +++ b/fe/src/components/ListDirectory.vue @@ -0,0 +1,599 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/OnboardingCarousel.vue b/fe/src/components/OnboardingCarousel.vue new file mode 100644 index 0000000..d5797b5 --- /dev/null +++ b/fe/src/components/OnboardingCarousel.vue @@ -0,0 +1,907 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/QuickChoreAdd.vue b/fe/src/components/QuickChoreAdd.vue index 3d9426f..21cb93c 100644 --- a/fe/src/components/QuickChoreAdd.vue +++ b/fe/src/components/QuickChoreAdd.vue @@ -1,41 +1,647 @@ \ No newline at end of file + +// Lifecycle +onMounted(() => { + suggestions.value = generateSuggestions() + + // Cycle tips every 5 seconds + setInterval(() => { + currentTipIndex.value = (currentTipIndex.value + 1) % tips.length + }, 5000) +}) + +// Add keyboard event listener when focused +watch(isFocused, (focused) => { + if (focused) { + document.addEventListener('keydown', handleKeydown) + } else { + document.removeEventListener('keydown', handleKeydown) + } +}) + + + \ No newline at end of file diff --git a/fe/src/components/ReceiptScannerModal.vue b/fe/src/components/ReceiptScannerModal.vue new file mode 100644 index 0000000..71bf9a9 --- /dev/null +++ b/fe/src/components/ReceiptScannerModal.vue @@ -0,0 +1,227 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/SmartShoppingItem.vue b/fe/src/components/SmartShoppingItem.vue new file mode 100644 index 0000000..6b15dc5 --- /dev/null +++ b/fe/src/components/SmartShoppingItem.vue @@ -0,0 +1,339 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/SmartShoppingList.vue b/fe/src/components/SmartShoppingList.vue new file mode 100644 index 0000000..69b7ad2 --- /dev/null +++ b/fe/src/components/SmartShoppingList.vue @@ -0,0 +1,828 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/__tests__/InviteManager.spec.ts b/fe/src/components/__tests__/InviteManager.spec.ts new file mode 100644 index 0000000..b6a7561 --- /dev/null +++ b/fe/src/components/__tests__/InviteManager.spec.ts @@ -0,0 +1,19 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect, vi } from 'vitest' +import InviteManager from '../InviteManager.vue' + +// Mock clipboard +vi.stubGlobal('navigator', { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, +}) + +describe('InviteManager.vue', () => { + it('shows generate button when no invite', () => { + const wrapper = mount(InviteManager, { + props: { groupId: 1 }, + }) + expect(wrapper.text()).toContain('Generate Invite Code') + }) +}) \ No newline at end of file diff --git a/fe/src/components/__tests__/ReceiptScannerModal.spec.ts b/fe/src/components/__tests__/ReceiptScannerModal.spec.ts new file mode 100644 index 0000000..efc28a8 --- /dev/null +++ b/fe/src/components/__tests__/ReceiptScannerModal.spec.ts @@ -0,0 +1,16 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import ReceiptScannerModal from '../ReceiptScannerModal.vue' + +describe('ReceiptScannerModal.vue', () => { + it('rejects non-image files', async () => { + const wrapper = mount(ReceiptScannerModal) + await wrapper.setProps({ modelValue: true }) + + const fileInput = wrapper.find('input[type="file"]') + const fakeFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }) + await fileInput.trigger('change', { target: { files: [fakeFile] } }) + + expect(wrapper.text()).toContain('Only image files are supported') + }) +}) \ No newline at end of file diff --git a/fe/src/components/dashboard/ActivityFeed.vue b/fe/src/components/dashboard/ActivityFeed.vue index 3f645e1..cb08c3c 100644 --- a/fe/src/components/dashboard/ActivityFeed.vue +++ b/fe/src/components/dashboard/ActivityFeed.vue @@ -1,35 +1,287 @@ \ No newline at end of file + +// Track new activities for animations +watch(() => store.activities, (newActivities, oldActivities) => { + if (oldActivities && newActivities.length > oldActivities.length) { + // New activities were added + const newIds = newActivities + .slice(0, newActivities.length - oldActivities.length) + .map(activity => activity.id) + + newIds.forEach(id => recentActivityIds.value.add(id)) + + // Remove from recent set after animation + setTimeout(() => { + newIds.forEach(id => recentActivityIds.value.delete(id)) + }, 3000) + } +}, { flush: 'post' }) + + + \ No newline at end of file diff --git a/fe/src/components/dashboard/ActivityItem.vue b/fe/src/components/dashboard/ActivityItem.vue index aae9ac9..4b4a7fc 100644 --- a/fe/src/components/dashboard/ActivityItem.vue +++ b/fe/src/components/dashboard/ActivityItem.vue @@ -1,44 +1,630 @@ \ No newline at end of file + +// Enhanced message with formatting +const enhancedMessage = computed(() => { + let message = props.activity.message + + // Bold user names + const userName = props.activity.user?.name + if (userName) { + message = message.replace(userName, `${userName}`) + } + + // Highlight items in quotes + message = message.replace(/'([^']+)'/g, '$1') + + return message +}) + +// Activity styling classes +const activityClasses = computed(() => [ + 'activity-item-base', + { + 'activity-compact': props.compact, + 'activity-new': props.isNew, + 'activity-completion': isCompletion.value, + 'activity-interactive': true, + } +]) + +const iconClasses = computed(() => { + const baseClasses = ['activity-icon-base'] + + switch (props.activity.event_type) { + case ActivityEventType.CHORE_COMPLETED: + baseClasses.push('icon-success') + break + case ActivityEventType.EXPENSE_CREATED: + baseClasses.push('icon-warning') + break + case ActivityEventType.EXPENSE_SETTLED: + baseClasses.push('icon-success') + break + case ActivityEventType.ITEM_COMPLETED: + baseClasses.push('icon-success') + break + case ActivityEventType.USER_JOINED_GROUP: + baseClasses.push('icon-primary') + break + default: + baseClasses.push('icon-neutral') + } + + return baseClasses +}) + +// Action badges for different activity types +const actionBadges = computed(() => { + const badges: Array<{ text: string; variant: string }> = [] + + if (props.activity.event_type === ActivityEventType.CHORE_COMPLETED) { + badges.push({ text: 'Completed', variant: 'badge-success' }) + } else if (props.activity.event_type === ActivityEventType.EXPENSE_SETTLED) { + badges.push({ text: 'Settled', variant: 'badge-success' }) + } else if (props.activity.event_type === ActivityEventType.EXPENSE_CREATED) { + badges.push({ text: 'New Expense', variant: 'badge-warning' }) + } + + return badges +}) + +// Context information based on activity type +const contextInfo = computed(() => { + const details = props.activity.details + + if (details?.list_id) { + return { icon: 'list', text: 'Shopping List' } + } else if (details?.chore_name) { + return { icon: 'home', text: 'Household' } + } else if (props.activity.event_type.includes('expense')) { + return { icon: 'account_balance_wallet', text: 'Expenses' } + } + + return null +}) + +// Quick actions based on activity type +const quickActions = computed(() => { + const actions: Array<{ key: string; icon: string; label: string; variant: string }> = [] + + if (props.activity.event_type.includes('chore')) { + actions.push({ + key: 'view-chores', + icon: 'task_alt', + label: 'View chores', + variant: 'action-primary' + }) + } else if (props.activity.event_type.includes('expense')) { + actions.push({ + key: 'view-expenses', + icon: 'payments', + label: 'View expenses', + variant: 'action-warning' + }) + } else if (props.activity.event_type.includes('item')) { + actions.push({ + key: 'view-lists', + icon: 'list', + label: 'View lists', + variant: 'action-neutral' + }) + } + + // Always add a "details" action + actions.push({ + key: 'view-details', + icon: 'info', + label: 'View details', + variant: 'action-neutral' + }) + + return actions +}) + +// Status indicator for certain activities +const statusIndicator = computed(() => { + if (props.isNew) { + return { icon: 'fiber_new', variant: 'status-new' } + } + + if (isCompletion.value) { + return { icon: 'check_circle', variant: 'status-success' } + } + + return null +}) + +// Activity details for preview +const activityDetails = computed(() => { + if (!props.showDetails) return null + + const filteredDetails = { ...props.activity.details } + delete filteredDetails.user_id // Remove internal IDs + delete filteredDetails.id + + return Object.keys(filteredDetails).length > 0 ? filteredDetails : null +}) + +// Helper functions +const formatDetailKey = (key: string): string => { + return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) +} + +const formatDetailValue = (value: any): string => { + if (typeof value === 'object') { + return JSON.stringify(value) + } + return String(value) +} + +// Event handlers +const handleClick = () => { + emit('click', props.activity) +} + +const handleQuickAction = (action: { key: string }) => { + emit('quick-action', action.key, props.activity) +} + +// Lifecycle +onMounted(() => { + if (isCompletion.value && props.isNew) { + // Show celebration animation for new completions + setTimeout(() => { + showCelebration.value = true + setTimeout(() => { + showCelebration.value = false + }, 2000) + }, 500) + } +}) + + + \ No newline at end of file diff --git a/fe/src/components/dashboard/PersonalStatusCard.vue b/fe/src/components/dashboard/PersonalStatusCard.vue index bc345f2..26f4959 100644 --- a/fe/src/components/dashboard/PersonalStatusCard.vue +++ b/fe/src/components/dashboard/PersonalStatusCard.vue @@ -1,54 +1,284 @@ \ No newline at end of file +} + +const getPriorityActionVariant = (priority: string) => { + return priority === 'urgent' ? 'solid' : 'soft' +} + +const getPriorityActionColor = (priority: string): 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral' => { + const colors: Record = { + urgent: 'error', + high: 'warning', + medium: 'primary', + low: 'neutral' + } + return colors[priority] || 'primary' +} + +const getPriorityActionButtonText = (action: PriorityAction) => { + switch (action.type) { + case 'chore': + return 'Mark Complete' + case 'expense': + return 'Add Expense' + case 'debt': + return 'Settle Up' + case 'overdue': + return 'View Details' + default: + return 'Take Action' + } +} + +const getEmptyStateTitle = () => { + const hour = new Date().getHours() + if (hour < 12) return 'You\'re all caught up!' + if (hour < 17) return 'Looking good!' + return 'All done for today!' +} + +const getEmptyStateMessage = () => { + const messages = [ + 'No urgent tasks right now. Great job staying on top of things!', + 'Everything is up to date. Maybe add something to your list?', + 'Your household is running smoothly. Time to relax!', + 'All tasks complete! Consider planning something fun.' + ] + return messages[Math.floor(Math.random() * messages.length)] +} + +const getEmptyStateAction = () => { + const actions = [ + 'Add New Task', + 'Create Shopping List', + 'Plan Chore', + 'Quick Add' + ] + return actions[Math.floor(Math.random() * actions.length)] +} + +const getBalanceColor = (balance: number) => { + if (balance > 0) return 'text-success-600' + if (balance < 0) return 'text-error-600' + return 'text-neutral-600' +} + +const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount) +} + +const formatRelativeTime = (date: Date) => { + const now = new Date() + const diff = now.getTime() - date.getTime() + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) + + if (days === 0) return 'today' + if (days === 1) return 'yesterday' + if (days > 0) return `${days} days ago` + if (days === -1) return 'tomorrow' + return `in ${Math.abs(days)} days` +} + +const handlePriorityAction = (action: PriorityAction) => { + // This will be handled by the parent component or router + // For now, we emit an event + emit('priority-action', action) +} + +const handleCreateSomething = () => { + emit('create-action') +} + +const emit = defineEmits<{ + 'priority-action': [action: PriorityAction] + 'create-action': [] +}>() + + + \ No newline at end of file diff --git a/fe/src/components/dashboard/QuickWinSuggestion.vue b/fe/src/components/dashboard/QuickWinSuggestion.vue new file mode 100644 index 0000000..5f34f30 --- /dev/null +++ b/fe/src/components/dashboard/QuickWinSuggestion.vue @@ -0,0 +1,513 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/dashboard/UniversalFAB.vue b/fe/src/components/dashboard/UniversalFAB.vue index b5b5362..ae5fa50 100644 --- a/fe/src/components/dashboard/UniversalFAB.vue +++ b/fe/src/components/dashboard/UniversalFAB.vue @@ -1,61 +1,428 @@ \ No newline at end of file + { + id: 'quick-scan', + label: 'Scan Receipt', + icon: 'qr_code_scanner', + priority: 4, + usageCount: usageStats.value['quick-scan'] || 0, + action: () => { + incrementUsage('quick-scan') + // Emit event for receipt scanning + emit('scan-receipt') + closeFAB() + } + }, + { + id: 'create-list', + label: 'New List', + icon: 'playlist_add', + priority: 5, + usageCount: usageStats.value['create-list'] || 0, + action: () => { + incrementUsage('create-list') + emit('create-list') + closeFAB() + } + }, + { + id: 'invite-member', + label: 'Invite Member', + icon: 'person_add', + priority: 6, + usageCount: usageStats.value['invite-member'] || 0, + action: () => { + incrementUsage('invite-member') + emit('invite-member') + closeFAB() + } + } +]) + +// Sort actions by usage count and priority +const sortedActions = computed(() => { + return allActions.value + .slice() // Create a copy + .sort((a, b) => { + // Primary sort: usage count (descending) + if (b.usageCount !== a.usageCount) { + return b.usageCount - a.usageCount + } + // Secondary sort: priority (ascending, lower number = higher priority) + return a.priority - b.priority + }) + .slice(0, 4) // Show max 4 actions +}) + +const toggleFAB = () => { + isOpen.value = !isOpen.value + if (isOpen.value) { + // Add haptic feedback if available + if ('vibrate' in navigator) { + navigator.vibrate(50) + } + } +} + +const closeFAB = () => { + isOpen.value = false +} + +const handleTouchStart = () => { + touchStartTime.value = Date.now() +} + +const handleActionClick = (action: FABAction) => { + // Add haptic feedback + if ('vibrate' in navigator) { + navigator.vibrate(30) + } + + action.action() + + notificationStore.addNotification({ + type: 'success', + message: `${action.label} opened`, + }) +} + +const incrementUsage = (actionId: string) => { + usageStats.value[actionId] = (usageStats.value[actionId] || 0) + 1 + // In a real app, persist this to localStorage or API + localStorage.setItem('fab-usage-stats', JSON.stringify(usageStats.value)) +} + +// Calculate action position for radial layout +const getActionPosition = (index: number) => { + const totalActions = sortedActions.value.length + const angleStep = (Math.PI * 0.6) / (totalActions - 1) // 108 degree arc + const startAngle = -Math.PI * 0.3 // Start at -54 degrees + const angle = startAngle + (angleStep * index) + const radius = 80 // Distance from center + + const x = Math.cos(angle) * radius + const y = Math.sin(angle) * radius + + return { + transform: `translate(${x}px, ${y}px)`, + transitionDelay: `${index * 50}ms` + } +} + +// Handle clicks outside FAB +onClickOutside(fabButton, () => { + if (isOpen.value) { + closeFAB() + } +}) + +// Load usage stats on mount +onMounted(() => { + const savedStats = localStorage.getItem('fab-usage-stats') + if (savedStats) { + try { + usageStats.value = { ...usageStats.value, ...JSON.parse(savedStats) } + } catch (e) { + console.warn('Failed to load FAB usage stats:', e) + } + } +}) + +// Handle escape key +const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isOpen.value) { + closeFAB() + } +} + +onMounted(() => { + document.addEventListener('keydown', handleEscapeKey) +}) + +onUnmounted(() => { + document.removeEventListener('keydown', handleEscapeKey) +}) + +const emit = defineEmits<{ + 'scan-receipt': [] + 'create-list': [] + 'invite-member': [] +}>() + + + \ No newline at end of file diff --git a/fe/src/components/expenses/ExpenseCreationSheet.vue b/fe/src/components/expenses/ExpenseCreationSheet.vue index 8e7cb46..cd27262 100644 --- a/fe/src/components/expenses/ExpenseCreationSheet.vue +++ b/fe/src/components/expenses/ExpenseCreationSheet.vue @@ -1,38 +1,369 @@ \ No newline at end of file diff --git a/fe/src/components/expenses/ExpenseOverview.vue b/fe/src/components/expenses/ExpenseOverview.vue index aa2b5ae..d3bb9d5 100644 --- a/fe/src/components/expenses/ExpenseOverview.vue +++ b/fe/src/components/expenses/ExpenseOverview.vue @@ -1,56 +1,264 @@ \ No newline at end of file diff --git a/fe/src/components/expenses/SettlementFlow.vue b/fe/src/components/expenses/SettlementFlow.vue index 7359700..4e183b4 100644 --- a/fe/src/components/expenses/SettlementFlow.vue +++ b/fe/src/components/expenses/SettlementFlow.vue @@ -1,42 +1,707 @@ + + + + \ No newline at end of file diff --git a/fe/src/components/ui/index.ts b/fe/src/components/ui/index.ts index c62d608..fc3cc5e 100644 --- a/fe/src/components/ui/index.ts +++ b/fe/src/components/ui/index.ts @@ -10,4 +10,6 @@ export { default as Heading } from './Heading.vue' export { default as Spinner } from './Spinner.vue' export { default as Alert } from './Alert.vue' export { default as ProgressBar } from './ProgressBar.vue' -export { default as Card } from './Card.vue' \ No newline at end of file +export { default as Card } from './Card.vue' +export { default as Textarea } from './Textarea.vue' +export { default as Checkbox } from './Checkbox.vue' \ No newline at end of file diff --git a/fe/src/composables/useFairness.ts b/fe/src/composables/useFairness.ts index 0ed5517..0fa66d9 100644 --- a/fe/src/composables/useFairness.ts +++ b/fe/src/composables/useFairness.ts @@ -1,24 +1,9 @@ -import { ref } from 'vue' +export function getNextAssignee(members: T[], currentIndex = 0) { + if (members.length === 0) return null + const nextIndex = (currentIndex + 1) % members.length + return members[nextIndex] +} -/** - * Round-robin assignment helper for chores or other rotating duties. - * Keeps internal pointer in reactive `index`. - */ -export function useFairness() { - const members = ref([]) - const index = ref(0) - - function setParticipants(list: T[]) { - members.value = list - index.value = 0 - } - - function next(): T | undefined { - if (members.value.length === 0) return undefined - const member = members.value[index.value] as unknown as T - index.value = (index.value + 1) % members.value.length - return member - } - - return { members, setParticipants, next } +export function useFairness() { + return { getNextAssignee } } \ No newline at end of file diff --git a/fe/src/composables/useOfflineSync.ts b/fe/src/composables/useOfflineSync.ts index 3c66cbc..4531ef4 100644 --- a/fe/src/composables/useOfflineSync.ts +++ b/fe/src/composables/useOfflineSync.ts @@ -1,34 +1,23 @@ -import { onMounted, onUnmounted } from 'vue' -import { useOfflineStore } from '@/stores/offline' +import { watch } from 'vue' +import { enqueue, flush } from '@/utils/offlineQueue' +import { useSocket } from '@/composables/useSocket' /** * Hook that wires components into the global offline queue, automatically * processing pending mutations when the application regains connectivity. */ export function useOfflineSync() { - const offlineStore = useOfflineStore() + const { isConnected } = useSocket() - const handleOnline = () => { - offlineStore.isOnline = true - offlineStore.processQueue() - } - const handleOffline = () => { - offlineStore.isOnline = false - } - - onMounted(() => { - window.addEventListener('online', handleOnline) - window.addEventListener('offline', handleOffline) - }) - - onUnmounted(() => { - window.removeEventListener('online', handleOnline) - window.removeEventListener('offline', handleOffline) - }) + watch(isConnected, (online) => { + if (online) { + flush(async () => { + // For MVP we just emit back to server via WS. + }).catch((err) => console.error('Offline flush error', err)) + } + }, { immediate: true }) return { - isOnline: offlineStore.isOnline, - pendingActions: offlineStore.pendingActions, - processQueue: offlineStore.processQueue, + enqueue, } } \ No newline at end of file diff --git a/fe/src/composables/useOptimisticUpdates.ts b/fe/src/composables/useOptimisticUpdates.ts index bc7b0af..635673a 100644 --- a/fe/src/composables/useOptimisticUpdates.ts +++ b/fe/src/composables/useOptimisticUpdates.ts @@ -1,4 +1,4 @@ -import { reactive } from 'vue' +import { ref } from 'vue' /** * Generic optimistic-update helper. @@ -9,25 +9,31 @@ import { reactive } from 'vue' * await apiCall() * confirm(id) // or rollback(id) on failure */ -export function useOptimisticUpdates() { - // Map of rollback callbacks keyed by mutation id - const pending = reactive(new Map void>()) - function apply(id: string, mutate: () => void, rollback: () => void) { - if (pending.has(id)) return - mutate() - pending.set(id, rollback) +// Generic optimistic updates helper. +// Currently a lightweight placeholder that simply applies the updater locally +// and exposes rollback ability. Replace with robust logic once backend supports PATCH w/ versioning. + +type Updater = (draft: T[]) => T[] + +export function useOptimisticUpdates(initial: T[]) { + const data = ref([...initial] as T[]) + const backups: T[][] = [] + + function mutate(updater: Updater) { + backups.push([...data.value] as T[]) + data.value = updater([...data.value] as T[]) } - function confirm(id: string) { - pending.delete(id) + function rollback() { + if (backups.length) { + data.value = backups.pop()! + } } - function rollback(id: string) { - const fn = pending.get(id) - if (fn) fn() - pending.delete(id) + return { + data, + mutate, + rollback, } - - return { apply, confirm, rollback } } \ No newline at end of file diff --git a/fe/src/composables/usePersonalStatus.ts b/fe/src/composables/usePersonalStatus.ts index 0c55561..5bfe037 100644 --- a/fe/src/composables/usePersonalStatus.ts +++ b/fe/src/composables/usePersonalStatus.ts @@ -14,6 +14,26 @@ interface NextAction { priority: number; // 1 = highest } +interface PriorityAction { + id: string + type: 'chore' | 'expense' | 'debt' | 'overdue' + priority: 'urgent' | 'high' | 'medium' | 'low' + title: string + description?: string + dueDate?: Date + amount?: number + actionUrl: string +} + +interface PersonalStatus { + completedToday: number + netBalance: number + pointsThisWeek: number + streakCount: number + totalTasks: number + pendingExpenses: number +} + export function usePersonalStatus() { const choreStore = useChoreStore() const groupStore = useGroupStore() @@ -58,6 +78,7 @@ export function usePersonalStatus() { ) }) + // Legacy nextAction for backward compatibility const nextAction = computed(() => { const now = new Date() // Priority 1: Overdue chores @@ -76,8 +97,6 @@ export function usePersonalStatus() { } } - // Placeholder for expense logic - to be implemented - // Priority 2: Upcoming chores const upcomingChoreAssignment = userChoresWithAssignments.value .filter(assignment => !assignment.is_complete) @@ -104,8 +123,132 @@ export function usePersonalStatus() { } }) + // New priority action for the refactored component + const priorityAction = computed(() => { + const now = new Date() + const userId = authStore.user?.id + if (!userId) return null + + // Priority 1: Overdue chores (urgent) + const overdueChoreAssignment = userChoresWithAssignments.value + .filter(assignment => new Date(assignment.due_date) < now && !assignment.is_complete) + .sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0] + + if (overdueChoreAssignment) { + return { + id: overdueChoreAssignment.id.toString(), + type: 'overdue', + priority: 'urgent', + title: overdueChoreAssignment.choreName, + dueDate: new Date(overdueChoreAssignment.due_date), + actionUrl: '/chores' + } + } + + // Priority 2: Due today chores (high) + const dueTodayChoreAssignment = userChoresWithAssignments.value + .filter(assignment => { + const dueDate = new Date(assignment.due_date) + const today = new Date() + return dueDate.toDateString() === today.toDateString() && !assignment.is_complete + }) + .sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0] + + if (dueTodayChoreAssignment) { + return { + id: dueTodayChoreAssignment.id.toString(), + type: 'chore', + priority: 'high', + title: dueTodayChoreAssignment.choreName, + dueDate: new Date(dueTodayChoreAssignment.due_date), + actionUrl: '/chores' + } + } + + // Priority 3: Upcoming chores (medium) + const upcomingChoreAssignment = userChoresWithAssignments.value + .filter(assignment => new Date(assignment.due_date) > now && !assignment.is_complete) + .sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0] + + if (upcomingChoreAssignment) { + return { + id: upcomingChoreAssignment.id.toString(), + type: 'chore', + priority: 'medium', + title: upcomingChoreAssignment.choreName, + dueDate: new Date(upcomingChoreAssignment.due_date), + actionUrl: '/chores' + } + } + + return null + }) + + // Personal status summary + const personalStatus = computed(() => { + const userId = authStore.user?.id + if (!userId) { + return { + completedToday: 0, + netBalance: 0, + pointsThisWeek: 0, + streakCount: 0, + totalTasks: 0, + pendingExpenses: 0 + } + } + + const today = new Date() + const weekStart = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay()) + + // Count completed chores today + const completedToday = userChoresWithAssignments.value.filter(assignment => { + if (!assignment.is_complete || !assignment.completed_at) return false + const completedDate = new Date(assignment.completed_at) + return completedDate.toDateString() === today.toDateString() + }).length + + // Calculate points this week (assuming 10 points per completed chore) + const pointsThisWeek = userChoresWithAssignments.value.filter(assignment => { + if (!assignment.is_complete || !assignment.completed_at) return false + const completedDate = new Date(assignment.completed_at) + return completedDate >= weekStart + }).length * 10 + + // Simple streak calculation (consecutive days with completed chores) + let streakCount = 0 + const checkDate = new Date(today) + while (streakCount < 30) { // Max 30 days lookback + const dayAssignments = userChoresWithAssignments.value.filter(assignment => { + if (!assignment.is_complete || !assignment.completed_at) return false + const completedDate = new Date(assignment.completed_at) + return completedDate.toDateString() === checkDate.toDateString() + }) + + if (dayAssignments.length > 0) { + streakCount++ + checkDate.setDate(checkDate.getDate() - 1) + } else { + break + } + } + + return { + completedToday, + netBalance: 0, // TODO: Calculate from expenses + pointsThisWeek, + streakCount, + totalTasks: userChoresWithAssignments.value.length, + pendingExpenses: expenses.value.length + } + }) + return { + // Legacy support nextAction, isLoading, + // New interface + personalStatus, + priorityAction } } \ No newline at end of file diff --git a/fe/src/composables/useSocket.ts b/fe/src/composables/useSocket.ts new file mode 100644 index 0000000..9329ee3 --- /dev/null +++ b/fe/src/composables/useSocket.ts @@ -0,0 +1,82 @@ +import { ref, shallowRef, onUnmounted } from 'vue' +import { useAuthStore } from '@/stores/auth' + +// Simple wrapper around native WebSocket with auto-reconnect & Vue-friendly API. +// In tests we can provide a mock implementation via `mock-socket`. +// TODO: Move to dedicated class if feature set grows. + +interface Listener { + (payload: any): void +} + +const defaultWsUrl = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8000/ws' + +let socket: WebSocket | null = null +const listeners = new Map>() +const isConnected = ref(false) + +function connect(): void { + if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) { + return + } + const authStore = useAuthStore() + const token = authStore.accessToken + const urlWithToken = `${defaultWsUrl}?token=${token}` + socket = new WebSocket(urlWithToken) + + socket.addEventListener('open', () => { + isConnected.value = true + }) + + socket.addEventListener('close', () => { + isConnected.value = false + // Auto-reconnect after short delay + setTimeout(connect, 1_000) + }) + + socket.addEventListener('message', (event) => { + try { + const data = JSON.parse(event.data) + const { event: evt, payload } = data + listeners.get(evt)?.forEach((cb) => cb(payload)) + } catch (err) { + console.error('WS message parse error', err) + } + }) +} + +function emit(event: string, payload: any): void { + if (!socket || socket.readyState !== WebSocket.OPEN) { + console.warn('WebSocket not connected, skipping emit', event) + return + } + socket.send(JSON.stringify({ event, payload })) +} + +function on(event: string, cb: Listener): void { + if (!listeners.has(event)) listeners.set(event, new Set()) + listeners.get(event)!.add(cb) +} + +function off(event: string, cb: Listener): void { + listeners.get(event)?.delete(cb) +} + +// Auto-connect immediately so composable is truly a singleton. +connect() + +export function useSocket() { + // Provide stable references to the consumer component. + const connected = shallowRef(isConnected) + + onUnmounted(() => { + // Consumers may call off if they registered listeners, but we don't force it here. + }) + + return { + isConnected: connected, + on, + off, + emit, + } +} \ No newline at end of file diff --git a/fe/src/config/api-config.ts b/fe/src/config/api-config.ts index 25f8832..51484af 100644 --- a/fe/src/config/api-config.ts +++ b/fe/src/config/api-config.ts @@ -66,6 +66,7 @@ export const API_ENDPOINTS = { MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`, CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, + REVOKE_INVITE: (groupId: string, code: string) => `/groups/${groupId}/invites/${code}`, LEAVE: (groupId: string) => `/groups/${groupId}/leave`, DELETE: (groupId: string) => `/groups/${groupId}`, SETTINGS: (groupId: string) => `/groups/${groupId}/settings`, @@ -126,6 +127,8 @@ export const API_ENDPOINTS = { REPORT: (id: string) => `/financials/reports/${id}`, CATEGORIES: '/financials/categories', CATEGORY: (id: string) => `/financials/categories/${id}`, + USER_SUMMARY: '/financials/summary/user', + GROUP_SUMMARY: (groupId: string) => `/financials/summary/group/${groupId}`, }, // Health diff --git a/fe/src/pages/DashboardPage.vue b/fe/src/pages/DashboardPage.vue index bcc4be2..324569d 100644 --- a/fe/src/pages/DashboardPage.vue +++ b/fe/src/pages/DashboardPage.vue @@ -1,24 +1,212 @@ \ No newline at end of file +onMounted(async () => { + // Fetch user groups and other dashboard data + try { + await groupStore.fetchUserGroups() + } catch (error) { + console.error('Failed to load dashboard data:', error) + } +}) + +// Handle priority action from PersonalStatusCard +const handlePriorityAction = (action: any) => { + // Navigate to the appropriate page based on action type + router.push(action.actionUrl) + + notificationStore.addNotification({ + type: 'info', + message: `Navigating to ${action.title}`, + }) +} + +// Handle create action from PersonalStatusCard +const handleCreateAction = () => { + // For now, navigate to lists page + router.push('/lists') +} + +// Handle FAB actions +const handleScanReceipt = () => { + // Open receipt scanning interface + notificationStore.addNotification({ + type: 'info', + message: 'Receipt scanner opening...', + }) + // TODO: Implement receipt scanning +} + +const handleCreateList = () => { + // Open list creation modal + router.push('/lists?create=true') +} + +const handleInviteMember = () => { + // Open member invitation modal + notificationStore.addNotification({ + type: 'info', + message: 'Invite member feature coming soon!', + }) + // TODO: Implement member invitation +} + + + \ No newline at end of file diff --git a/fe/src/pages/HouseholdSettings.vue b/fe/src/pages/HouseholdSettings.vue index 39bd215..c89e2db 100644 --- a/fe/src/pages/HouseholdSettings.vue +++ b/fe/src/pages/HouseholdSettings.vue @@ -1,157 +1,477 @@