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 @@ + + + + + + + + {{ getPriorityIcon() }} + + + + {{ chore.name }} + + {{ chore.description }} + + + + {{ getDueDateText() }} + + + star + {{ chore.points }} + + + + + + + + + + {{ getAssignedUser()?.name?.charAt(0) || getAssignedUser()?.full_name?.charAt(0) || '?' }} + + + + + + + + + schedule + {{ formatDuration(chore.estimatedDuration) }} + + + + + + + + + check_circle + + Mark Complete + + + + + + volunteer_activism + + Claim This Chore + + + + + + info + + {{ category === 'archive' ? 'View Details' : 'Details' }} + + + + + + + celebration + + Completed by {{ getCompletedByUser()?.name || getCompletedByUser()?.full_name || 'Someone' }} + + + {{ formatCompletionDate() }} + + + + + + + + + \ 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 @@ - - - - {{ chore?.name || 'Chore details' }} - - - - - + + + + + + + + + + + + {{ chore?.name || 'Chore Details' }} + + + {{ chore?.type === 'group' ? 'Group Task' : 'Personal Task' + }} + + {{ getStatusText(chore) }} + + + + + + + + + + + + + - - - General - - - Type - {{ chore.type === 'group' ? 'Group' : 'Personal' }} - - - Created by - {{ chore.creator?.name || chore.creator?.email || 'Unknown' }} - - - Due date - {{ formatDate(chore.next_due_date) }} - - - Frequency - {{ frequencyLabel }} - - + + + + + + + Complete + + + + + + Claim + + + + + + Start Timer + + + + + + {{ formatTimerDuration(currentTimerDuration) }} + + - - Description - {{ chore.description }} - + + + + + + Overview + + + {{ getFrequencyLabel(chore.frequency) }} + + + {{ getDueDateBadge(chore) }} + + + + + + + + + + + Due Date + {{ formatDate(chore?.next_due_date) }} + + + + + + + + Created by + {{ chore?.creator?.full_name || chore?.creator?.email || + 'Unknown' + }} + + + + + + + + Frequency + {{ getFrequencyDescription(chore) }} + + + + + + + + Assigned to + {{ getAssignedUserName(chore) }} + + + + + + + + + + + + Description + + + + + + + {{ chore.description }} + + + + + + + + + + + History & Progress + {{ getCompletionCount(chore) }} completions + + + + + + + + + + + + + + + {{ assignment.assigned_user?.full_name || + assignment.assigned_user?.email }} + + Due: {{ formatDate(assignment.due_date) }} + + Completed: {{ formatDate(assignment.completed_at) }} + + + + + + Show {{ chore.assignments.length - 3 }} more assignments + + + + + No completion history yet + + + + + + + + + + + + Sub-tasks + {{ chore.child_chores.length }} tasks + + + + + + + + + + + + + + {{ sub.name }} + + + + + + + + + + + + + + Scheduling + + + + + + + + + Next Due Date + {{ formatDate(chore?.next_due_date) }} + + + Last Completed + {{ formatDate(chore.last_completed_at) }} + + + Created + {{ formatDate(chore?.created_at) }} + + + Last Updated + {{ formatDate(chore?.updated_at) }} + + + + + - - Sub-Tasks - - - {{ sub.name }} - - + + \ 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 @@ - - - + + - + + + - - - - - {{ chore.name }} - - - - - Group - - - - - Overdue - - - - - Due Today - - - - - {{ dueInText }} - - + + + + {{ chore.name }} + + Group + Overdue + Due Today + - - - + {{ chore.description }} - - - {{ chore.subtext }} - - Total Time: {{ formatDuration(totalTime) }} - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + {{ isActiveTimer ? 'Stop Timer' : 'Start Timer' }} + + + + + + View History + + + + + + Edit + + + + + + Delete + + + + + + - + emit('stop-timer', chore, timeEntryId)" /> - + @@ -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 @@ + + + + + Chores + + + + add_task + + Add Chore + + + + + + + + + + + {{ category.icon }} + + + {{ category.title }} + + {{ category.count }} + + + + + + + + + + + + + + + + + + + + + + + + {{ getEmptyStateIcon(category.id) }} + + {{ getEmptyStateTitle(category.id) }} + {{ getEmptyStateDescription(category.id) }} + + {{ getEmptyStateAction(category.id) }} + + + + + + + + + + + + + + + + + + \ 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 @@ - - {{ t('inviteManager.title', 'Household Invites') }} + + + Share this code or QR with new members to let them join. - - - - {{ inviteCode ? t('inviteManager.regenerate', 'Regenerate Invite Code') : t('inviteManager.generate', - 'Generate Invite Code') }} - + + + + + + + + + + + {{ formattedInviteCode }} + + - - - - - {{ copied ? t('inviteManager.copied', 'Copied!') : t('inviteManager.copy', 'Copy') }} + + + + {{ copied ? 'Copied!' : 'Copy Code' }} + + + + Copy Deep Link + + + + Share Link + + + + + + + + Generate an invite to allow new members to join your household. + + + + + Generate Invite Code - - + + + - \ 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 @@ + + + + + + + + + + + {{ list.name }} + {{ lastActivity }} + + + + + + + + + + + + + + + Edit + + + + + + {{ list.archived_at ? 'Unarchive' : 'Archive' }} + + + + + + Delete + + + + + + + + + + + + + + {{ item.name }} + + + + +{{ list.items.length - 3 }} more + + + + No items in this list yet. + + + + + + + + + + + \ 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 @@ + + + + + + Your Lists + + + + + + {{ viewMode === 'grid' ? 'List' : 'Grid' }} + + + + + + New List + + + + + + + + + + + + {{ filter.label }} + {{ filter.count }} + + + + + + + + + + + Loading your lists... + + + + + + + + + + + + Pinned List + + + + + + + + + {{ currentFilter.label }} + {{ activeLists.length }} + + + + + + + + + + + + + + Start a New List + + + + + + + + + + {{ suggestion.title }} + {{ suggestion.description }} + + + + + + + + + + + + + {{ currentFilter.emptyTitle }} + {{ currentFilter.emptyDescription }} + + + + + Create Your First List + + + + + + + + + + + + \ 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 @@ + + + + + + + + + {{ currentStep + 1 }} of {{ steps.length }} + + + Skip Setup + + + + + + + + + + + + Welcome to Your Household Hub! + Let's get your household organized in under 60 seconds. + + + + + + + Set Up Your Profile + + + + + + + + + + + Add Photo + + + + + + + + Auto-imported from {{ oauthData.provider }} + + + + + + + + + + + + + + Join or Create a Household? + Choose how you'd like to get started with household management. + + + + + + + + + + + Join Existing Household + Someone already set up your household? Join with an + invite code. + + + + Quick setup + + + + Existing data preserved + + + + + + + + + + + + + + + + + Create New Household + Start fresh with a new household that you can invite + others to join. + + + + Full control + + + + Customize everything + + + + + + + + + + + + + + + + + Enter Invite Code + + + + + + Scan QR + + + Ask your household member for the invite code or QR code. + + + + + + + + + Name Your Household + + + Choose a name that everyone in your household will recognize. + + + + + + + + + Here's What You Can Do + A quick tour of features that will transform your household management. + + + + + + + + + + {{ feature.title }} + {{ feature.description }} + + Example: + {{ feature.example }} + + + + + + + + + + + + + + + + You're All Set! + Your household management hub is ready to use. + + + + + + + Recommended First Steps + + + + + {{ step.id }} + + {{ step.title }} + {{ step.time }} + + + + + + + + + + + + Go to Dashboard + + + + + + + Invite Household Members + + + + + + + + + + + + + + \ 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 @@ - - - - Add - - + + + + + + + + + + + + + + + + + + + + + {{ suggestion.name }} + + {{ suggestion.description }} + + + + {{ suggestion.frequency }} + + + + + + + + + + handleAdd()" class="add-button"> + + + + Add + + + + + + + + Smart + + + + + + + + + + + Quick suggestions + + {{ smartSuggestions.length }} + + + + + + + + + {{ suggestion.name }} + + {{ suggestion.description }} + + + + + + + + + + + + + + + {{ currentTip }} + + + + \ 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 @@ + + + + + + Scan Receipt + + + + + + + + + + Drag & drop or click to upload a receipt image (JPG, PNG, WEBP) + + + Max size: 5MB. Only images are supported. + + + + + + + Remove + + + + + + Extracting items from your receipt… + + + + Extracted Items + + + + {{ item }} + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + {{ item.name }} + Qty: {{ item.quantity }} + + + + {{ claimStatusText }} + {{ claimTimeText }} + + + + + + + + + + + + + Claim + + + + + + + Unclaim + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + {{ list.name }} + + {{ incompletedItems.length }} items + + {{ claimedByOthersCount }} claimed by others + + + + + + + + + + + + Scan Receipt + + + + + + + Create Expense + + + + + + + + + + + + + + + + + + Available to Shop ({{ unclaimedItems.length }}) + + + + + + + + + + + + + My Items ({{ myClaimedItems.length }}) + + + + + + + + + + + + + Claimed by Others ({{ othersClaimedItems.length }}) + + + + + + + + + + + + + Completed ({{ completedItems.length }}) + + + + + + + + + + + + {{ undoAction.message }} + + + Undo + + + + + + + + + + + + + + Choose Receipt Image + + + + + + Scanning receipt... + + + + Detected Items & Prices: + + + {{ detectedItem.name }} + ${{ detectedItem.price.toFixed(2) }} + + Apply + + + + + + + + + + + + You have {{ completedItemsWithPrices.length }} completed items with prices totaling + ${{ totalCompletedValue.toFixed(2) }}. + Would you like to create an expense for this shopping trip? + + + + + + + Equal Split + Split total amount equally among all group members + + + + + + + Item-Based Split + Each person pays for items they added to the list + + + + + + + Not Now + + + Create Expense + + + + + + + + + + \ 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 @@ - - Activity Feed - - Loading activity... + + + + + Recent Activity + + + {{ totalActivitiesText }} + + + + + + open_in_new + + - - {{ store.error }} + + + + + + + + + + + + + + Loading recent activity... + + + + + + + error_outline + {{ store.error }} + + Try Again + + + + + + + + + + {{ group.timeLabel }} + + {{ group.activities.length }} {{ group.activities.length === 1 ? + 'update' : 'updates' }} + + + + + + + + + + + + + + + + + + Loading more... + + + + + + + + + timeline + + No activity yet + + Activity from your household will appear here as members complete chores, add expenses, and + update lists. + + + + add + Get Started + + + + - - - - - - No recent activity. + + + + + people + {{ socialProofMessage }} + \ 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 @@ - - - - + + + + + {{ materialIcon }} + + + + + + + + + + + {{ userInitials }} + - - - - {{ formattedTimestamp }} - + + + + + + + + + + + {{ badge.text }} + + + + + + + + + {{ formattedTimestamp }} + + + + + {{ contextInfo.icon }} + {{ contextInfo.text }} + + + + + + + {{ action.icon }} + + + + + + + + + {{ formatDetailKey(String(key)) }}: + {{ formatDetailValue(value) }} + + + + + + + {{ statusIndicator.icon }} + + + + \ 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 @@ - - - Loading your status... - - - - - + + + + + {{ greeting }} + + + + local_fire_department + {{ personalStatus.streakCount }} - - {{ nextAction.title }} - {{ nextAction.subtitle }} + + + + + + + + + + {{ getPriorityIcon(priorityAction.type) }} + + + + + {{ getPriorityActionTitle(priorityAction) }} + + + {{ getPriorityActionDescription(priorityAction) }} + + + {{ getPriorityActionButtonText(priorityAction) }} + + + - - - {{ nextAction.cta }} - + + + + + check_circle + + + {{ getEmptyStateTitle() }} + + + {{ getEmptyStateMessage() }} + + + {{ getEmptyStateAction() }} + + + + + + + {{ personalStatus.completedToday }} + Completed + + + + {{ formatCurrency(personalStatus.netBalance) }} + + Balance + + + {{ personalStatus.pointsThisWeek }} + Points + - + \ 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 @@ + + + + + + Finding your next quick win... + + + + + + + + + + + {{ currentSuggestion.type }} + +{{ currentSuggestion.points }} points + + + + + + {{ currentSuggestion.title }} + {{ currentSuggestion.description }} + + + + + + {{ currentSuggestion.context }} + + + + + + + + + + + {{ currentSuggestion.actionText }} + + + + + + + Dismiss + + + + + + + + + + All caught up! + No immediate suggestions right now. Check back later for new opportunities. + + + + + + + \ 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 @@ - - - - - - - - - + + + + + + close + add + + + - - - - - - - {{ item.label }} - - - - - - + + + + + {{ action.icon }} + {{ action.label }} + + + + + + + + \ 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 @@ - - - {{ $t('expenseCreation.title', 'Add Expense') }} - - - + + + + + + + {{ isEditing ? 'Edit Expense' : 'Add New Expense' }} + + + {{ isEditing ? 'Update expense details' : 'Split costs fairly with your household' }} + + + + + + + + + + + + + + Scan Receipt + + + + + + Smart Fill + + + + + + Equal Split + + - - - - - {{ $t('common.cancel', 'Cancel') }} - {{ $t('common.save', 'Save') }} + + + + + + Step {{ currentStep }} of {{ totalSteps }} - + + + + + + + + + + + What did you spend on? + Add a clear description to help others understand the expense + + + + + + + + + + Common expenses + + + + {{ expense.name }} + + + + + + + + + + + + + + How much did it cost? + Enter the total amount you paid + + + + + + + + + {{ currencySymbol }} + + + + + + Currency + + {{ form.currency }} + + + + + + + {{ currency.code }} + {{ currency.symbol }} + + {{ currency.name }} + + + + + + + + + + + Recent amounts + + + {{ currencySymbol }}{{ amount }} + + + + + + + + + + + + + + Who paid for this? + Select the person who made the payment + + + + + + + + {{ user.email.charAt(0).toUpperCase() }} + + + {{ user.full_name || user.email }} + You + + + + + + + + + + + + + + + + + + How should this be split? + Choose how to divide the expense among household members + + + + + + + + + + + + + {{ splitType.title }} + {{ splitType.description }} + + + + + + + + + + Split preview + + {{ splitPreview }} + + + {{ getUserById(split.user_id)?.full_name || + getUserById(split.user_id)?.email }} + {{ currencySymbol }}{{ split.amount }} + + + + + + + + + + + + Customize split amounts + Adjust individual amounts as needed + + + + + + + {{ getUserById(split.user_id)?.email.charAt(0).toUpperCase() }} + + {{ getUserById(split.user_id)?.full_name || + getUserById(split.user_id)?.email }} + + + + + {{ currencySymbol }} + + + + + + + + + + {{ splitValidationMessage }} + + + + + + + + + + + + Back + + + + + + Continue + + + + + + + + + + {{ isEditing ? 'Update Expense' : 'Create Expense' }} + + + + + + + + + Scan Your Receipt + + + + + + + + + + Camera preview will appear here + + + + + + + + + Capture Receipt + + Position your receipt clearly in the camera view + + + + + \ 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 @@ - - {{ $t('expenseOverview.title', 'Expense Overview') }} - - - - {{ $t('expenseOverview.totalExpenses', 'Total Expenses') }} - {{ formatCurrency(totalExpenses, currency) }} - - - {{ $t('expenseOverview.myBalance', 'My Balance') }} - {{ - formatCurrency(myBalance, currency) }} - + + + + + + - - - {{ $t('expenseOverview.chartPlaceholder', 'Spending chart will appear here…') }} + + + Retry - - + + + + + Your Net Balance + + {{ formatCurrency(netBalance, currency) }} + + + {{ balanceStatusText }} + + + + Total Group Spending + + {{ formatCurrency(totalGroupSpending, currency) }} + + + + + + + + {{ tab.name }} + + + + + + + + {{ credit.user.name }} owes you + {{ formatCurrency(credit.amount, currency) + }} + + + You owe {{ debt.user.name }} + {{ formatCurrency(debt.amount, currency) }} + + + + You are all settled up. Good job! + + + + + + + Transaction history coming soon. + + + + + + + Debt graph visualization coming soon. + + + + + + \ 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 @@ - - - {{ $t('settlementFlow.title', 'Settle Expense') }} + + + + + + + + Settlement Details + Resolve outstanding balances + + + + + + - - {{ $t('settlementFlow.placeholder', 'Settlement flow coming soon…') - }} - - - {{ $t('common.close', 'Close') }} + + + + + + {{ index + 1 }} + + {{ step.label }} + + + + + + + + + + + Settlement Summary + + + + + {{ debt.fromUser.name }} + + {{ debt.toUser.name }} + + + {{ formatCurrency(debt.amount) }} + + + + + Total Settlement Amount: + {{ formatCurrency(totalAmount) }} + + + + + + + What this covers: + + + + {{ item.name }} + {{ formatDate(item.date) }} + + {{ formatCurrency(item.amount) }} + + + + + + + + How was this settled? + + + + + {{ method.name }} + {{ method.description }} + + + + + + + + + + + + + + + + Settlement Verification + + + + + Verification Required + + + Both parties need to confirm this settlement to prevent disputes. + + + + + + + Amount: + {{ formatCurrency(totalAmount) }} + + + Method: + {{ getSelectedPaymentMethodName() }} + + + Date: + {{ formatDate(new Date()) }} + + + + + + + + Add Receipt (Optional) + + + + + + + + + + + + + + + + + + + + + + + + + + + Dispute Settlement + + + + If you disagree with this settlement, please provide details about the issue: + + + + - \ No newline at end of file + \ No newline at end of file diff --git a/fe/src/components/global/ConflictResolutionDialog.vue b/fe/src/components/global/ConflictResolutionDialog.vue new file mode 100644 index 0000000..08c5f14 --- /dev/null +++ b/fe/src/components/global/ConflictResolutionDialog.vue @@ -0,0 +1,492 @@ + + + + + + + + + + + Conflict Detected + + {{ conflict?.action?.type }} conflict with server data. Choose how to resolve. + + + + + {{ conflict?.action }} Operation + {{ formatConflictTime }} + + + + + + + + + + + + Your Changes + + {{ formatLocalTime }} + + + + + + {{ change.field }} + {{ change.value }} + + + No specific changes detected + + + + + + + + Keep Your Changes + + + + + + + + + + Server Version + + {{ formatServerTime }} + + + + + + {{ change.field }} + {{ change.value }} + + + No server changes + + + + + + + + Use Server Version + + + + + + + + + + + + {{ showRawDiff ? 'Hide' : 'Show' }} Raw Data + + + + + + + + + Local JSON + {{ localJSON }} + + + Server JSON + {{ serverJSON }} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fe/src/components/list-detail/ItemsList.vue b/fe/src/components/list-detail/ItemsList.vue index 57a7217..1e0da62 100644 --- a/fe/src/components/list-detail/ItemsList.vue +++ b/fe/src/components/list-detail/ItemsList.vue @@ -13,7 +13,9 @@ @checkbox-change="(item, checked) => $emit('checkbox-change', item, checked)" @update-price="$emit('update-price', item)" @start-edit="$emit('start-edit', item)" @save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)" - @update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event" + @update-quantity="(item, qty) => $emit('update-quantity', item, qty)" + @mark-bought="handleMarkBought" @update:editName="item.editName = $event" + @update:editQuantity="item.editQuantity = $event" @update:editCategoryId="item.editCategoryId = $event" @update:priceInput="item.priceInput = $event" :list="list" /> @@ -36,14 +38,14 @@ {{ t('listDetailPage.buttons.add', 'Add') - }} + }} \ No newline at end of file diff --git a/fe/src/components/list-detail/PurchaseConfirmationDialog.vue b/fe/src/components/list-detail/PurchaseConfirmationDialog.vue new file mode 100644 index 0000000..170d3f1 --- /dev/null +++ b/fe/src/components/list-detail/PurchaseConfirmationDialog.vue @@ -0,0 +1,32 @@ + + + + Confirm Purchase + Did you buy these items? + + {{ i.name }} × {{ i.quantity }} + + + Cancel + Confirm + + + + + + \ No newline at end of file diff --git a/fe/src/components/ui/Button.vue b/fe/src/components/ui/Button.vue index c8b4af0..4992df8 100644 --- a/fe/src/components/ui/Button.vue +++ b/fe/src/components/ui/Button.vue @@ -1,6 +1,8 @@ - + + + @@ -9,15 +11,19 @@ import { computed } from 'vue' export interface ButtonProps { /** Visual variant */ - variant?: 'solid' | 'outline' | 'ghost' + variant?: 'solid' | 'outline' | 'ghost' | 'soft' /** Color token */ - color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral' + color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral' /** Size preset */ - size?: 'sm' | 'md' | 'lg' + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' /** HTML button type */ type?: 'button' | 'submit' | 'reset' /** Disabled state */ disabled?: boolean + /** Loading state */ + loading?: boolean + /** Full width */ + fullWidth?: boolean } const props = withDefaults(defineProps(), { @@ -26,48 +32,205 @@ const props = withDefaults(defineProps(), { size: 'md', type: 'button', disabled: false, + loading: false, + fullWidth: false, }) +const emit = defineEmits<{ + click: [event: MouseEvent] +}>() + +const handleClick = (event: MouseEvent) => { + if (!props.disabled && !props.loading) { + emit('click', event) + } +} + /** - * Tailwind class maps – keep in sync with design tokens in `tailwind.config.ts`. + * Tailwind class maps using new design tokens */ -const base = - 'inline-flex items-center justify-center font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 transition disabled:opacity-50 disabled:pointer-events-none' +const base = ` + inline-flex items-center justify-center gap-2 font-medium rounded-lg + focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 + transition-all duration-micro ease-micro + disabled:opacity-50 disabled:pointer-events-none + active:scale-[0.98] transform-gpu +`.trim().replace(/\s+/g, ' ') const sizeClasses: Record = { - sm: 'px-2.5 py-1.5 text-xs', - md: 'px-4 py-2 text-sm', - lg: 'px-6 py-3 text-base', + xs: 'px-2 py-1 text-xs h-6', + sm: 'px-3 py-1.5 text-sm h-8', + md: 'px-4 py-2 text-sm h-10', + lg: 'px-6 py-3 text-base h-12', + xl: 'px-8 py-4 text-lg h-14', } const colorMatrix: Record> = { solid: { - primary: 'bg-primary text-white hover:bg-primary/90', - secondary: 'bg-secondary text-white hover:bg-secondary/90', - success: 'bg-success text-white hover:bg-success/90', - warning: 'bg-warning text-white hover:bg-warning/90', - danger: 'bg-danger text-white hover:bg-danger/90', - neutral: 'bg-neutral text-white hover:bg-neutral/90', + primary: ` + bg-primary-500 text-white shadow-soft + hover:bg-primary-600 hover:shadow-medium + focus-visible:ring-primary-500/50 + active:bg-primary-700 + `.trim().replace(/\s+/g, ' '), + secondary: ` + bg-secondary text-white shadow-soft + hover:bg-secondary/90 hover:shadow-medium + focus-visible:ring-secondary/50 + `.trim().replace(/\s+/g, ' '), + success: ` + bg-success-500 text-white shadow-soft + hover:bg-success-600 hover:shadow-medium + focus-visible:ring-success-500/50 + active:bg-success-700 + `.trim().replace(/\s+/g, ' '), + warning: ` + bg-warning-500 text-white shadow-soft + hover:bg-warning-600 hover:shadow-medium + focus-visible:ring-warning-500/50 + active:bg-warning-700 + `.trim().replace(/\s+/g, ' '), + error: ` + bg-error-500 text-white shadow-soft + hover:bg-error-600 hover:shadow-medium + focus-visible:ring-error-500/50 + active:bg-error-700 + `.trim().replace(/\s+/g, ' '), + neutral: ` + bg-neutral-500 text-white shadow-soft + hover:bg-neutral-600 hover:shadow-medium + focus-visible:ring-neutral-500/50 + active:bg-neutral-700 + `.trim().replace(/\s+/g, ' '), }, outline: { - primary: 'border border-primary text-primary hover:bg-primary/10', - secondary: 'border border-secondary text-secondary hover:bg-secondary/10', - success: 'border border-success text-success hover:bg-success/10', - warning: 'border border-warning text-warning hover:bg-warning/10', - danger: 'border border-danger text-danger hover:bg-danger/10', - neutral: 'border border-neutral text-neutral hover:bg-neutral/10', + primary: ` + border border-primary-300 text-primary-600 bg-white + hover:bg-primary-50 hover:border-primary-400 + focus-visible:ring-primary-500/50 + active:bg-primary-100 + `.trim().replace(/\s+/g, ' '), + secondary: ` + border border-gray-300 text-secondary bg-white + hover:bg-gray-50 hover:border-gray-400 + focus-visible:ring-secondary/50 + `.trim().replace(/\s+/g, ' '), + success: ` + border border-success-300 text-success-600 bg-white + hover:bg-success-50 hover:border-success-400 + focus-visible:ring-success-500/50 + active:bg-success-100 + `.trim().replace(/\s+/g, ' '), + warning: ` + border border-warning-300 text-warning-600 bg-white + hover:bg-warning-50 hover:border-warning-400 + focus-visible:ring-warning-500/50 + active:bg-warning-100 + `.trim().replace(/\s+/g, ' '), + error: ` + border border-error-300 text-error-600 bg-white + hover:bg-error-50 hover:border-error-400 + focus-visible:ring-error-500/50 + active:bg-error-100 + `.trim().replace(/\s+/g, ' '), + neutral: ` + border border-neutral-300 text-neutral-600 bg-white + hover:bg-neutral-50 hover:border-neutral-400 + focus-visible:ring-neutral-500/50 + active:bg-neutral-100 + `.trim().replace(/\s+/g, ' '), }, ghost: { - primary: 'text-primary hover:bg-primary/10', - secondary: 'text-secondary hover:bg-secondary/10', - success: 'text-success hover:bg-success/10', - warning: 'text-warning hover:bg-warning/10', - danger: 'text-danger hover:bg-danger/10', - neutral: 'text-neutral hover:bg-neutral/10', + primary: ` + text-primary-600 bg-transparent + hover:bg-primary-50 + focus-visible:ring-primary-500/50 + active:bg-primary-100 + `.trim().replace(/\s+/g, ' '), + secondary: ` + text-secondary bg-transparent + hover:bg-gray-50 + focus-visible:ring-secondary/50 + `.trim().replace(/\s+/g, ' '), + success: ` + text-success-600 bg-transparent + hover:bg-success-50 + focus-visible:ring-success-500/50 + active:bg-success-100 + `.trim().replace(/\s+/g, ' '), + warning: ` + text-warning-600 bg-transparent + hover:bg-warning-50 + focus-visible:ring-warning-500/50 + active:bg-warning-100 + `.trim().replace(/\s+/g, ' '), + error: ` + text-error-600 bg-transparent + hover:bg-error-50 + focus-visible:ring-error-500/50 + active:bg-error-100 + `.trim().replace(/\s+/g, ' '), + neutral: ` + text-neutral-600 bg-transparent + hover:bg-neutral-50 + focus-visible:ring-neutral-500/50 + active:bg-neutral-100 + `.trim().replace(/\s+/g, ' '), + }, + soft: { + primary: ` + bg-primary-100 text-primary-700 + hover:bg-primary-200 + focus-visible:ring-primary-500/50 + active:bg-primary-300 + `.trim().replace(/\s+/g, ' '), + secondary: ` + bg-gray-100 text-secondary + hover:bg-gray-200 + focus-visible:ring-secondary/50 + `.trim().replace(/\s+/g, ' '), + success: ` + bg-success-100 text-success-700 + hover:bg-success-200 + focus-visible:ring-success-500/50 + active:bg-success-300 + `.trim().replace(/\s+/g, ' '), + warning: ` + bg-warning-100 text-warning-700 + hover:bg-warning-200 + focus-visible:ring-warning-500/50 + active:bg-warning-300 + `.trim().replace(/\s+/g, ' '), + error: ` + bg-error-100 text-error-700 + hover:bg-error-200 + focus-visible:ring-error-500/50 + active:bg-error-300 + `.trim().replace(/\s+/g, ' '), + neutral: ` + bg-neutral-100 text-neutral-700 + hover:bg-neutral-200 + focus-visible:ring-neutral-500/50 + active:bg-neutral-300 + `.trim().replace(/\s+/g, ' '), }, } const buttonClasses = computed(() => { - return [base, sizeClasses[props.size], colorMatrix[props.variant][props.color]].join(' ') + const classes = [ + base, + sizeClasses[props.size], + colorMatrix[props.variant][props.color], + ] + + if (props.fullWidth) { + classes.push('w-full') + } + + if (props.loading) { + classes.push('cursor-wait') + } + + return classes.join(' ') }) \ No newline at end of file diff --git a/fe/src/components/ui/Card.vue b/fe/src/components/ui/Card.vue index c17548e..6a55734 100644 --- a/fe/src/components/ui/Card.vue +++ b/fe/src/components/ui/Card.vue @@ -1,9 +1,142 @@ - + \ No newline at end of file +import { computed } from 'vue' + +export interface CardProps { + /** Visual variant */ + variant?: 'elevated' | 'outlined' | 'filled' | 'soft' + /** Color scheme */ + color?: 'neutral' | 'primary' | 'success' | 'warning' | 'error' + /** Padding size */ + padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl' + /** Border radius */ + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full' + /** Interactive state */ + interactive?: boolean + /** Disabled state */ + disabled?: boolean +} + +const props = withDefaults(defineProps(), { + variant: 'elevated', + color: 'neutral', + padding: 'md', + rounded: 'lg', + interactive: false, + disabled: false, +}) + +/** + * Tailwind class maps using new design tokens + */ +const baseClasses = ` + relative transition-all duration-micro ease-micro + disabled:opacity-50 disabled:pointer-events-none +`.trim().replace(/\s+/g, ' ') + +const variantClasses: Record = { + elevated: 'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-soft', + outlined: 'bg-transparent border-2 border-neutral-300 dark:border-neutral-700', + filled: 'bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700', + soft: 'bg-white/60 dark:bg-neutral-900/60 backdrop-blur-sm border border-white/20 dark:border-neutral-700/30 shadow-soft', +} + +const colorClasses: Record> = { + neutral: { + elevated: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800', + outlined: 'border-neutral-300 dark:border-neutral-700 text-neutral-900 dark:text-neutral-100', + filled: 'bg-neutral-50 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700', + soft: 'bg-white/60 dark:bg-neutral-900/60 border-white/20 dark:border-neutral-700/30', + }, + primary: { + elevated: 'bg-primary-50 dark:bg-primary-950 border-primary-200 dark:border-primary-800', + outlined: 'border-primary-300 dark:border-primary-700 text-primary-900 dark:text-primary-100', + filled: 'bg-primary-100 dark:bg-primary-900 border-primary-200 dark:border-primary-800', + soft: 'bg-primary-50/60 dark:bg-primary-950/60 border-primary-200/30 dark:border-primary-800/30', + }, + success: { + elevated: 'bg-success-50 dark:bg-success-950 border-success-200 dark:border-success-800', + outlined: 'border-success-300 dark:border-success-700 text-success-900 dark:text-success-100', + filled: 'bg-success-100 dark:bg-success-900 border-success-200 dark:border-success-800', + soft: 'bg-success-50/60 dark:bg-success-950/60 border-success-200/30 dark:border-success-800/30', + }, + warning: { + elevated: 'bg-warning-50 dark:bg-warning-950 border-warning-200 dark:border-warning-800', + outlined: 'border-warning-300 dark:border-warning-700 text-warning-900 dark:text-warning-100', + filled: 'bg-warning-100 dark:bg-warning-900 border-warning-200 dark:border-warning-800', + soft: 'bg-warning-50/60 dark:bg-warning-950/60 border-warning-200/30 dark:border-warning-800/30', + }, + error: { + elevated: 'bg-error-50 dark:bg-error-950 border-error-200 dark:border-error-800', + outlined: 'border-error-300 dark:border-error-700 text-error-900 dark:text-error-100', + filled: 'bg-error-100 dark:bg-error-900 border-error-200 dark:border-error-800', + soft: 'bg-error-50/60 dark:bg-error-950/60 border-error-200/30 dark:border-error-800/30', + }, +} + +const paddingClasses: Record = { + none: 'p-0', + sm: 'p-3', + md: 'p-4', + lg: 'p-6', + xl: 'p-8', +} + +const roundedClasses: Record = { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + xl: 'rounded-xl', + full: 'rounded-full', +} + +const interactiveClasses = ` + cursor-pointer hover:shadow-medium hover:scale-[1.02] active:scale-[0.98] + focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 + transform-gpu +`.trim().replace(/\s+/g, ' ') + +const cardClasses = computed(() => { + const classes = [baseClasses] + + // Base variant and color + const variantClass = variantClasses[props.variant] || variantClasses.elevated + const colorClass = colorClasses[props.color]?.[props.variant] || '' + + // Replace color-specific classes if color is not neutral + if (props.color !== 'neutral') { + classes.push(variantClass.replace(/bg-\S+|border-\S+|text-\S+/g, '')) + classes.push(colorClass) + } else { + classes.push(variantClass) + } + + // Padding + classes.push(paddingClasses[props.padding] || paddingClasses.md) + + // Rounded + classes.push(roundedClasses[props.rounded] || roundedClasses.lg) + + // Interactive + if (props.interactive && !props.disabled) { + classes.push(interactiveClasses) + } + + // Disabled + if (props.disabled) { + classes.push('opacity-50 pointer-events-none') + } + + return classes.join(' ') +}) + + + \ No newline at end of file diff --git a/fe/src/components/ui/Checkbox.vue b/fe/src/components/ui/Checkbox.vue new file mode 100644 index 0000000..f7f36b7 --- /dev/null +++ b/fe/src/components/ui/Checkbox.vue @@ -0,0 +1,153 @@ + + + + + + + + + + {{ label }} + + + + + + + \ No newline at end of file diff --git a/fe/src/components/ui/Input.vue b/fe/src/components/ui/Input.vue index e2f6923..ad87f4f 100644 --- a/fe/src/components/ui/Input.vue +++ b/fe/src/components/ui/Input.vue @@ -1,40 +1,396 @@ - - {{ label - }} - - {{ error }} + + + + {{ label }} + * + + + + + + + + {{ prefixIcon }} + + + + + + + + + + + + + + + + + + + + check_circle + + + + + error + + + + + {{ passwordVisible ? 'visibility_off' : 'visibility' + }} + + + + + + {{ suffixIcon }} + + + + + + + + + error + {{ error }} + + + {{ helpText }} + + + + + + + {{ characterCount }}/{{ maxLength }} + + - \ No newline at end of file + \ No newline at end of file diff --git a/fe/src/components/ui/Textarea.vue b/fe/src/components/ui/Textarea.vue new file mode 100644 index 0000000..2facf8c --- /dev/null +++ b/fe/src/components/ui/Textarea.vue @@ -0,0 +1,73 @@ + + + {{ label }} + + + + {{ description }} + + + + + + \ 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 @@ - - Dashboard - - - - + + + + + Dashboard + + + + + + + + + + + + + + + + + Recent Activity + + View All + + + + + + + + + Quick Actions + + + + + + + + + + + + + \ 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 @@ - - {{ group?.name || t('householdSettings.title', 'Household Settings') }} - - - - - {{ t('householdSettings.rename.title', 'Rename Household') }} - - - - - {{ t('shared.save', 'Save') }} - + + + + + + + + Household Settings + Manage your household's profile, members, and rules. + + - - + - - - {{ t('householdSettings.members.title', 'Members') }} - - - {{ member.email }} - - {{ t('householdSettings.members.remove', 'Remove') }} - - - - - + + + + + + + + + + + Household Profile + + + + + + Save Name + + + + - - + + + + + + Theme + + + Choose a primary color for your household. + + + + + + Save Theme + + + - - + + + + + + House Rules + + + Set clear expectations for everyone in the household. + + + + Save Rules + + + + + + + + + + Danger Zone + + + + + + Leave Household + You will lose access to all + shared data. + + Leave + + + + Delete Household + This will permanently delete + the household and all + its data for everyone. This action cannot be undone. + + Delete + + + + + + + + + + + + + Manage Members + + + + + + {{ member.email.charAt(0).toUpperCase() }} + + {{ member.email }} + {{ member.role }} + + + + Remove + + + + + + + + + + + + Invite New Members + + + + + + + + + + - {{ t('householdSettings.members.confirmTitle', 'Remove member?') }} - {{ confirmPrompt }} + {{ confirmDialog.title }} + {{ confirmDialog.message }} - {{ t('shared.cancel', - 'Cancel') }} - - - {{ t('shared.remove', 'Remove') }} + Cancel + + {{ confirmDialog.confirmText }} - + - \ No newline at end of file + \ No newline at end of file diff --git a/fe/src/pages/LoginPage.vue b/fe/src/pages/LoginPage.vue index 700522f..e650001 100644 --- a/fe/src/pages/LoginPage.vue +++ b/fe/src/pages/LoginPage.vue @@ -37,7 +37,7 @@ Email Magic Link - + {{ t('loginPage.signupLink') }} @@ -70,11 +70,12 @@ const email = ref(''); const password = ref(''); const isPwdVisible = ref(false); const loading = ref(false); +const isSheetOpen = ref(false); const formErrors = ref<{ email?: string; password?: string; general?: string }>({}); const sheet = ref>() function openSheet() { - sheet.value?.show() + isSheetOpen.value = true; } const isValidEmail = (val: string): boolean => { diff --git a/fe/src/sw.ts b/fe/src/sw.ts index b31d760..69765b1 100644 --- a/fe/src/sw.ts +++ b/fe/src/sw.ts @@ -17,107 +17,216 @@ import { createHandlerBoundToURL, } from 'workbox-precaching'; import { registerRoute, NavigationRoute } from 'workbox-routing'; -import { CacheFirst, NetworkFirst } from 'workbox-strategies'; +import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; import { ExpirationPlugin } from 'workbox-expiration'; import { CacheableResponsePlugin } from 'workbox-cacheable-response'; import { BackgroundSyncPlugin } from 'workbox-background-sync'; import type { WorkboxPlugin } from 'workbox-core/types'; -// Create a background sync plugin instance -const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions-queue', { - maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes) -}); - -// Initialize service worker -const initializeSW = async () => { - try { - await self.skipWaiting(); - clientsClaim(); - // console.log('Service Worker initialized successfully'); - } catch (error) { - console.error('Error during service worker initialization:', error); - } -}; - -// Use with precache injection -// vite-plugin-pwa will populate self.__WB_MANIFEST +// Precache all assets generated by Vite precacheAndRoute(self.__WB_MANIFEST); +// Clean up outdated caches cleanupOutdatedCaches(); -// Cache app shell and static assets with Cache First strategy +// Take control of all pages immediately +clientsClaim(); + +// Background sync plugin +const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', { + maxRetentionTime: 24 * 60, // Retry for 24 hours +}); + +// Cache API responses with NetworkFirst strategy +registerRoute( + ({ url }) => url.pathname.startsWith('/api/'), + new NetworkFirst({ + cacheName: 'api-cache', + networkTimeoutSeconds: 3, + plugins: [ + { + cacheKeyWillBeUsed: async ({ request }) => { + // Create cache keys that ignore auth headers for better cache hits + const url = new URL(request.url); + return url.pathname + url.search; + }, + }, + ], + }) +); + +// Cache static assets with CacheFirst strategy registerRoute( ({ request }) => - request.destination === 'style' || - request.destination === 'script' || request.destination === 'image' || - request.destination === 'font', + request.destination === 'font' || + request.destination === 'style', new CacheFirst({ cacheName: 'static-assets', plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], - }) as WorkboxPlugin, - new ExpirationPlugin({ - maxEntries: 60, - maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days - }) as WorkboxPlugin, + { + cacheWillUpdate: async ({ response }) => { + return response.status === 200 ? response : null; + }, + }, ], }) ); -// Cache API calls with Network First strategy and Background Sync for failed requests +// Cache navigation requests with StaleWhileRevalidate registerRoute( - ({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure - new NetworkFirst({ - cacheName: 'api-cache', - plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], - }) as WorkboxPlugin, - new ExpirationPlugin({ - maxEntries: 50, - maxAgeSeconds: 24 * 60 * 60, // 24 hours - }) as WorkboxPlugin, - bgSyncPlugin, // Add background sync plugin for failed requests - ], + ({ request }) => request.mode === 'navigate', + new StaleWhileRevalidate({ + cacheName: 'navigation-cache', }) ); -// Non-SSR fallbacks to index.html -// Production SSR fallbacks to offline.html (except for dev) -// Using environment variables defined in vite.config.ts and injected by Vite -declare const __PWA_FALLBACK_HTML__: string; -declare const __PWA_SERVICE_WORKER_REGEX__: string; +// Register background sync route for API calls +registerRoute( + ({ url }) => url.pathname.startsWith('/api/') && + !url.pathname.includes('/auth/'), + new NetworkFirst({ + cacheName: 'api-mutations', + plugins: [bgSyncPlugin], + }), + 'POST' +); -// Use fallback values if not defined -const PWA_FALLBACK_HTML = typeof __PWA_FALLBACK_HTML__ !== 'undefined' ? __PWA_FALLBACK_HTML__ : '/index.html'; -const PWA_SERVICE_WORKER_REGEX = typeof __PWA_SERVICE_WORKER_REGEX__ !== 'undefined' - ? new RegExp(__PWA_SERVICE_WORKER_REGEX__) - : /^(sw|workbox)-.*\.js$/; +registerRoute( + ({ url }) => url.pathname.startsWith('/api/') && + !url.pathname.includes('/auth/'), + new NetworkFirst({ + cacheName: 'api-mutations', + plugins: [bgSyncPlugin], + }), + 'PUT' +); -// Register navigation route for SPA fallback -if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) { - // Cache the index.html explicitly for navigation fallback - registerRoute( - ({ request }) => request.mode === 'navigate', - new NetworkFirst({ - cacheName: 'navigation-cache', - plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], - }) as WorkboxPlugin, - ], +registerRoute( + ({ url }) => url.pathname.startsWith('/api/') && + !url.pathname.includes('/auth/'), + new NetworkFirst({ + cacheName: 'api-mutations', + plugins: [bgSyncPlugin], + }), + 'DELETE' +); + +// Handle offline fallback +const OFFLINE_FALLBACK_URL = '/offline.html'; + +// Cache the offline fallback page +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open('offline-fallback') + .then(cache => cache.add(OFFLINE_FALLBACK_URL)) + ); +}); + +// Serve offline fallback when network fails +self.addEventListener('fetch', (event) => { + if (event.request.mode === 'navigate') { + event.respondWith( + (async () => { + try { + return await fetch(event.request); + } catch { + const cached = await caches.match(OFFLINE_FALLBACK_URL); + return cached ?? new Response('Offline', { status: 503, statusText: 'Offline' }); + } + })() + ); + } +}); + +// Listen for messages from main thread +self.addEventListener('message', (event) => { + if (event.data?.type === 'SYNC_BACKGROUND') { + // Trigger background sync + self.registration.sync.register('background-sync'); + } + + if (event.data?.type === 'CLEAR_CACHE') { + // Clear specific cache + const cacheName = event.data.cacheName; + caches.delete(cacheName); + } + + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +// Background sync event +self.addEventListener('sync', (event) => { + if (event.tag === 'background-sync') { + console.log('Background sync triggered'); + } +}); + +// Show notification when app is updated +self.addEventListener('controllerchange', () => { + // Notify main thread that SW has updated + self.clients.matchAll().then(clients => { + clients.forEach(client => { + client.postMessage({ + type: 'SW_UPDATED', + message: 'App updated! Refresh to see changes.' + }); + }); + }); +}); + +// Progressive enhancement: add install prompt handling +let deferredPrompt: any; + +self.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredPrompt = e; + + // Notify main thread that install is available + self.clients.matchAll().then(clients => { + clients.forEach(client => { + client.postMessage({ + type: 'INSTALL_AVAILABLE', + message: 'App can be installed' + }); + }); + }); +}); + +// Handle install event +self.addEventListener('install', (event) => { + console.log('Service Worker installing...'); + + // Cache critical resources immediately + event.waitUntil( + caches.open('critical-cache-v1').then(cache => { + return cache.addAll([ + '/', + '/manifest.json', + '/offline.html' + ]); }) ); +}); - // Register fallback for offline navigation - registerRoute( - new NavigationRoute(createHandlerBoundToURL(PWA_FALLBACK_HTML), { - denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/], - }), +// Handle activation +self.addEventListener('activate', (event) => { + console.log('Service Worker activated'); + + // Clean up old caches + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames + .filter(cacheName => cacheName.startsWith('critical-cache-') && + cacheName !== 'critical-cache-v1') + .map(cacheName => caches.delete(cacheName)) + ); + }) ); -} +}); -// Initialize the service worker -initializeSW(); \ No newline at end of file +// Export for TypeScript +export default null; \ No newline at end of file diff --git a/fe/src/types/list.ts b/fe/src/types/list.ts index 3d912c1..2beb291 100644 --- a/fe/src/types/list.ts +++ b/fe/src/types/list.ts @@ -5,11 +5,14 @@ export interface List { id: number name: string description?: string | null + type: 'shopping' | 'todo' | 'custom' is_complete: boolean group_id?: number | null items: Item[] version: number updated_at: string + created_at: string + archived_at?: string | null expenses?: Expense[] } diff --git a/fe/src/utils/analytics.ts b/fe/src/utils/analytics.ts new file mode 100644 index 0000000..41be257 --- /dev/null +++ b/fe/src/utils/analytics.ts @@ -0,0 +1,15 @@ +export type AnalyticsPayload = Record + +/** + * Lightweight analytics stub. In production this should forward to a real + * provider (PostHog, Plausible, etc.). For now we just log for visibility and + * keep a typed API so that calls compile even when the real service is absent. + */ +export function track(event: string, payload: AnalyticsPayload = {}) { + if (import.meta.env.MODE === 'production') { + // TODO: connect real analytics provider + } else { + // eslint-disable-next-line no-console + console.debug(`[analytics] ${event}`, payload) + } +} \ No newline at end of file diff --git a/fe/src/utils/offlineQueue.ts b/fe/src/utils/offlineQueue.ts new file mode 100644 index 0000000..912a2a8 --- /dev/null +++ b/fe/src/utils/offlineQueue.ts @@ -0,0 +1,45 @@ +import { get, set, del } from 'idb-keyval' + +export type OfflineOperation = { + id: string + type: string + payload: any + timestamp: number +} + +const STORE_KEY = 'offline-queue' + +async function readAll(): Promise { + const data = await get(STORE_KEY) + return data || [] +} + +export async function enqueue(op: Omit) { + const current = await readAll() + current.push({ ...op, id: crypto.randomUUID(), timestamp: Date.now() }) + await set(STORE_KEY, current) +} + +export async function flush(handler: (op: OfflineOperation) => Promise) { + const queue = await readAll() + for (const op of queue) { + try { + await handler(op) + await remove(op.id) + } catch (err) { + console.error('Failed to process offline op', op, err) + // stop flush on first failure to avoid loop + break + } + } +} + +export async function remove(id: string) { + const current = await readAll() + const updated = current.filter((o) => o.id !== id) + await set(STORE_KEY, updated) +} + +export async function clear() { + await del(STORE_KEY) +} \ No newline at end of file diff --git a/fe/tailwind.config.ts b/fe/tailwind.config.ts index 0fde93d..c00f10d 100644 --- a/fe/tailwind.config.ts +++ b/fe/tailwind.config.ts @@ -13,29 +13,225 @@ const config: Config = { theme: { extend: { colors: { - primary: '#FF7B54', + // Design System Colors from refactor plan + primary: { + DEFAULT: '#3b82f6', + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', // Main primary color + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + 950: '#172554', + }, + success: { + 50: '#ecfdf5', + 100: '#d1fae5', + 200: '#a7f3d0', + 300: '#6ee7b7', + 400: '#34d399', + 500: '#10b981', // Main success color + 600: '#059669', + 700: '#047857', + 800: '#065f46', + 900: '#064e3b', + 950: '#022c22', + }, + warning: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', // Main warning color + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + 950: '#4c2209', + }, + error: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', // Main error color + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + 950: '#661010', + }, + neutral: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', // Main neutral color + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + }, + // Legacy colors for backward compatibility secondary: '#FFB26B', accent: '#FFD56B', info: '#54C7FF', - success: '#A0E7A0', - warning: '#FFD56B', danger: '#FF4D4D', dark: '#393E46', light: '#FFF8F0', black: '#000000', - neutral: '#64748B', + // Additional color aliases for design tokens + 'surface-primary': '#ffffff', // Light surface + 'surface-secondary': '#f8fafc', + 'surface-elevated': '#ffffff', // elevated surface same as primary for now + 'surface-hover': '#f1f5f9', // neutral-100, subtle hover for surfaces + 'surface-soft': '#f8fafc', // neutral-50, extra soft surface + // Border colors + 'border-secondary': '#e2e8f0', // neutral-200 + 'border-subtle': '#cbd5e1', // neutral-300 + // Text colors + 'text-primary': '#0f172a', // neutral-900 + 'text-secondary': '#475569', // neutral-600 + 'text-tertiary': '#94a3b8', // neutral-400 + // Border primary alias (light) + 'border-primary': '#bfdbfe', // primary-200 }, spacing: { - 1: '4px', - 4: '16px', - 6: '24px', - 8: '32px', - 12: '48px', - 16: '64px', + // Design system spacing (base unit: 4px) + '1': '4px', + '2': '8px', + '3': '12px', + '4': '16px', // Component spacing + '5': '20px', + '6': '24px', // Component spacing + '7': '28px', + '8': '32px', // Component spacing + '9': '36px', + '10': '40px', + '11': '44px', + '12': '48px', // Section spacing + '14': '56px', + '16': '64px', // Section spacing + '20': '80px', + '24': '96px', + '28': '112px', + '32': '128px', + // Alias tokens for design system (used as spacing-[size]) + 'spacing-xs': '4px', // 1 + 'spacing-sm': '8px', // 2 + 'spacing-md': '16px', // 4 + 'spacing-lg': '24px', // 6 + 'spacing-xl': '32px', // 8 }, fontFamily: { + // Typography system + sans: [ + 'Inter', + '-apple-system', + 'BlinkMacSystemFont', + 'San Francisco', + 'Segoe UI', + 'Roboto', + 'Helvetica Neue', + 'Arial', + 'sans-serif' + ], + mono: [ + 'JetBrains Mono', + 'SFMono-Regular', + 'Monaco', + 'Consolas', + 'Liberation Mono', + 'Courier New', + 'monospace' + ], + // Legacy hand: ['"Patrick Hand"', 'cursive'], - sans: ['Inter', 'ui-sans-serif', 'system-ui'], + }, + fontSize: { + 'xs': ['0.75rem', { lineHeight: '1rem' }], + 'sm': ['0.875rem', { lineHeight: '1.25rem' }], + 'base': ['1rem', { lineHeight: '1.5rem' }], + 'lg': ['1.125rem', { lineHeight: '1.75rem' }], + 'xl': ['1.25rem', { lineHeight: '1.75rem' }], + '2xl': ['1.5rem', { lineHeight: '2rem' }], + '3xl': ['1.875rem', { lineHeight: '2.25rem' }], + '4xl': ['2.25rem', { lineHeight: '2.5rem' }], + '5xl': ['3rem', { lineHeight: '1' }], + '6xl': ['3.75rem', { lineHeight: '1' }], + // Alias font-size tokens for design system + 'label-xs': ['0.75rem', { lineHeight: '1rem' }], + 'label-sm': ['0.875rem', { lineHeight: '1.25rem' }], + 'body-sm': ['0.875rem', { lineHeight: '1.25rem' }], + 'heading-lg': ['1.5rem', { lineHeight: '2rem' }], + }, + transitionDuration: { + // Motion system + 'micro': '150ms', // Micro-interactions + 'page': '300ms', // Page transitions + 'slow': '500ms', // Loading states + // Medium alias (used in components) + 'medium': '300ms', + }, + transitionTimingFunction: { + // Custom timing functions for design system + 'micro': 'cubic-bezier(0.4, 0, 0.2, 1)', // alias for ease-out micro interactions + 'medium': 'cubic-bezier(0.4, 0, 0.2, 1)', // same curve, longer duration + 'page': 'cubic-bezier(0.4, 0, 0.6, 1)', // ease-in-out for page transitions + }, + keyframes: { + // Skeleton loading animation + skeleton: { + '0%': { opacity: '1' }, + '50%': { opacity: '0.4' }, + '100%': { opacity: '1' }, + }, + // Fade in animation + fadeIn: { + '0%': { opacity: '0', transform: 'translateY(10px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + // Scale in animation + scaleIn: { + '0%': { opacity: '0', transform: 'scale(0.95)' }, + '100%': { opacity: '1', transform: 'scale(1)' }, + }, + }, + animation: { + 'skeleton': 'skeleton 1.5s ease-in-out infinite', + 'fade-in': 'fadeIn 300ms ease-out', + 'scale-in': 'scaleIn 150ms ease-out', + }, + backdropBlur: { + xs: '2px', + }, + boxShadow: { + 'soft': '0 2px 8px rgba(0, 0, 0, 0.04)', + 'medium': '0 4px 12px rgba(0, 0, 0, 0.08)', + 'strong': '0 8px 24px rgba(0, 0, 0, 0.12)', + 'floating': '0 12px 32px rgba(0, 0, 0, 0.16)', + // Alias box-shadow tokens matching naming convention in components + 'elevation-soft': '0 2px 8px rgba(0, 0, 0, 0.04)', + 'elevation-medium': '0 4px 12px rgba(0, 0, 0, 0.08)', + 'elevation-strong': '0 8px 24px rgba(0, 0, 0, 0.12)', + 'elevation-high': '0 16px 48px rgba(0,0,0,0.18)', + }, + borderRadius: { + // Radius alias tokens + 'radius-sm': '4px', + 'radius-md': '8px', + 'radius-lg': '12px', + 'radius-full': '9999px', + }, + zIndex: { + 60: '60', }, }, }, diff --git a/fe/tsconfig.app.json b/fe/tsconfig.app.json index 913b8f2..054e6f0 100644 --- a/fe/tsconfig.app.json +++ b/fe/tsconfig.app.json @@ -1,12 +1,25 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], - "exclude": ["src/**/__tests__/*"], + "include": [ + "env.d.ts", + "src/**/*", + "src/**/*.vue" + ], + "exclude": [ + "src/**/__tests__/*" + ], "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "paths": { - "@/*": ["./src/*"] - } + "@/*": [ + "./src/*" + ] + }, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext", + "WebWorker" + ] } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index eac072c..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "doe", - "lockfileVersion": 3, - "requires": true, - "packages": {} -}
+ {{ chore.description }} +
{{ chore.description }} -
{{ chore.description }}
No completion history yet
+
{{ getEmptyStateDescription(category.id) }}
Share this code or QR with new members to let them join.
Generate an invite to allow new members to join your household.
{{ lastActivity }}
+ No items in this list yet. +
{{ suggestion.description }}
{{ currentFilter.emptyDescription }}
Let's get your household organized in under 60 seconds.
Choose how you'd like to get started with household management.
Someone already set up your household? Join with an + invite code.
Start fresh with a new household that you can invite + others to join.
Ask your household member for the invite code or QR code.
Choose a name that everyone in your household will recognize.
A quick tour of features that will transform your household management.
{{ feature.description }}
Your household management hub is ready to use.
Max size: 5MB. Only images are supported.
Extracting items from your receipt…
Qty: {{ item.quantity }}
+ You have {{ completedItemsWithPrices.length }} completed items with prices totaling + ${{ totalCompletedValue.toFixed(2) }}. + Would you like to create an expense for this shopping trip? +
Split total amount equally among all group members
Each person pays for items they added to the list
Loading recent activity...
{{ store.error }}
+ Activity from your household will appear here as members complete chores, add expenses, and + update lists. +
{{ socialProofMessage }}
- {{ formattedTimestamp }} -
{{ nextAction.title }}
{{ nextAction.subtitle }}
+ {{ getPriorityActionDescription(priorityAction) }} +
+ {{ getEmptyStateMessage() }} +
{{ currentSuggestion.description }}
No immediate suggestions right now. Check back later for new opportunities.
+ {{ isEditing ? 'Update expense details' : 'Split costs fairly with your household' }} +
Add a clear description to help others understand the expense +
Enter the total amount you paid
Select the person who made the payment
Choose how to divide the expense among household members
{{ splitType.description }}
{{ splitPreview }}
Adjust individual amounts as needed
Camera preview will appear here
Position your receipt clearly in the camera view
+ {{ balanceStatusText }} +
+ {{ formatCurrency(totalGroupSpending, currency) }} +
You are all settled up. Good job!
Transaction history coming soon.
Debt graph visualization coming soon.
Resolve outstanding balances
{{ $t('settlementFlow.placeholder', 'Settlement flow coming soon…') - }}
+ Both parties need to confirm this settlement to prevent disputes. +
+ If you disagree with this settlement, please provide details about the issue: +
+ {{ conflict?.action?.type }} conflict with server data. Choose how to resolve. +
{{ localJSON }}
{{ serverJSON }}
Did you buy these items?
{{ error }}
{{ description }}
Manage your household's profile, members, and rules.
Choose a primary color for your household.
Set clear expectations for everyone in the household.
You will lose access to all + shared data.
This will permanently delete + the household and all + its data for everyone. This action cannot be undone.
{{ confirmPrompt }}
{{ confirmDialog.message }}