feat: Introduce activity tracking and management features

This commit adds new functionality for tracking user activities within the application, including:

- Implementation of a new activity service to fetch and manage group activities.
- Creation of a dedicated activity store to handle state management for activities.
- Introduction of new API endpoints for retrieving paginated activity data.
- Enhancements to the UI with new components for displaying activity feeds and items.
- Refactoring of existing components to utilize the new activity features, improving user engagement and interaction.

These changes aim to enhance the application's activity tracking capabilities and provide users with a comprehensive view of their interactions.
This commit is contained in:
mohamad 2025-06-28 19:14:51 +02:00
parent 8b181087c3
commit 229f6b7b1c
77 changed files with 4401 additions and 4694 deletions

View File

@ -0,0 +1,32 @@
"""add claimed_by_user fields
Revision ID: 52caaf813ef4
Revises: 7f73c3196b99
Create Date: 2025-06-28 18:28:08.783756
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '52caaf813ef4'
down_revision: Union[str, None] = '7f73c3196b99'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,135 @@
"""add claim fields to item
Revision ID: 7f73c3196b99
Revises: d5f8a2e4c7b9
Create Date: 2025-06-28 16:11:49.500494
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '7f73c3196b99'
down_revision: Union[str, None] = 'd5f8a2e4c7b9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
op.drop_table('apscheduler_jobs')
op.alter_column('categories', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('chore_assignments', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('chores', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('expense_splits', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('expenses', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('groups', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.add_column('items', sa.Column('claimed_by_user_id', sa.Integer(), nullable=True))
op.add_column('items', sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('items', sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True))
op.alter_column('items', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.create_foreign_key(None, 'items', 'users', ['claimed_by_user_id'], ['id'])
op.alter_column('lists', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('recurrence_patterns', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('time_entries', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('users', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('time_entries', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('recurrence_patterns', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('lists', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.drop_constraint(None, 'items', type_='foreignkey')
op.alter_column('items', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.drop_column('items', 'claimed_at')
op.drop_column('items', 'completed_at')
op.drop_column('items', 'claimed_by_user_id')
op.alter_column('groups', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('expenses', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('expense_splits', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('chores', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('chore_assignments', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('categories', 'is_deleted',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.create_table('apscheduler_jobs',
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
)
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
# ### end Alembic commands ###

View File

@ -0,0 +1,60 @@
from fastapi import APIRouter, HTTPException, status, Depends, Request, Query
from pydantic import BaseModel, EmailStr
from app.auth import get_user_manager, AuthenticationBackendWithRefresh, bearer_transport, get_jwt_strategy, get_refresh_jwt_strategy
from fastapi.responses import JSONResponse
router = APIRouter()
class MagicLinkRequest(BaseModel):
email: EmailStr
# Path: POST /api/v1/auth/magic-link
@router.post('/magic-link', status_code=status.HTTP_200_OK)
async def send_magic_link(payload: MagicLinkRequest, request: Request, user_manager=Depends(get_user_manager)):
"""Generate a one-time magic-link token and *log* it for now.
In production this should email the user. For Phase-4 backend milestone we
simply issue the verification token and return it in the response so the
frontend can test the flow without an email provider.
"""
# Ensure user exists (create guest if not)
user = await user_manager.get_by_email(payload.email)
if user is None:
# Auto-register guest account (inactive until verified)
user_in = {
'email': payload.email,
'password': '', # FastAPI Users requires but we will bypass login
}
# Using UserCreate model generically relies on fastapi-users internals
try:
user = await user_manager.create(user_in, safe=True, request=request)
except Exception:
raise HTTPException(status_code=400, detail='Unable to create account')
verification_token = await user_manager.generate_verification_token(user)
# TODO: send email instead of returning token
return {'detail': 'Magic link generated (token returned for dev)', 'token': verification_token}
@router.get('/magic-link/verify')
async def verify_magic_link(token: str = Query(...), request: Request = None, user_manager=Depends(get_user_manager)):
"""Verify incoming token and issue standard JWT + refresh tokens."""
try:
user = await user_manager.verify(token, request)
except Exception:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid or expired token')
# Issue JWT + refresh using existing backend routines
access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user)
refresh_token = await refresh_strategy.write_token(user)
return JSONResponse(
{
'access_token': access_token,
'refresh_token': refresh_token,
'token_type': 'bearer',
}
)

View File

@ -11,9 +11,18 @@ from app.api.v1.endpoints import financials
from app.api.v1.endpoints import chores
from app.api.v1.endpoints import history
from app.api.v1.endpoints import categories
from app.api.v1.endpoints import users
from app.api.auth import oauth, guest, jwt
# WebSocket support
from app.api.v1.endpoints import websocket as ws_endpoint
from app.api.v1.endpoints import users
# Magic link endpoints
from app.api.auth import magic_link
# New activity router
from app.api.v1.endpoints import activity
api_router_v1 = APIRouter()
api_router_v1.include_router(health.router)
@ -31,3 +40,12 @@ api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(guest.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(jwt.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
# Magic link
api_router_v1.include_router(magic_link.router, prefix="/auth", tags=["Auth"])
# New activity router
api_router_v1.include_router(activity.router, tags=["Activity"])
# WebSockets (no prefix, standalone path)
api_router_v1.include_router(ws_endpoint.router, tags=["WebSockets"])

View File

@ -0,0 +1,33 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.crud import activity as crud_activity
from app.schemas.activity import PaginatedActivityResponse
router = APIRouter()
@router.get(
"/groups/{group_id}/activity",
response_model=PaginatedActivityResponse,
summary="Get group activity feed",
tags=["Activity"],
)
async def get_group_activity(
group_id: int,
limit: int = Query(20, ge=1, le=100),
cursor: Optional[int] = Query(None, description="Unix timestamp for pagination"),
db: AsyncSession = Depends(get_session),
):
"""
Retrieves a paginated feed of recent activities within a group.
"""
activities = await crud_activity.get_group_activity(db, group_id=group_id, limit=limit, cursor=cursor)
next_cursor = None
if activities and len(activities) == limit:
next_cursor = int(activities[-1].timestamp.timestamp())
return PaginatedActivityResponse(items=activities, cursor=next_cursor)

View File

@ -1,4 +1,3 @@
import logging
from typing import List as PyList, Optional
@ -13,6 +12,7 @@ from app.crud import item as crud_item
from app.crud import list as crud_list
from app.core.exceptions import ItemNotFoundError, ListPermissionError, ConflictError
from app.auth import current_active_user
from app.core.redis import broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter()
@ -163,3 +163,84 @@ async def delete_item(
await crud_item.delete_item(db=db, item_db=item_db)
logger.info(f"Item {item_id} (version {item_db.version}) deleted successfully by user {user_email}.")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/items/{item_id}/claim",
response_model=ItemPublic,
summary="Claim an Item",
tags=["Items"],
responses={
status.HTTP_409_CONFLICT: {"description": "Item is already claimed or completed"},
status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to claim this item"}
}
)
async def claim_item(
item_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Marks an item as claimed by the current user."""
item_db = await get_item_and_verify_access(item_id, db, current_user)
if item_db.list.group_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Items on personal lists cannot be claimed.")
try:
updated_item = await crud_item.claim_item(db, item=item_db, user_id=current_user.id)
# Broadcast the event
event = {
"type": "item_claimed",
"payload": {
"list_id": updated_item.list_id,
"item_id": updated_item.id,
"claimed_by": {
"id": current_user.id,
"name": current_user.name
},
"claimed_at": updated_item.claimed_at.isoformat(),
"version": updated_item.version
}
}
await broadcast_event(f"list_{updated_item.list_id}", event)
return updated_item
except ConflictError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@router.delete(
"/items/{item_id}/claim",
response_model=ItemPublic,
summary="Unclaim an Item",
tags=["Items"],
responses={
status.HTTP_403_FORBIDDEN: {"description": "User cannot unclaim an item they did not claim"}
}
)
async def unclaim_item(
item_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Removes the current user's claim from an item."""
item_db = await get_item_and_verify_access(item_id, db, current_user)
if item_db.claimed_by_user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only unclaim items that you have claimed.")
updated_item = await crud_item.unclaim_item(db, item=item_db)
# Broadcast the event
event = {
"type": "item_unclaimed",
"payload": {
"list_id": updated_item.list_id,
"item_id": updated_item.id,
"version": updated_item.version
}
}
await broadcast_event(f"list_{updated_item.list_id}", event)
return updated_item

View File

@ -0,0 +1,77 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_jwt_strategy, get_user_from_token
from app.database import get_transactional_session
from app.crud import list as crud_list
from app.core.redis import subscribe_to_channel, unsubscribe_from_channel
from app.models import User
import asyncio
router = APIRouter()
async def _verify_jwt(token: str):
"""Return user_id from JWT or None if invalid/expired."""
strategy = get_jwt_strategy()
try:
# FastAPI Users' JWTStrategy.read_token returns the user ID encoded in the token
user_id = await strategy.read_token(token)
return user_id
except Exception: # pragma: no cover any decoding/expiration error
return None
@router.websocket("/ws/lists/{list_id}")
async def list_websocket_endpoint(
websocket: WebSocket,
list_id: int,
token: str = Query(...),
db: AsyncSession = Depends(get_transactional_session),
):
"""Authenticated WebSocket endpoint for a specific list."""
user = await get_user_from_token(token, db)
if not user:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
try:
# Verify the user has access to this list's group
await crud_list.check_list_permission(db, list_id, user.id)
except Exception:
await websocket.close(code=status.WS_1003_UNSUPPORTED_DATA)
return
await websocket.accept()
# Subscribe to the list-specific channel
pubsub = await subscribe_to_channel(f"list_{list_id}")
try:
# Keep the connection alive and forward messages from Redis
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
if message and message.get("type") == "message":
await websocket.send_text(message["data"])
except WebSocketDisconnect:
# Client disconnected
pass
finally:
# Clean up the Redis subscription
await unsubscribe_from_channel(f"list_{list_id}", pubsub)
@router.websocket("/ws/{household_id}")
async def household_websocket_endpoint(
websocket: WebSocket,
household_id: int,
token: str = Query(...),
db: AsyncSession = Depends(get_transactional_session)
):
"""Authenticated WebSocket endpoint for a household."""
user = await get_user_from_token(token, db)
if not user:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# TODO: Add permission check for household
await websocket.accept()
# ... rest of household logic

View File

@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response
from pydantic import BaseModel
from fastapi.responses import JSONResponse
from sqlalchemy import select
from .database import get_session
from .models import User
@ -166,3 +167,34 @@ fastapi_users = FastAPIUsers[User, int](
current_active_user = fastapi_users.current_user(active=True)
current_superuser = fastapi_users.current_user(active=True, superuser=True)
# ---------------------------------------------------------------------------
# JWT helper function used by WebSocket endpoints
# ---------------------------------------------------------------------------
async def get_user_from_token(token: str, db: AsyncSession) -> Optional[User]:
"""Return the ``User`` associated with a valid *token* or ``None``.
The function decodes the JWT using the same strategy FastAPI Users uses
elsewhere in the application. If the token is invalid/expired or the user
cannot be found (or is deleted/inactive), ``None`` is returned so the
caller can close the WebSocket with the appropriate code.
"""
strategy = get_jwt_strategy()
try:
user_id = await strategy.read_token(token)
except Exception:
# Any decoding/parsing/expiry error treat as invalid token
return None
# Fetch the user from the database. We avoid failing hard return ``None``
# if the user does not exist or is inactive/deleted.
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or getattr(user, "is_deleted", False) or not user.is_active:
return None
return user

View File

@ -1,7 +1,51 @@
import redis.asyncio as redis
from app.config import settings
import json
from typing import Any, Dict
redis_pool = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async def get_redis():
return redis_pool
# ---------------------------------------------------------------------------
# Helper functions for Pub/Sub messaging
# ---------------------------------------------------------------------------
async def broadcast_event(channel: str, event: Dict[str, Any]) -> None:
"""Serialize the given event as JSON and publish it on the specified Redis channel.
This is primarily used by API endpoints to notify WebSocket consumers of
real-time changes (e.g. an item being claimed). Consumers subscribe to the
corresponding channel (see ``subscribe_to_channel``) and relay the JSON
payload to connected clients.
"""
redis_conn = await get_redis()
# NOTE: ``json.dumps`` ensures we only send textual data over the wire
# which plays nicely with WebSocket ``send_text`` in the frontend.
await redis_conn.publish(channel, json.dumps(event))
async def subscribe_to_channel(channel: str):
"""Return a PubSub instance already subscribed to *channel*.
The caller is responsible for reading messages from the returned ``pubsub``
object and for eventually invoking :func:`unsubscribe_from_channel` to
clean up resources.
"""
redis_conn = await get_redis()
pubsub = redis_conn.pubsub()
await pubsub.subscribe(channel)
return pubsub
async def unsubscribe_from_channel(channel: str, pubsub) -> None:
"""Unsubscribe from *channel* and properly close the PubSub connection."""
try:
await pubsub.unsubscribe(channel)
finally:
# Close the pubsub object to release the underlying connection back to
# the pool. We ignore any exceptions here because we are often in a
# ``finally`` block during WebSocket shutdown.
await pubsub.close()

81
be/app/crud/activity.py Normal file
View File

@ -0,0 +1,81 @@
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import func, text
from app.models import ChoreHistory, FinancialAuditLog, Item, UserGroup, User
from app.schemas.activity import Activity, ActivityEventType, ActivityUser
async def get_group_activity(db: AsyncSession, group_id: int, limit: int, cursor: int = None) -> list[Activity]:
queries = []
chore_history_stmt = (
select(ChoreHistory)
.where(ChoreHistory.group_id == group_id)
.options(selectinload(ChoreHistory.changed_by_user))
)
if cursor:
chore_history_stmt = chore_history_stmt.where(ChoreHistory.timestamp < datetime.fromtimestamp(cursor))
queries.append(chore_history_stmt)
# FinancialAuditLog has group_id through joins, which is complex. For now, we assume a direct or easy link.
# This might need refinement based on actual relationships.
# This is a placeholder as FinancialAuditLog doesn't have a direct group_id.
# We will need to join through expenses/settlements, etc.
# For now, this part will return empty.
items_stmt = (
select(Item)
.join(Item.list)
.where(text("lists.group_id = :group_id"))
.params(group_id=group_id)
.options(selectinload(Item.added_by_user), selectinload(Item.completed_by_user))
)
if cursor:
items_stmt = items_stmt.where(Item.created_at < datetime.fromtimestamp(cursor))
queries.append(items_stmt)
results = []
for q in queries:
res = await db.execute(q)
results.extend(res.scalars().all())
activities = []
for item in results:
if isinstance(item, ChoreHistory):
if item.event_type.value == 'completed':
user = item.changed_by_user
activities.append(Activity(
id=f"chore_history-{item.id}",
event_type=ActivityEventType.CHORE_COMPLETED,
timestamp=item.timestamp,
user=ActivityUser(id=user.id, name=user.name),
details={"chore_name": item.event_data.get("name", "Unknown chore")},
message=f"{user.name or 'User'} completed '{item.event_data.get('name', 'a chore')}'."
))
elif isinstance(item, Item):
user = item.added_by_user
activities.append(Activity(
id=f"item-added-{item.id}",
event_type=ActivityEventType.ITEM_ADDED,
timestamp=item.created_at,
user=ActivityUser(id=user.id, name=user.name),
details={"item_name": item.name, "list_id": item.list_id},
message=f"{user.name or 'User'} added '{item.name}' to a list."
))
if item.is_complete and item.completed_by_user:
c_user = item.completed_by_user
activities.append(Activity(
id=f"item-completed-{item.id}",
event_type=ActivityEventType.ITEM_COMPLETED,
timestamp=item.updated_at, # This is an approximation
user=ActivityUser(id=c_user.id, name=c_user.name),
details={"item_name": item.name, "list_id": item.list_id},
message=f"{c_user.name or 'User'} purchased '{item.name}'."
))
activities.sort(key=lambda x: x.timestamp, reverse=True)
return activities[:limit]

View File

@ -190,3 +190,26 @@ async def delete_item(db: AsyncSession, item_db: ItemModel) -> None:
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")
async def claim_item(db: AsyncSession, item: ItemModel, user_id: int) -> ItemModel:
"""Marks an item as claimed by a user."""
if item.is_complete:
raise ConflictError("Cannot claim a completed item.")
if item.claimed_by_user_id is not None and item.claimed_by_user_id != user_id:
raise ConflictError("Item is already claimed by another user.")
item.claimed_by_user_id = user_id
item.claimed_at = datetime.now(timezone.utc)
item.version += 1
db.add(item)
await db.flush()
await db.refresh(item, attribute_names=['claimed_by_user', 'version', 'claimed_at'])
return item
async def unclaim_item(db: AsyncSession, item: ItemModel) -> ItemModel:
"""Removes a user's claim from an item."""
item.claimed_by_user_id = None
item.claimed_at = None
item.version += 1
db.add(item)
await db.flush()

View File

@ -1,6 +1,7 @@
import enum
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import (
Column,
@ -18,7 +19,8 @@ from sqlalchemy import (
Text,
Numeric,
CheckConstraint,
Date
Date,
Float
)
from sqlalchemy.orm import relationship, declared_attr
from sqlalchemy.dialects.postgresql import JSONB
@ -115,6 +117,12 @@ class User(Base, SoftDeleteMixin):
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator")
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user")
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user")
# Items this user has claimed responsibility for (e.g., grocery list items)
claimed_items = relationship(
"Item",
foreign_keys="Item.claimed_by_user_id",
back_populates="claimed_by_user",
)
expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user")
expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user")
expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user")
@ -220,14 +228,18 @@ class Item(Base, SoftDeleteMixin):
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
claimed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
completed_at = Column(DateTime(timezone=True), nullable=True)
claimed_at = Column(DateTime(timezone=True), nullable=True)
# --- Relationships ---
list = relationship("List", back_populates="items")
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
claimed_by_user = relationship("User", foreign_keys=[claimed_by_user_id], back_populates="claimed_items")
expenses = relationship("Expense", back_populates="item")
category = relationship("Category", back_populates="items")

View File

@ -0,0 +1,32 @@
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
from typing import Optional, Dict, Any, List
class ActivityEventType(str, Enum):
CHORE_COMPLETED = "chore_completed"
CHORE_CREATED = "chore_created"
EXPENSE_CREATED = "expense_created"
EXPENSE_SETTLED = "expense_settled"
ITEM_ADDED = "item_added"
ITEM_COMPLETED = "item_completed"
USER_JOINED_GROUP = "user_joined_group"
class ActivityUser(BaseModel):
id: int
name: Optional[str] = None
class Activity(BaseModel):
id: str = Field(..., description="A unique ID for the activity event, e.g., 'chore_completed-123'")
event_type: ActivityEventType
timestamp: datetime
user: ActivityUser
details: Dict[str, Any] = Field({}, description="Structured data about the event.")
message: str = Field(..., description="A human-readable message, e.g., 'John completed \"Wash dishes\".'")
class Config:
from_attributes = True
class PaginatedActivityResponse(BaseModel):
items: List[Activity]
cursor: Optional[int] = Field(None, description="The timestamp of the last item, for cursor-based pagination.")

View File

View File

@ -0,0 +1,48 @@
path,feature,uses SCSS?,uses Options API?,third-party libs
src/App.vue,Global UI,✔️,❌,
src/layouts/MainLayout.vue,Global UI,✔️,❌,
src/layouts/AuthLayout.vue,Auth,✔️,❌,
src/components/SocialLoginButtons.vue,Auth,❌,❌,vue3-social
src/components/OfflineIndicator.vue,Global UI,❌,❌,
src/components/global/NotificationDisplay.vue,Global UI,✔️,❌,
src/components/EssentialLink.vue,Global UI,❌,❌,
src/components/CategoryForm.vue,Categories,❌,❌,
src/components/SettleShareModal.vue,Lists,❌,❌,
src/components/CreateGroupModal.vue,Groups,❌,❌,
src/components/CreateListModal.vue,Lists,❌,❌,
src/components/CreateExpenseForm.vue,Expenses,❌,❌,
src/components/ChoreItem.vue,Chores,✔️,❌,
src/components/ConflictResolutionDialog.vue,Chores,❌,❌,
src/components/expenses/ExpenseForm.vue,Expenses,❌,❌,
src/components/expenses/ExpenseList.vue,Expenses,❌,❌,
src/components/expenses/RecurrencePatternForm.vue,Expenses,❌,❌,
src/components/list-detail/CostSummaryDialog.vue,Lists,❌,❌,
src/components/list-detail/ExpenseSection.vue,Lists,❌,❌,
src/components/list-detail/ItemsList.vue,Lists,❌,❌,
src/components/list-detail/ListItem.vue,Lists,❌,❌,
src/components/list-detail/OcrDialog.vue,Lists,❌,❌,
src/components/list-detail/SettleShareModal.vue,Lists,❌,❌,
src/pages/LoginPage.vue,Auth,❌,❌,
src/pages/SignupPage.vue,Auth,❌,❌,
src/pages/AuthCallbackPage.vue,Auth,❌,❌,
src/pages/AccountPage.vue,Auth,❌,❌,
src/pages/ChoresPage.vue,Chores,✔️,❌,
src/pages/CategoriesPage.vue,Categories,❌,❌,
src/pages/IndexPage.vue,Global UI,❌,❌,
src/pages/ExpensePage.vue,Expenses,✔️,❌,
src/pages/ExpensesPage.vue,Expenses,❌,❌,
src/pages/GroupsPage.vue,Groups,❌,❌,
src/pages/GroupDetailPage.vue,Groups,❌,❌,
src/pages/ListDetailPage.vue,Lists,❌,❌,
src/pages/ListsPage.vue,Lists,❌,❌,
src/pages/ErrorNotFound.vue,Misc,❌,❌,
# Pinia Stores
path,feature,uses SCSS?,uses Options API?,third-party libs
src/stores/auth.ts,Auth,,,,
src/stores/categoryStore.ts,Categories,,,,
src/stores/groupStore.ts,Groups,,,,
src/stores/listDetailStore.ts,Lists,,,,
src/stores/offline.ts,Global UI,,,,
src/stores/notifications.ts,Global UI,,,,
src/stores/timeEntryStore.ts,Chores,,,,
Can't render this file because it has a wrong number of fields in line 40.

View File

@ -1,17 +0,0 @@
import type { StorybookConfig } from '@storybook/vue3-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@storybook/addon-onboarding",
"@storybook/addon-docs"
],
"framework": {
"name": "@storybook/vue3-vite",
"options": {}
}
};
export default config;

View File

@ -1,14 +0,0 @@
import type { Preview } from '@storybook/vue3-vite'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@ -0,0 +1,97 @@
<template>
<TransitionRoot appear :show="open" as="template">
<Dialog as="div" class="relative z-50" @close="close">
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100"
leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
<div class="fixed inset-0 bg-black/25" />
</TransitionChild>
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100" leave="ease-in duration-200" leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95">
<DialogPanel
class="w-full max-w-md transform overflow-hidden rounded-2xl bg-white dark:bg-dark p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle class="text-lg font-medium leading-6 text-gray-900 dark:text-light mb-4">Sign
in</DialogTitle>
<div v-if="step === 'email'" class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-300">Enter your email and we'll send you
a magic link.</p>
<form @submit.prevent="sendLink" class="space-y-4">
<input v-model="email" type="email" placeholder="you@example.com"
class="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-primary focus:border-primary text-sm dark:bg-neutral-800 dark:border-neutral-600"
required />
<button type="submit"
class="w-full inline-flex justify-center rounded bg-primary text-white px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
:disabled="loading">
<span v-if="loading" class="animate-pulse">Sending</span>
<span v-else>Send Magic Link</span>
</button>
</form>
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200 dark:border-neutral-700" />
</div>
<div class="relative flex justify-center text-xs uppercase"><span
class="bg-white dark:bg-dark px-2 text-gray-500">or</span></div>
</div>
<SocialLoginButtons />
</div>
<div v-else-if="step === 'sent'" class="space-y-4">
<p class="text-sm text-gray-700 dark:text-gray-200">We've sent a link to <strong>{{
email }}</strong>. Check your inbox and click the link to sign in.</p>
<button @click="close" class="mt-2 text-sm text-primary hover:underline">Close</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { ref, defineExpose, watch } from 'vue'
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
import { useAuthStore } from '@/stores/auth'
import SocialLoginButtons from '@/components/SocialLoginButtons.vue'
import { useNotificationStore } from '@/stores/notifications'
const open = ref(false)
const email = ref('')
const loading = ref(false)
const step = ref<'email' | 'sent'>('email')
const authStore = useAuthStore()
const notify = useNotificationStore()
function show() {
open.value = true
step.value = 'email'
email.value = ''
}
function close() {
open.value = false
}
async function sendLink() {
loading.value = true
try {
await authStore.requestMagicLink(email.value)
step.value = 'sent'
notify.addNotification({ message: 'Magic link sent!', type: 'success' })
} catch (e: any) {
notify.addNotification({ message: e?.response?.data?.detail || 'Failed to send link', type: 'error' })
} finally {
loading.value = false
}
}
defineExpose({ show })
</script>
<style scoped></style>

View File

@ -0,0 +1,96 @@
<template>
<Dialog v-model="isOpen">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-medium leading-6 text-neutral-900 dark:text-neutral-100">
{{ chore?.name || 'Chore details' }}
</h3>
<button @click="close" class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
</button>
</div>
<div v-if="chore" class="space-y-4">
<div>
<h4 class="text-sm font-semibold mb-2">General</h4>
<dl class="grid grid-cols-2 gap-3 text-sm">
<div>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Type</dt>
<dd>{{ chore.type === 'group' ? 'Group' : 'Personal' }}</dd>
</div>
<div>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Created by</dt>
<dd>{{ chore.creator?.name || chore.creator?.email || 'Unknown' }}</dd>
</div>
<div>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Due date</dt>
<dd>{{ formatDate(chore.next_due_date) }}</dd>
</div>
<div>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Frequency</dt>
<dd>{{ frequencyLabel }}</dd>
</div>
</dl>
</div>
<div v-if="chore.description">
<h4 class="text-sm font-semibold mb-2">Description</h4>
<p class="text-sm text-neutral-700 dark:text-neutral-200 whitespace-pre-wrap">{{ chore.description }}
</p>
</div>
<div v-if="chore.child_chores?.length">
<h4 class="text-sm font-semibold mb-2">Sub-Tasks</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="sub in chore.child_chores" :key="sub.id">
{{ sub.name }}
</li>
</ul>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { format } from 'date-fns'
import type { ChoreWithCompletion } from '@/types/chore'
import BaseIcon from '@/components/BaseIcon.vue'
import Dialog from '@/components/ui/Dialog.vue'
interface Props {
modelValue: boolean
chore: ChoreWithCompletion | null
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
// Local proxy for the v-model binding to avoid mutating the prop directly
const isOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
function close() {
emit('update:modelValue', false)
}
function formatDate(dateStr: string) {
return format(new Date(dateStr), 'PPP')
}
const frequencyLabel = computed(() => {
if (!props.chore) return ''
const { frequency, custom_interval_days } = props.chore
if (frequency === 'custom' && custom_interval_days) {
return `Every ${custom_interval_days} days`
}
const map: Record<string, string> = {
one_time: 'One-time',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
}
return map[frequency] || frequency
})
</script>

View File

@ -1,52 +1,84 @@
<template>
<li class="neo-list-item" :class="`status-${getDueDateStatus(chore)}`">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)">
<div class="checkbox-content">
<div class="chore-main-info">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
<li :class="[
'relative flex items-start gap-3 py-3 border-b border-neutral-200 dark:border-neutral-700 transition',
getDueDateStatus(chore) === 'overdue' && 'bg-warning/10',
getDueDateStatus(chore) === 'due-today' && 'bg-success/10',
]">
<!-- Checkbox + main content -->
<label class="flex gap-3 w-full cursor-pointer select-none">
<!-- Checkbox -->
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)"
class="h-5 w-5 text-primary rounded-md border-neutral-300 dark:border-neutral-600 focus:ring-primary-500 focus:ring-2" />
<div class="flex flex-col gap-0.5 flex-1">
<!-- Title + badges -->
<div class="flex items-center gap-2">
<span :class="[chore.is_completed && !chore.updating ? 'line-through text-neutral-400' : '']">
{{ chore.name }}
</span>
<div class="chore-badges">
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
<span v-if="getDueDateStatus(chore) === 'overdue'"
class="badge badge-overdue">Overdue</span>
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
Today</span>
<span v-if="getDueDateStatus(chore) === 'upcoming'" class="badge badge-upcoming">{{
dueInText }}</span>
<template v-if="chore.type === 'group'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-primary/10 text-primary text-xs font-medium">
Group
</span>
</template>
<template v-if="getDueDateStatus(chore) === 'overdue'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-danger/10 text-danger text-xs font-medium">
Overdue
</span>
</template>
<template v-else-if="getDueDateStatus(chore) === 'due-today'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-warning/10 text-warning text-xs font-medium">
Due Today
</span>
</template>
<template v-else-if="getDueDateStatus(chore) === 'upcoming'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-neutral/10 text-neutral text-xs font-medium">
{{ dueInText }}
</span>
</template>
</div>
</div>
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
<div v-if="totalTime > 0" class="total-time">
<!-- Description -->
<p v-if="chore.description" class="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-2">
{{ chore.description }}
</p>
<!-- Subtext / time -->
<span v-if="chore.subtext" class="text-xs text-neutral-500">{{ chore.subtext }}</span>
<span v-if="totalTime > 0" class="text-xs text-neutral-500">
Total Time: {{ formatDuration(totalTime) }}
</div>
</span>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="toggleTimer"
<!-- Action buttons -->
<div class="flex items-center gap-1 ml-auto">
<Button variant="ghost" size="sm" color="neutral" @click="toggleTimer"
:disabled="chore.is_completed || !chore.current_assignment_id">
<BaseIcon :name="isActiveTimer ? 'heroicons:pause-20-solid' : 'heroicons:play-20-solid'"
class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
</Button>
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-details', chore)">
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-history', chore)" title="View History">
</Button>
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-history', chore)">
<BaseIcon name="heroicons:calendar-days-20-solid" class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
</Button>
<Button variant="ghost" size="sm" color="neutral" @click="emit('edit', chore)">
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
</Button>
<Button variant="ghost" size="sm" color="danger" @click="emit('delete', chore)">
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
</button>
</Button>
</div>
</div>
<ul v-if="chore.child_chores && chore.child_chores.length" class="child-chore-list">
<!-- Recursive children -->
<ul v-if="chore.child_chores?.length" class="ml-5">
<ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries"
:active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)"
@edit="emit('edit', $event)" @delete="emit('delete', $event)"
@ -59,12 +91,14 @@
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import { formatDistanceToNow, parseISO, isToday, isPast } from 'date-fns';
import { formatDistanceToNow, isToday } from 'date-fns';
import type { ChoreWithCompletion } from '../types/chore';
import type { TimeEntry } from '../stores/timeEntryStore';
import type { TimeEntry } from '@/types/time_entry';
import { formatDuration } from '../utils/formatters';
import BaseIcon from './BaseIcon.vue';
import { Button } from '@/components/ui';
// --- props & emits ---
const props = defineProps<{
chore: ChoreWithCompletion;
timeEntries: TimeEntry[];
@ -81,13 +115,14 @@ const emit = defineEmits<{
(e: 'stop-timer', chore: ChoreWithCompletion, timeEntryId: number): void;
}>();
const isActiveTimer = computed(() => {
return props.activeTimer && props.activeTimer.chore_assignment_id === props.chore.current_assignment_id;
});
// --- computed helpers ---
const isActiveTimer = computed(() =>
!!props.activeTimer && props.activeTimer.chore_assignment_id === props.chore.current_assignment_id,
);
const totalTime = computed(() => {
return props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0);
});
const totalTime = computed(() =>
props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0),
);
const dueInText = computed(() => {
if (!props.chore.next_due_date) return '';
@ -96,15 +131,16 @@ const dueInText = computed(() => {
return formatDistanceToNow(dueDate, { addSuffix: true });
});
const toggleTimer = () => {
// --- methods ---
function toggleTimer() {
if (isActiveTimer.value) {
emit('stop-timer', props.chore, props.activeTimer!.id);
} else {
emit('start-timer', props.chore);
}
};
}
const getDueDateStatus = (chore: ChoreWithCompletion) => {
function getDueDateStatus(chore: ChoreWithCompletion) {
if (chore.is_completed) return 'completed';
const today = new Date();
@ -116,7 +152,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (dueDate < today) return 'overdue';
if (dueDate.getTime() === today.getTime()) return 'due-today';
return 'upcoming';
};
}
</script>
<script lang="ts">
@ -124,250 +160,3 @@ export default {
name: 'ChoreItem'
}
</script>
<style scoped lang="scss">
/* Neo-style list items */
.neo-list-item {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
margin-bottom: 0;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: none;
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Animated strikethrough line */
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
/* Status-based styling */
.status-completed {
opacity: 0.7;
}
/* Chore-specific styles */
.chore-main-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.chore-badges {
display: flex;
gap: 0.25rem;
}
.badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-group {
background-color: #3b82f6;
color: white;
}
.badge-overdue {
background-color: #ef4444;
color: white;
}
.badge-due-today {
background-color: #f59e0b;
color: white;
}
.badge-upcoming {
background-color: #3b82f6;
color: white;
}
.chore-description {
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
margin-top: 0.25rem;
font-style: italic;
}
.item-time {
font-size: 0.9rem;
color: var(--dark);
opacity: 0.7;
}
.total-time {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
.child-chore-list {
list-style: none;
padding-left: 2rem;
margin-top: 0.5rem;
border-left: 2px solid #e5e7eb;
}
</style>

View File

@ -1,32 +1,38 @@
<template>
<VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New Group">
<template #default>
<Dialog v-model="isOpen" class="max-w-md w-full p-6 bg-white rounded-lg dark:bg-neutral-800">
<Heading :level="3" class="mb-4">{{ t('createGroupModal.title', 'Create New Household') }}</Heading>
<form @submit.prevent="onSubmit">
<VFormField label="Group Name" :error-message="formError ?? undefined">
<VInput type="text" v-model="groupName" required ref="groupNameInput" />
</VFormField>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" for="groupNameInput">{{ t('createGroupModal.nameLabel',
'Group Name') }}</label>
<Input id="groupNameInput" v-model="groupName"
:placeholder="t('createGroupModal.namePlaceholder', 'e.g. My Household')" required />
<Alert v-if="formError" type="error" :message="formError" class="mt-1" />
</div>
<div class="flex justify-end gap-2 mt-6">
<Button variant="ghost" color="neutral" type="button" @click="closeModal">{{ t('shared.cancel',
'Cancel') }}</Button>
<Button type="submit" :disabled="loading">
<Spinner v-if="loading" size="sm" class="mr-1" />
{{ t('createGroupModal.create', 'Create') }}
</Button>
</div>
</form>
</template>
<template #footer>
<VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
<VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
<VSpinner v-if="loading" size="sm" />
Create
</VButton>
</template>
</VModal>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { useVModel } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import Dialog from '@/components/ui/Dialog.vue';
import Input from '@/components/ui/Input.vue';
import Button from '@/components/ui/Button.vue';
import Spinner from '@/components/ui/Spinner.vue';
import Heading from '@/components/ui/Heading.vue';
import Alert from '@/components/ui/Alert.vue';
const props = defineProps<{
modelValue: boolean;
@ -42,7 +48,8 @@ const groupName = ref('');
const loading = ref(false);
const formError = ref<string | null>(null);
const notificationStore = useNotificationStore();
const groupNameInput = ref<InstanceType<typeof VInput> | null>(null);
const { t } = useI18n();
const groupNameInput = ref<InstanceType<typeof Input> | null>(null);
watch(isOpen, (newVal) => {
if (newVal) {
@ -58,14 +65,14 @@ const closeModal = () => {
isOpen.value = false;
};
const validateForm = () => {
function validateForm() {
formError.value = null;
if (!groupName.value.trim()) {
formError.value = 'Name is required';
formError.value = t('createGroupModal.nameRequired', 'Name is required');
return false;
}
return true;
};
}
const onSubmit = async () => {
if (!validateForm()) {
@ -75,7 +82,7 @@ const onSubmit = async () => {
try {
const payload = { name: groupName.value };
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, payload);
notificationStore.addNotification({ message: 'Group created successfully', type: 'success' });
notificationStore.addNotification({ message: t('createGroupModal.success', 'Group created successfully'), type: 'success' });
emit('created', response.data);
closeModal();
} catch (error: any) {
@ -89,14 +96,4 @@ const onSubmit = async () => {
};
</script>
<style>
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
</style>
<style scoped></style>

View File

@ -1,44 +1,56 @@
<template>
<VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New List">
<template #default>
<form @submit.prevent="onSubmit">
<VFormField label="List Name" :error-message="formErrors.listName">
<VInput type="text" v-model="listName" required ref="listNameInput" />
</VFormField>
<Dialog :model-value="isOpen" @update:model-value="closeModal">
<form @submit.prevent="onSubmit" class="mt-4">
<div>
<label for="list-name" class="block text-sm font-medium text-gray-700">{{
$t('createListModal.listNameLabel')
}}</label>
<Input id="list-name" type="text" v-model="listName" required ref="listNameInput" class="mt-1 w-full" />
<p v-if="formErrors.listName" class="mt-1 text-sm text-red-600">
{{ formErrors.listName }}
</p>
</div>
<VFormField label="Description">
<VTextarea v-model="description" :rows="3" />
</VFormField>
<div class="mt-4">
<label for="list-description" class="block text-sm font-medium text-gray-700">{{
$t('createListModal.descriptionLabel')
}}</label>
<textarea id="list-description" v-model="description" :rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
</div>
<VFormField label="Associate with Group (Optional)" v-if="props.groups && props.groups.length > 0">
<VSelect v-model="selectedGroupId" :options="groupOptionsForSelect" placeholder="None" />
</VFormField>
<!-- Form submission is handled by button in footer slot -->
<div v-if="props.groups && props.groups.length > 0" class="mt-4">
<label for="list-group" class="block text-sm font-medium text-gray-700">{{
$t('createListModal.groupLabel')
}}</label>
<select id="list-group" v-model="selectedGroupId"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option :value="SENTINEL_NO_GROUP">{{ $t('shared.none') }}</option>
<option v-for="opt in props.groups" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="mt-6 flex justify-end gap-x-2">
<Button variant="outline" color="neutral" @click="closeModal" type="button">{{
$t('createListModal.cancelButton') }}</Button>
<Button type="submit" color="primary" :disabled="loading" @click="onSubmit">
<Spinner v-if="loading" size="sm" class="mr-1" />
{{ $t('createListModal.createButton') }}
</Button>
</div>
</form>
</template>
<template #footer>
<VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
<VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
<VSpinner v-if="loading" size="sm" />
Create
</VButton>
</template>
</VModal>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed } from 'vue';
import { useVModel } from '@vueuse/core'; // onClickOutside removed
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming this path is correct
import { useVModel } from '@vueuse/core';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VTextarea from '@/components/valerie/VTextarea.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import VButton from '@/components/valerie/VButton.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import type { Group } from '@/types/group';
import { Dialog, Input, Button, Spinner } from '@/components/ui';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps<{
modelValue: boolean;
@ -53,41 +65,34 @@ const emit = defineEmits<{
const isOpen = useVModel(props, 'modelValue', emit);
const listName = ref('');
const description = ref('');
const SENTINEL_NO_GROUP = 0; // Using 0 to represent 'None' or 'Personal List'
const selectedGroupId = ref<number>(SENTINEL_NO_GROUP); // Initialize with sentinel
const SENTINEL_NO_GROUP = 0;
const selectedGroupId = ref<number>(SENTINEL_NO_GROUP);
const loading = ref(false);
const formErrors = ref<{ listName?: string }>({});
const notificationStore = useNotificationStore();
const listNameInput = ref<InstanceType<typeof VInput> | null>(null);
// const modalContainerRef = ref<HTMLElement | null>(null); // Removed
const listNameInput = ref<any>(null);
const groupOptionsForSelect = computed(() => {
// VSelect's placeholder should work if selectedGroupId is the sentinel value
return props.groups ? props.groups.map(g => ({ label: g.label, value: g.value })) : [];
return props.groups ? [{ label: t('shared.none'), value: SENTINEL_NO_GROUP }, ...props.groups] : [];
});
watch(isOpen, (newVal) => {
if (newVal) {
// Reset form when opening
listName.value = '';
description.value = '';
// If a single group is passed, pre-select it. Otherwise, default to sentinel
if (props.groups && props.groups.length === 1) {
selectedGroupId.value = props.groups[0].value;
} else {
selectedGroupId.value = SENTINEL_NO_GROUP; // Reset to sentinel
selectedGroupId.value = SENTINEL_NO_GROUP;
}
formErrors.value = {};
nextTick(() => {
// listNameInput.value?.focus?.(); // This might still be an issue depending on VInput. Commenting out for now.
(listNameInput.value?.$el?.querySelector('input') as HTMLInputElement | undefined)?.focus();
});
}
});
// onClickOutside removed, VModal handles backdrop clicks
const closeModal = () => {
isOpen.value = false;
};
@ -95,7 +100,7 @@ const closeModal = () => {
const validateForm = () => {
formErrors.value = {};
if (!listName.value.trim()) {
formErrors.value.listName = 'Name is required';
formErrors.value.listName = t('createListModal.errors.nameRequired');
}
return Object.keys(formErrors.value).length === 0;
};
@ -113,12 +118,15 @@ const onSubmit = async () => {
};
const response = await apiClient.post(API_ENDPOINTS.LISTS.BASE, payload);
notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
notificationStore.addNotification({
message: t('createListModal.notifications.createSuccess'),
type: 'success',
});
emit('created', response.data);
closeModal();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to create list';
const message = error instanceof Error ? error.message : t('createListModal.notifications.createFailed');
notificationStore.addNotification({ message, type: 'error' });
console.error(message, error);
} finally {
@ -126,16 +134,3 @@ const onSubmit = async () => {
}
};
</script>
<style>
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
/* from Valerie UI utilities */
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<section>
<Heading :level="3" class="mb-4">{{ t('inviteManager.title', 'Household Invites') }}</Heading>
<!-- Generate / regenerate button -->
<Button :disabled="generating" @click="generateInvite" class="mb-3">
<Spinner v-if="generating" class="mr-2" size="sm" />
{{ inviteCode ? t('inviteManager.regenerate', 'Regenerate Invite Code') : t('inviteManager.generate',
'Generate Invite Code') }}
</Button>
<!-- Active invite display -->
<div v-if="inviteCode" class="mb-2 flex items-center gap-2">
<Input :model-value="inviteCode" readonly class="flex-1" />
<Button variant="outline" color="secondary" size="sm" :disabled="!clipboardSupported" @click="copyInvite">
{{ copied ? t('inviteManager.copied', 'Copied!') : t('inviteManager.copy', 'Copy') }}
</Button>
</div>
<Alert v-if="error" type="error" :message="error" />
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import Button from '@/components/ui/Button.vue'
import Heading from '@/components/ui/Heading.vue'
import Input from '@/components/ui/Input.vue'
import Spinner from '@/components/ui/Spinner.vue'
import Alert from '@/components/ui/Alert.vue'
interface Props {
groupId: number
}
defineProps<Props>()
const props = defineProps<Props>()
const { t } = useI18n()
const inviteCode = ref<string | null>(null)
const generating = ref(false)
const error = ref<string | null>(null)
const { copy, copied, isSupported: clipboardSupported } = useClipboard({ legacy: true })
async function fetchActiveInvite() {
if (!props.groupId) return
try {
const res = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(props.groupId)))
if (res.data && res.data.code) {
inviteCode.value = res.data.code
}
} catch (err: any) {
// silent absence of active invite is OK
}
}
async function generateInvite() {
if (!props.groupId) return
generating.value = true
error.value = null
try {
const res = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(props.groupId)))
inviteCode.value = res.data.code
} catch (err: any) {
error.value = err?.response?.data?.detail || err.message || t('inviteManager.generateError', 'Failed to generate invite')
} finally {
generating.value = false
}
}
async function copyInvite() {
if (!inviteCode.value) return
await copy(inviteCode.value)
}
onMounted(fetchActiveInvite)
watch(() => props.groupId, fetchActiveInvite)
</script>
<style scoped></style>

View File

@ -0,0 +1,41 @@
<template>
<div class="flex items-center space-x-2">
<input v-model="choreName" @keyup.enter="handleAdd" type="text" placeholder="Add a quick chore..."
class="flex-1 px-3 py-2 rounded-md border border-gray-300 dark:border-neutral-700 focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm bg-white dark:bg-neutral-800 text-gray-900 dark:text-gray-100" />
<Button variant="solid" size="sm" :disabled="!choreName || isLoading" @click="handleAdd">
Add
</Button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useChoreStore } from '@/stores/choreStore'
import Button from '@/components/ui/Button.vue'
const choreStore = useChoreStore()
const choreName = ref('')
const isLoading = ref(false)
const handleAdd = async () => {
if (!choreName.value.trim()) return
try {
isLoading.value = true
await choreStore.create({
name: choreName.value.trim(),
description: '',
frequency: 'one_time',
type: 'personal',
custom_interval_days: undefined,
next_due_date: new Date().toISOString().split('T')[0],
created_by_id: 0, // backend will override
} as any)
choreName.value = ''
} catch (e) {
// Optionally handle error
console.error('Failed to create quick chore', e)
} finally {
isLoading.value = false
}
}
</script>

View File

@ -1,11 +1,17 @@
<template>
<div class="social-login-container">
<div class="divider">
<span>or continue with</span>
<div class="mt-6">
<div class="relative py-2">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-300 dark:border-neutral-600" />
</div>
<div class="social-buttons">
<button @click="handleGoogleLogin" class="btn btn-social btn-google">
<svg class="icon" viewBox="0 0 24 24">
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white dark:bg-dark text-gray-500">or continue with</span>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-3">
<Button variant="outline" color="neutral" @click="handleGoogleLogin">
<svg class="w-5 h-5 mr-3" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4" />
@ -19,95 +25,21 @@
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335" />
</svg>
Continue with Google
</button>
<!-- <button @click="handleAppleLogin" class="btn btn-social btn-apple">
<svg class="icon" viewBox="0 0 24 24">
<path
d="M17.05 20.28c-.98.95-2.05.88-3.08.41-1.09-.47-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.41C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.19 2.31-.89 3.51-.84 1.54.07 2.7.61 3.44 1.57-3.14 1.88-2.29 5.13.22 6.41-.65 1.29-1.51 2.58-2.25 4.03zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
fill="#000" />
</svg>
Continue with Apple
</button> -->
<span>Continue with Google</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { API_BASE_URL } from '@/config/api-config';
const router = useRouter();
import { Button } from '@/components/ui';
const handleGoogleLogin = () => {
window.location.href = `${API_BASE_URL}/auth/google/login`;
};
const handleAppleLogin = () => {
window.location.href = '/auth/apple/login';
window.location.href = `${API_BASE_URL}/api/v1/auth/google/login`;
};
</script>
<style scoped>
.social-login-container {
margin-top: 1.5rem;
}
.divider {
display: flex;
align-items: center;
text-align: center;
margin: 1rem 0;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--border-color);
}
.divider span {
padding: 0 1rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.social-buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.btn-social {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-color);
color: var(--text-color);
font-weight: 500;
transition: all var(--transition-speed) var(--transition-ease-out);
}
.btn-social:hover {
background: var(--bg-hover);
}
.btn-google {
border-color: #4285F4;
}
.btn-apple {
border-color: #000;
}
.icon {
width: 1.5rem;
height: 1.5rem;
}
/* All styles handled by Tailwind utility classes */
</style>

View File

@ -0,0 +1,62 @@
<template>
<div class="rounded-lg bg-white dark:bg-dark shadow">
<h2 class="font-bold p-4 border-b border-gray-200 dark:border-neutral-700">Activity Feed</h2>
<div v-if="store.isLoading && store.activities.length === 0" class="p-4 text-center text-gray-500">
Loading activity...
</div>
<div v-else-if="store.error" class="p-4 text-center text-danger">
{{ store.error }}
</div>
<div v-else-if="store.activities.length > 0" class="divide-y divide-gray-200 dark:divide-neutral-700 px-4">
<ActivityItem v-for="activity in store.activities" :key="activity.id" :activity="activity" />
<div ref="loadMoreSentinel"></div>
</div>
<div v-else class="p-4 text-center text-gray-500">
No recent activity.
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { useActivityStore } from '@/stores/activityStore'
import { useGroupStore } from '@/stores/groupStore'
import { useAuthStore } from '@/stores/auth'
import ActivityItem from './ActivityItem.vue'
const store = useActivityStore()
const groupStore = useGroupStore()
const authStore = useAuthStore()
const loadMoreSentinel = ref<HTMLDivElement | null>(null)
onMounted(() => {
if (groupStore.currentGroupId) {
store.fetchActivities(groupStore.currentGroupId)
store.connectWebSocket(groupStore.currentGroupId, authStore.accessToken)
}
})
watch(() => groupStore.currentGroupId, (newGroupId, oldGroupId) => {
if (oldGroupId && oldGroupId !== newGroupId) {
store.disconnectWebSocket()
}
if (newGroupId) {
store.fetchActivities(newGroupId)
store.connectWebSocket(newGroupId, authStore.accessToken)
}
})
onUnmounted(() => {
store.disconnectWebSocket()
})
useIntersectionObserver(
loadMoreSentinel,
([{ isIntersecting }]) => {
if (isIntersecting && groupStore.currentGroupId && store.hasMore && !store.isLoading) {
store.fetchActivities(groupStore.currentGroupId)
}
},
)
</script>

View File

@ -0,0 +1,44 @@
<template>
<div class="flex items-start space-x-3 py-3">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
<BaseIcon :name="iconName" class="h-5 w-5 text-gray-500" />
</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-800 dark:text-gray-200" v-html="activity.message"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<time :datetime="activity.timestamp">{{ formattedTimestamp }}</time>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { type Activity, ActivityEventType } from '@/types/activity'
import BaseIcon from '@/components/BaseIcon.vue'
const props = defineProps<{
activity: Activity
}>()
const formattedTimestamp = computed(() => {
return formatDistanceToNow(new Date(props.activity.timestamp), { addSuffix: true })
})
const iconMap: Record<ActivityEventType, string> = {
[ActivityEventType.CHORE_COMPLETED]: 'heroicons:check-circle',
[ActivityEventType.CHORE_CREATED]: 'heroicons:plus-circle',
[ActivityEventType.EXPENSE_CREATED]: 'heroicons:currency-dollar',
[ActivityEventType.EXPENSE_SETTLED]: 'heroicons:receipt-percent',
[ActivityEventType.ITEM_ADDED]: 'heroicons:shopping-cart',
[ActivityEventType.ITEM_COMPLETED]: 'heroicons:check',
[ActivityEventType.USER_JOINED_GROUP]: 'heroicons:user-plus',
}
const iconName = computed(() => {
return iconMap[props.activity.event_type] || 'heroicons:question-mark-circle'
})
</script>

View File

@ -0,0 +1,54 @@
<template>
<div class="rounded-lg bg-white dark:bg-dark shadow p-4">
<div v-if="isLoading" class="text-center text-gray-500">
Loading your status...
</div>
<div v-else class="flex items-center space-x-4">
<div class="flex-shrink-0">
<div class="h-12 w-12 rounded-full flex items-center justify-center" :class="iconBgColor">
<BaseIcon :name="iconName" class="h-6 w-6 text-white" />
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ nextAction.title }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ nextAction.subtitle }}</p>
</div>
<div class="flex-shrink-0">
<router-link :to="nextAction.path">
<Button variant="solid">{{ nextAction.cta }}</Button>
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { usePersonalStatus } from '@/composables/usePersonalStatus';
import BaseIcon from '@/components/BaseIcon.vue';
import Button from '@/components/ui/Button.vue';
const { nextAction, isLoading } = usePersonalStatus();
const iconName = computed(() => {
switch (nextAction.value.type) {
case 'chore':
return 'heroicons:bell-alert';
case 'expense':
return 'heroicons:credit-card';
default:
return 'heroicons:check-circle';
}
});
const iconBgColor = computed(() => {
switch (nextAction.value.priority) {
case 1:
return 'bg-red-500';
case 2:
return 'bg-yellow-500';
default:
return 'bg-green-500';
}
});
</script>

View File

@ -0,0 +1,61 @@
<template>
<div class="fixed bottom-4 right-4">
<Menu as="div" class="relative inline-block text-left">
<div>
<MenuButton as="template">
<Button variant="solid" class="rounded-full w-14 h-14 flex items-center justify-center shadow-lg">
<BaseIcon name="heroicons:plus" class="h-7 w-7 text-white" />
</Button>
</MenuButton>
</div>
<transition enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in" leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0">
<MenuItems
class="absolute bottom-16 right-0 w-56 origin-bottom-right rounded-md bg-white dark:bg-dark shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div class="px-1 py-1">
<MenuItem v-for="item in menuItems" :key="item.label" v-slot="{ active }">
<button @click="item.action" :class="[
active ? 'bg-primary-500 text-white' : 'text-gray-900 dark:text-gray-100',
'group flex w-full items-center rounded-md px-2 py-2 text-sm',
]">
<BaseIcon :name="item.icon"
:class="[active ? 'text-white' : 'text-primary-500', 'mr-2 h-5 w-5']" />
{{ item.label }}
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import BaseIcon from '@/components/BaseIcon.vue';
import Button from '@/components/ui/Button.vue';
const router = useRouter();
const menuItems = [
{
label: 'Add Expense',
icon: 'heroicons:currency-dollar',
action: () => router.push('/expenses/new'),
},
{
label: 'Complete Chore',
icon: 'heroicons:check-circle',
action: () => router.push('/chores'),
},
{
label: 'Add to List',
icon: 'heroicons:shopping-cart',
action: () => router.push('/lists'),
},
]
</script>

View File

@ -0,0 +1,90 @@
<template>
<Dialog v-model="open" class="z-50">
<form @submit.prevent="handleSubmit"
class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<Heading :level="3">{{ $t('expenseCreation.title', 'Add Expense') }}</Heading>
<Button variant="ghost" size="sm" @click="open = false">
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
</Button>
</div>
<Input v-model="form.description" :label="$t('expenseCreation.description', 'Description')" class="mb-3" />
<Input type="number" step="0.01" v-model="form.total_amount" :label="$t('expenseCreation.amount', 'Amount')"
class="mb-3" />
<div class="flex justify-end gap-2 mt-6">
<Button variant="ghost" @click.prevent="open = false">{{ $t('common.cancel', 'Cancel') }}</Button>
<Button type="submit" :loading="submitting">{{ $t('common.save', 'Save') }}</Button>
</div>
</form>
</Dialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { Dialog } from '@/components/ui'
import { Heading, Input, Button } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
import { useExpenses } from '@/composables/useExpenses'
import type { Expense } from '@/types/expense'
import { computed } from 'vue'
interface ExpenseForm {
description: string
total_amount: string
}
const open = defineModel<boolean>('modelValue', { default: false })
// Props
const props = defineProps<{ expense?: Expense | null }>()
const form = reactive<ExpenseForm>({ description: '', total_amount: '' })
const isEditing = computed(() => !!props.expense)
const { createExpense, updateExpense } = useExpenses()
// Sync form when editing
watch(
() => props.expense,
(val) => {
if (val) {
form.description = val.description
form.total_amount = val.total_amount
} else {
form.description = ''
form.total_amount = ''
}
},
{ immediate: true },
)
const submitting = ref(false)
async function handleSubmit() {
submitting.value = true
try {
if (isEditing.value && props.expense) {
await updateExpense(props.expense.id, { ...form, version: props.expense.version })
} else {
await createExpense({
...form,
currency: 'USD',
split_type: 'EQUAL',
isRecurring: false,
paid_by_user_id: 0,
})
}
open.value = false
} catch (e) {
console.error('Failed to create expense', e)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
/* Tailwind styling */
</style>

View File

@ -1,276 +1,91 @@
<template>
<div class="expense-list">
<!-- Show loading state -->
<div v-if="loading" class="loading-state">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
<ul class="space-y-2">
<li v-for="expense in expenses" :key="expense.id" class="p-4 rounded bg-white dark:bg-neutral-800 shadow-sm">
<div class="flex items-start gap-3">
<div class="flex-1 cursor-pointer" @click="toggle(expense.id)">
<p class="font-medium leading-tight">{{ expense.description }}</p>
<p class="text-xs text-neutral-500 mt-0.5">
{{ $t('expenseList.paidBy', 'Paid by') }}
{{ expense.paid_by_user?.full_name || expense.paid_by_user?.email || '#' +
expense.paid_by_user_id
}} ·
{{ formatCurrency(parseFloat(expense.total_amount), expense.currency) }}
</p>
</div>
<div class="flex items-center gap-1 ml-auto">
<Button variant="ghost" size="sm" @click.stop="$emit('edit', expense)">
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" color="danger" @click.stop="$emit('delete', expense)">
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
</Button>
</div>
</div>
<!-- Show error message -->
<div v-else-if="error" class="alert alert-danger">
{{ error }}
</div>
<!-- Show empty state -->
<div v-else-if="!expenses.length" class="empty-state">
No expenses found
</div>
<!-- Show expenses -->
<template v-else>
<div v-for="expense in expenses" :key="expense.id" class="expense-item">
<div class="expense-header">
<h3>{{ expense.description }}</h3>
<div class="expense-actions">
<button
@click="$emit('edit', expense)"
class="btn btn-sm btn-outline-primary"
:disabled="loading"
>
Edit
</button>
<button
@click="handleDelete(expense.id)"
class="btn btn-sm btn-outline-danger"
:disabled="loading"
>
Delete
</button>
<!-- Details -->
<div v-if="expandedId === expense.id"
class="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-700 space-y-1">
<div v-for="split in expense.splits" :key="split.id" class="flex items-center text-sm gap-2 flex-wrap">
<span class="text-neutral-600 dark:text-neutral-300">{{ split.user?.full_name || split.user?.email
|| 'User #' + split.user_id }} owes</span>
<span class="font-mono font-semibold">{{ formatCurrency(parseFloat(split.owed_amount),
expense.currency) }}</span>
<span v-if="remaining(split) > 0" class="text-xs text-danger">{{ formatCurrency(remaining(split),
expense.currency) }} {{ $t('expenseList.remaining', 'left') }}</span>
<Button v-if="canSettle(split)" variant="solid" size="sm" class="ml-auto"
@click.stop="$emit('settle', { split, expense })">
{{ $t('expenseList.settle', 'Settle') }}
</Button>
</div>
</div>
<div class="expense-details">
<div class="amount">
<span class="currency">{{ expense.currency }}</span>
<span class="value">{{ formatAmount(expense.total_amount) }}</span>
</div>
<!-- Recurring expense indicator -->
<div v-if="expense.isRecurring" class="recurring-indicator">
<i class="fas fa-sync-alt"></i>
<span>Recurring</span>
<div class="recurrence-details" v-if="expense.recurrencePattern">
{{ formatRecurrencePattern(expense.recurrencePattern) }}
</div>
</div>
<div class="split-info">
<span class="split-type">{{ formatSplitType(expense.split_type) }}</span>
<span class="participants">{{ expense.splits.length }} participants</span>
</div>
</div>
</div>
</template>
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Expense, RecurrencePattern } from '@/types/expense'
import { useExpenses } from '@/composables/useExpenses'
import { ref, computed, type PropType } from 'vue'
import BaseIcon from '@/components/BaseIcon.vue'
import { Button } from '@/components/ui'
import type { Expense, ExpenseSplit } from '@/types/expense'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
expenses: Expense[]
}>()
defineProps({
expenses: {
type: Array as PropType<Expense[]>,
required: true,
},
})
const emit = defineEmits<{
(e: 'edit', expense: Expense): void
(e: 'delete', id: string): void
(e: 'delete', expense: Expense): void
(e: 'settle', payload: { split: ExpenseSplit; expense: Expense }): void
}>()
const { deleteExpense, loading, error } = useExpenses()
const formatAmount = (amount: string) => {
return parseFloat(amount).toFixed(2)
const expandedId = ref<number | null>(null)
function toggle(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
const formatSplitType = (type: string) => {
return type.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')
}
const formatRecurrencePattern = (pattern: RecurrencePattern) => {
const parts = []
// Format the type and interval
parts.push(`${pattern.interval} ${pattern.type}`)
// Add days of week for weekly recurrence
if (pattern.type === 'weekly' && pattern.daysOfWeek?.length) {
const days = pattern.daysOfWeek.map(day => {
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
return dayNames[day]
}).join(', ')
parts.push(`on ${days}`)
}
// Add end conditions
if (pattern.endDate) {
parts.push(`until ${new Date(pattern.endDate).toLocaleDateString()}`)
} else if (pattern.maxOccurrences) {
parts.push(`for ${pattern.maxOccurrences} occurrences`)
}
return parts.join(' ')
}
const handleDelete = async (id: number) => {
if (confirm('Are you sure you want to delete this expense?')) {
function formatCurrency(amount: number, currency: string) {
try {
await deleteExpense(id)
emit('delete', id.toString())
} catch (err) {
// Error is already handled by the composable
console.error('Failed to delete expense:', err)
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
} catch {
return amount.toFixed(2) + ' ' + currency
}
}
function remaining(split: ExpenseSplit) {
const paid = split.settlement_activities.reduce((s, a) => s + parseFloat(a.amount_paid), 0)
return parseFloat(split.owed_amount) - paid
}
const authStore = useAuthStore()
function canSettle(split: ExpenseSplit) {
return authStore.getUser && authStore.getUser.id === split.user_id && remaining(split) > 0
}
</script>
<style scoped>
.expense-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.expense-item {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.expense-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.expense-header h3 {
margin: 0;
font-size: 1.1rem;
}
.expense-actions {
display: flex;
gap: 0.5rem;
}
.expense-details {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.amount {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.currency {
font-size: 0.9rem;
color: #666;
}
.value {
font-size: 1.2rem;
font-weight: 500;
}
.recurring-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
color: #007bff;
font-size: 0.9rem;
}
.recurrence-details {
font-size: 0.8rem;
color: #666;
}
.split-info {
display: flex;
gap: 0.5rem;
font-size: 0.9rem;
color: #666;
}
.btn {
padding: 0.25rem 0.5rem;
border-radius: 4px;
border: 1px solid;
cursor: pointer;
font-size: 0.9rem;
}
.btn-sm {
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
}
.btn-outline-primary {
color: #007bff;
border-color: #007bff;
background: transparent;
}
.btn-outline-danger {
color: #dc3545;
border-color: #dc3545;
background: transparent;
}
.btn:hover {
opacity: 0.8;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
font-style: italic;
}
.alert {
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid transparent;
border-radius: 0.25rem;
}
.alert-danger {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.spinner-border {
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: text-bottom;
border: 0.25em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spinner-border 0.75s linear infinite;
}
@keyframes spinner-border {
to { transform: rotate(360deg); }
}
/* Tailwind covers layout */
</style>

View File

@ -0,0 +1,56 @@
<template>
<div class="p-4 rounded-lg bg-white dark:bg-neutral-800 shadow-sm w-full max-w-screen-md mx-auto">
<Heading :level="2" class="mb-4">{{ $t('expenseOverview.title', 'Expense Overview') }}</Heading>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50">
<span class="text-neutral-500">{{ $t('expenseOverview.totalExpenses', 'Total Expenses') }}</span>
<span class="text-lg font-mono font-semibold">{{ formatCurrency(totalExpenses, currency) }}</span>
</div>
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50">
<span class="text-neutral-500">{{ $t('expenseOverview.myBalance', 'My Balance') }}</span>
<span class="text-lg font-mono font-semibold" :class="myBalance < 0 ? 'text-danger' : 'text-success'">{{
formatCurrency(myBalance, currency) }}</span>
</div>
</div>
<!-- Placeholder for chart -->
<div class="mt-6 text-center text-neutral-400" v-if="!chartReady">
{{ $t('expenseOverview.chartPlaceholder', 'Spending chart will appear here…') }}
</div>
<canvas v-else ref="chartRef" class="w-full h-64" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { Heading } from '@/components/ui'
import { useExpenses } from '@/composables/useExpenses'
// For future chart integration (e.g., Chart.js or ECharts)
const chartRef = ref<HTMLCanvasElement | null>(null)
const chartReady = ref(false)
const { expenses } = useExpenses()
const currency = 'USD'
const totalExpenses = computed(() => expenses.value.reduce((sum: number, e: any) => sum + parseFloat(e.total_amount), 0))
const myBalance = ref(0) // Will be provided by backend balances endpoint later
function formatCurrency(amount: number, currency: string) {
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
} catch {
return amount.toFixed(2) + ' ' + currency
}
}
onMounted(() => {
// TODO: load chart library dynamically & render
chartReady.value = false
})
</script>
<style scoped>
/* Tailwind handles styles */
</style>

View File

@ -0,0 +1,42 @@
<template>
<Dialog v-model="open" class="z-50">
<div class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-xl w-full max-w-md">
<Heading :level="3" class="mb-4">{{ $t('settlementFlow.title', 'Settle Expense') }}</Heading>
<!-- Placeholder content -->
<p class="text-sm text-neutral-500 mb-6">{{ $t('settlementFlow.placeholder', 'Settlement flow coming soon…')
}}</p>
<div class="flex justify-end">
<Button variant="ghost" @click="open = false">{{ $t('common.close', 'Close') }}</Button>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { Dialog } from '@/components/ui'
import { Heading, Button } from '@/components/ui'
import type { ExpenseSplit, Expense } from '@/types/expense'
import { useExpenses } from '@/composables/useExpenses'
const open = defineModel<boolean>('modelValue', { default: false })
const props = defineProps<{ split: ExpenseSplit | null, expense: Expense | null }>()
const form = reactive<{ amount: string }>({ amount: '' })
const { settleExpenseSplit } = useExpenses()
async function handleSettle() {
if (!props.split) return
await settleExpenseSplit(props.split.id, {
expense_split_id: props.split.id,
paid_by_user_id: props.split.user_id,
amount_paid: form.amount || '0',
})
open.value = false
}
</script>
<style scoped></style>

View File

@ -1,11 +1,12 @@
<template>
<div class="neo-item-list-cotainer">
<div class="space-y-6">
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
<h3 v-if="group.items.length" class="text-lg font-semibold mb-2 px-1">{{ group.categoryName }}</h3>
<draggable :list="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
:disabled="!isOnline || supermarktMode" class="neo-item-list" ghost-class="sortable-ghost"
drag-class="sortable-drag">
:disabled="!isOnline || supermarktMode" class="space-y-1"
ghost-class="opacity-50 bg-neutral-100 dark:bg-neutral-800"
drag-class="bg-white dark:bg-neutral-900 shadow-lg">
<template #item="{ element: item }">
<ListItem :item="item" :is-online="isOnline" :category-options="categoryOptions"
:supermarkt-mode="supermarktMode" @delete-item="$emit('delete-item', item)"
@ -14,26 +15,30 @@
@save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)"
@update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event"
@update:editCategoryId="item.editCategoryId = $event"
@update:priceInput="item.priceInput = $event" />
@update:priceInput="item.priceInput = $event" :list="list" />
</template>
</draggable>
</div>
<!-- New Add Item LI, integrated into the list -->
<li class="neo-list-item new-item-input-container" v-show="!supermarktMode">
<label class="neo-checkbox-label">
<input type="checkbox" disabled />
<input type="text" class="neo-new-item-input"
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
:value="newItem.name"
@input="$emit('update:newItemName', ($event.target as HTMLInputElement).value)"
@keyup.enter="$emit('add-item')" @blur="handleNewItemBlur" @click.stop />
<VSelect
:model-value="newItem.category_id === null || newItem.category_id === undefined ? '' : newItem.category_id"
@update:modelValue="$emit('update:newItemCategoryId', $event === '' ? null : $event)"
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
</label>
</li>
<!-- New Add Item Form, integrated into the list -->
<div v-show="!supermarktMode" class="flex items-center gap-2 pt-2 border-t border-dashed">
<div class="flex-shrink-0 text-neutral-400">
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5" />
</div>
<form @submit.prevent="$emit('add-item')" class="flex-grow flex items-center gap-2">
<Input type="text" class="flex-grow"
:placeholder="t('listDetailPage.items.addItemForm.placeholder', 'Add a new item...')"
ref="itemNameInputRef" :model-value="newItem.name"
@update:modelValue="$emit('update:newItemName', $event)" @blur="handleNewItemBlur" />
<Listbox :model-value="newItem.category_id"
@update:modelValue="$emit('update:newItemCategoryId', $event)" class="w-40">
<!-- Simplified Listbox structure for brevity, assuming it exists -->
</Listbox>
<Button type="submit" size="sm" :disabled="!newItem.name.trim()">{{ t('listDetailPage.buttons.add',
'Add')
}}</Button>
</form>
</div>
</div>
</template>
@ -43,8 +48,14 @@ import type { PropType } from 'vue';
import draggable from 'vuedraggable';
import { useI18n } from 'vue-i18n';
import ListItem from './ListItem.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import type { Item } from '@/types/item';
import type { List } from '@/types/list';
// New component imports
import { Listbox } from '@headlessui/vue'; // This might not be needed if we make a full wrapper
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import BaseIcon from '@/components/BaseIcon.vue';
interface ItemWithUI extends Item {
updating: boolean;
@ -60,6 +71,10 @@ interface ItemWithUI extends Item {
}
const props = defineProps({
list: {
type: Object as PropType<List>,
required: true,
},
items: {
type: Array as PropType<ItemWithUI[]>,
required: true,
@ -169,87 +184,5 @@ defineExpose({
</script>
<style scoped>
.neo-checkbox-label {
display: flex;
align-items: center;
gap: 1rem;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 1.2rem;
padding-inline: 0;
margin-bottom: 0;
border-bottom: 1px solid #eee;
background: var(--light);
}
.new-item-input-container {
list-style: none !important;
padding-inline: 3rem;
padding-bottom: 1.2rem;
}
.new-item-input-container .neo-checkbox-label {
width: 100%;
}
.neo-new-item-input {
all: unset;
height: 100%;
width: 100%;
font-size: 1.05rem;
font-weight: 500;
color: #444;
padding: 0.2rem 0;
border-bottom: 1px dashed #ccc;
transition: border-color 0.2s ease;
}
.neo-new-item-input:focus {
border-bottom-color: var(--secondary);
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 400;
}
.sortable-ghost {
opacity: 0.5;
background: #f0f0f0;
}
.sortable-drag {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.category-group {
margin-bottom: 1.5rem;
}
.category-header {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
padding: 0 1.2rem;
}
.category-group.highlight .neo-list-item:not(.is-complete) {
background-color: #e6f7ff;
}
.w-40 {
width: 20%;
}
/* All styles removed as they are now handled by Tailwind utility classes */
</style>

View File

@ -1,10 +1,8 @@
<template>
<li class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync }">
<div class="neo-item-content">
<!-- Drag Handle -->
<div class="drag-handle" v-if="isOnline && !supermarktMode">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<div class="list-item-wrapper" :class="{ 'is-complete': item.is_complete }">
<div class="list-item-content">
<div class="drag-handle" v-if="isOnline">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
@ -14,484 +12,150 @@
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<!-- Content when NOT editing -->
<template v-if="!item.isEditing">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete"
@change="$emit('checkbox-change', item, ($event.target as HTMLInputElement).checked)" />
<div class="checkbox-content">
<div class="item-text-container">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<!-- User Information -->
<div class="item-user-info" v-if="item.added_by_user || item.completed_by_user">
<span v-if="item.added_by_user" class="user-badge added-by"
:title="$t('listDetailPage.items.addedByTooltip', { name: item.added_by_user.name })">
{{ $t('listDetailPage.items.addedBy') }} {{ item.added_by_user.name }}
</span>
<span v-if="item.is_complete && item.completed_by_user" class="user-badge completed-by"
:title="$t('listDetailPage.items.completedByTooltip', { name: item.completed_by_user.name })">
{{ $t('listDetailPage.items.completedBy') }} {{ item.completed_by_user.name }}
</span>
<div class="item-main-content">
<VCheckbox :model-value="item.is_complete" @update:model-value="onCheckboxChange" :label="item.name"
:quantity="item.quantity" />
<div v-if="claimStatus" class="claim-status-badge">
{{ claimStatus }}
</div>
</div>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="onPriceInput"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
step="0.01" @blur="$emit('update-price', item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</label>
<div class="neo-item-actions" v-if="!supermarktMode">
<button class="neo-icon-button neo-edit-button" @click.stop="$emit('start-edit', item)"
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<div class="item-actions">
<!-- Claim/Unclaim Buttons -->
<VButton v-if="canClaim" variant="outline" size="sm" @click="handleClaim">
Claim
</VButton>
<VButton v-if="canUnclaim" variant="outline" size="sm" @click="handleUnclaim">
Unclaim
</VButton>
<VInput v-if="item.is_complete" type="number" :model-value="item.price"
@update:model-value="$emit('update-price', item, $event)" placeholder="Price" class="price-input" />
<VButton @click="$emit('edit-item', item)" variant="ghost" size="sm" aria-label="Edit Item">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</VButton>
</div>
</template>
<!-- Content WHEN editing -->
<template v-else>
<div class="inline-edit-form flex-grow flex items-center gap-2">
<VInput type="text" :model-value="item.editName ?? ''"
@update:modelValue="$emit('update:editName', $event)" required class="flex-grow" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VInput type="number" :model-value="item.editQuantity || ''"
@update:modelValue="$emit('update:editQuantity', $event)" min="1" class="w-20" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VSelect :model-value="categoryModel" @update:modelValue="categoryModel = $event"
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-save-button" @click.stop="$emit('save-edit', item)"
:aria-label="$t('listDetailPage.buttons.saveChanges')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="neo-icon-button neo-cancel-button" @click.stop="$emit('cancel-edit', item)"
:aria-label="$t('listDetailPage.buttons.cancel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
</div>
</li>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Item } from '@/types/item';
import type { List } from '@/types/list';
import { useListsStore } from '@/stores/listsStore';
import { useAuthStore } from '@/stores/auth';
import { formatDistanceToNow } from 'date-fns';
import VCheckbox from '@/components/valerie/VCheckbox.vue';
import VButton from '@/components/valerie/VButton.vue';
import VInput from '@/components/valerie/VInput.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import { useOfflineStore } from '@/stores/offline';
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
isEditing?: boolean;
editName?: string;
editQuantity?: number | string | null;
editCategoryId?: number | null;
showFirework?: boolean;
}
const props = defineProps({
item: {
type: Object as PropType<ItemWithUI>,
type: Object as PropType<Item & { swiped?: boolean; }>,
required: true,
},
isOnline: {
type: Boolean,
list: {
type: Object as PropType<List>,
required: true,
},
categoryOptions: {
type: Array as PropType<{ label: string; value: number | null }[]>,
required: true,
},
supermarktMode: {
type: Boolean,
default: false,
},
isOnline: Boolean,
});
const emit = defineEmits([
'delete-item',
'checkbox-change',
'update-price',
'start-edit',
'save-edit',
'cancel-edit',
'update:editName',
'update:editQuantity',
'update:editCategoryId',
'update:priceInput'
]);
const emit = defineEmits(['delete-item', 'checkbox-change', 'update-price', 'edit-item']);
const { t } = useI18n();
const offlineStore = useOfflineStore();
const listsStore = useListsStore();
const authStore = useAuthStore();
const currentUser = computed(() => authStore.user);
const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
...opt,
value: opt.value === null ? '' : opt.value
})));
const categoryModel = computed({
get: () => props.item.editCategoryId === null || props.item.editCategoryId === undefined ? '' : props.item.editCategoryId,
set: (value) => {
emit('update:editCategoryId', value === '' ? null : value);
}
const canClaim = computed(() => {
return props.list.group_id && !props.item.is_complete && !props.item.claimed_by_user_id;
});
const isItemPendingSync = computed(() => {
return offlineStore.pendingActions.some(action => {
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
const payload = action.payload as { listId: string; itemId: string };
return payload.itemId === String(props.item.id);
}
return false;
});
const canUnclaim = computed(() => {
return props.item.claimed_by_user_id === currentUser.value?.id;
});
const onPriceInput = (value: string | number) => {
emit('update:priceInput', value);
}
const claimStatus = computed(() => {
if (!props.item.claimed_by_user) return '';
const claimer = props.item.claimed_by_user_id === currentUser.value?.id ? 'You' : props.item.claimed_by_user.name;
const time = props.item.claimed_at ? formatDistanceToNow(new Date(props.item.claimed_at), { addSuffix: true }) : '';
return `${claimer} claimed this ${time}`;
});
const handleClaim = () => {
if (!props.list.group_id) return; // Should not happen if button is shown, but good practice
listsStore.claimItem(props.item.id);
};
const handleUnclaim = () => {
listsStore.unclaimItem(props.item.id);
};
const onCheckboxChange = (checked: boolean) => {
emit('checkbox-change', props.item, checked);
};
</script>
<style scoped>
.neo-list-item {
padding: 1rem 0;
border-bottom: 1px solid #eee;
transition: background-color 0.2s ease;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
@media (max-width: 600px) {
.neo-list-item {
padding: 0.75rem 1rem;
}
}
.item-pending-sync {
/* You can add specific styling for pending items, e.g., a subtle glow or background */
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
.inline-edit-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-grow: 1;
}
.neo-icon-button {
padding: 0.5rem;
border-radius: 4px;
color: #666;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
}
.neo-icon-button:hover {
background: #f0f0f0;
color: #333;
}
.neo-edit-button {
color: #3b82f6;
}
.neo-edit-button:hover {
background: #eef7fd;
color: #2563eb;
}
.neo-delete-button {
color: #ef4444;
}
.neo-delete-button:hover {
background: #fee2e2;
color: #dc2626;
}
.neo-save-button {
color: #22c55e;
}
.neo-save-button:hover {
background: #dcfce7;
color: #16a34a;
}
.neo-cancel-button {
color: #ef4444;
}
.neo-cancel-button:hover {
background: #fee2e2;
color: #dc2626;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
/* Basic styling to make it look acceptable */
.list-item-wrapper {
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
overflow: hidden;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
.list-item-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
background-color: white;
border-bottom: 1px solid #e5e7eb;
transition: none;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
}
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
.neo-price-input {
display: inline-flex;
align-items: center;
margin-left: 0.5rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.neo-list-item:hover .neo-price-input {
opacity: 1;
.list-item-delete {
display: none;
}
.drag-handle {
cursor: grab;
padding: 0.5rem;
color: #666;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
color: #9ca3af;
margin-right: 0.75rem;
}
.neo-list-item:hover .drag-handle {
opacity: 0.5;
.item-main-content {
flex-grow: 1;
}
.drag-handle:hover {
opacity: 1 !important;
color: #333;
}
.drag-handle:active {
cursor: grabbing;
}
/* User Information Styles */
.item-text-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-user-info {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.user-badge {
.claim-status-badge {
font-size: 0.75rem;
color: #6b7280;
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid #e5e7eb;
white-space: nowrap;
margin-left: 2.25rem;
/* Align under checkbox */
margin-top: 0.25rem;
}
.user-badge.added-by {
color: #059669;
background: #ecfdf5;
border-color: #a7f3d0;
.item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-badge.completed-by {
color: #7c3aed;
background: #f3e8ff;
border-color: #c4b5fd;
.price-input {
width: 80px;
}
.is-complete .item-main-content {
opacity: 0.6;
}
.is-complete .VCheckbox :deep(label) {
text-decoration: line-through;
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div :class="classes" role="alert">
<div v-if="message">
{{ message }}
</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ type?: 'info' | 'success' | 'warning' | 'error'; message?: string }>(), {
type: 'info',
});
const typeClassMap: Record<string, string> = {
info: 'bg-blue-50 text-blue-800 border-blue-200',
success: 'bg-green-50 text-green-800 border-green-200',
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
error: 'bg-red-50 text-red-800 border-red-200',
};
const classes = computed(() => `border p-4 rounded-md ${typeClassMap[props.type]}`);
</script>

View File

@ -0,0 +1,9 @@
<template>
<div class="bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg shadow-sm p-4">
<slot />
</div>
</template>
<script setup lang="ts">
// A simple card component for content containment.
</script>

View File

@ -0,0 +1,13 @@
<template>
<component :is="tag" :class="[$attrs.class]">
<slot />
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ level?: 1 | 2 | 3 | 4 | 5 | 6 }>(), {
level: 2,
});
const tag = computed(() => `h${props.level}`);
</script>

View File

@ -0,0 +1,40 @@
<template>
<div>
<label v-if="label" :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ label
}}</label>
<input :id="id" v-bind="$attrs" :type="type" :class="inputClasses" v-model="modelValueProxy"
:aria-invalid="error ? 'true' : undefined" />
<p v-if="error" class="mt-1 text-sm text-danger">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, toRefs } from 'vue'
interface Props {
modelValue: string | number | null
label?: string
type?: string
error?: string | null
id?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
error: null,
})
const emit = defineEmits(['update:modelValue'])
const { modelValue } = toRefs(props)
const modelValueProxy = computed({
get: () => modelValue.value,
set: (val) => emit('update:modelValue', val),
})
const base = 'shadow-sm block w-full sm:text-sm rounded-md'
const theme = 'border-gray-300 dark:border-neutral-600 dark:bg-neutral-800 focus:ring-primary focus:border-primary'
const errorCls = 'border-danger focus:border-danger focus:ring-danger'
const inputClasses = computed(() => [base, theme, props.error ? errorCls : ''].join(' '))
</script>
<style scoped></style>

View File

@ -0,0 +1,11 @@
<template>
<div class="w-full bg-neutral-200 rounded-full h-2">
<div class="h-2 rounded-full bg-primary" :style="{ width: `${clampedValue}%` }" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ value?: number }>(), { value: 0 });
const clampedValue = computed(() => Math.max(0, Math.min(100, props.value)));
</script>

View File

@ -0,0 +1,13 @@
<template>
<div class="flex items-center justify-center" role="status" :aria-label="label">
<svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ label?: string; size?: 'sm' | 'md' | 'lg' }>();
</script>

View File

@ -0,0 +1,120 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import ChoreItem from '@/components/ChoreItem.vue';
import type { ChoreWithCompletion } from '@/types/chore';
// Minimal mock for a chore
const createMockChore = (overrides: Partial<ChoreWithCompletion> = {}): ChoreWithCompletion => ({
id: 1,
name: 'Test Chore',
description: 'A chore for testing.',
is_completed: false,
completed_at: null,
updating: false,
next_due_date: new Date().toISOString(),
current_assignment_id: 101,
// Base properties
group_id: null,
created_by_id: 1,
frequency: 'daily',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
type: 'personal',
assignments: [],
...overrides,
});
describe('ChoreItem.vue', () => {
it('renders chore name and description', () => {
const chore = createMockChore();
const wrapper = mount(ChoreItem, {
props: {
chore,
timeEntries: [],
activeTimer: null,
},
});
expect(wrapper.text()).toContain('Test Chore');
expect(wrapper.text()).toContain('A chore for testing.');
});
it('applies a line-through style when chore is completed', () => {
const chore = createMockChore({ is_completed: true });
const wrapper = mount(ChoreItem, {
props: {
chore,
timeEntries: [],
activeTimer: null,
},
});
const nameSpan = wrapper.find('span.line-through');
expect(nameSpan.exists()).toBe(true);
});
it('emits "toggle-completion" when the checkbox is clicked', async () => {
const chore = createMockChore();
const wrapper = mount(ChoreItem, {
props: { chore, timeEntries: [], activeTimer: null },
});
await wrapper.find('input[type="checkbox"]').setValue(true);
expect(wrapper.emitted('toggle-completion')).toHaveLength(1);
expect(wrapper.emitted('toggle-completion')![0]).toEqual([chore]);
});
it('emits "edit" when the edit button is clicked', async () => {
const chore = createMockChore();
const wrapper = mount(ChoreItem, {
props: { chore, timeEntries: [], activeTimer: null },
});
// Find button by icon name or a more specific selector if possible
await wrapper.find('button[title="Edit"]').trigger('click');
expect(wrapper.emitted('edit')).toHaveLength(1);
expect(wrapper.emitted('edit')![0]).toEqual([chore]);
});
it('emits "delete" when the delete button is clicked', async () => {
const chore = createMockChore();
const wrapper = mount(ChoreItem, {
props: { chore, timeEntries: [], activeTimer: null },
});
await wrapper.find('button[title="Delete"]').trigger('click');
expect(wrapper.emitted('delete')).toHaveLength(1);
expect(wrapper.emitted('delete')![0]).toEqual([chore]);
});
it('shows correct timer icon and emits "start-timer"', async () => {
const chore = createMockChore();
const wrapper = mount(ChoreItem, {
props: { chore, timeEntries: [], activeTimer: null },
});
const playButton = wrapper.find('button[title="Timer"]');
expect(playButton.find('svg[data-icon-name="heroicons:play-20-solid"]').exists()).toBe(true);
await playButton.trigger('click');
expect(wrapper.emitted('start-timer')).toHaveLength(1);
});
it('shows correct timer icon and emits "stop-timer" when timer is active', async () => {
const chore = createMockChore();
const activeTimer = { id: 99, chore_assignment_id: 101, start_time: new Date().toISOString(), user_id: 1 };
const wrapper = mount(ChoreItem, {
props: { chore, timeEntries: [], activeTimer },
});
const pauseButton = wrapper.find('button[title="Timer"]');
expect(pauseButton.find('svg[data-icon-name="heroicons:pause-20-solid"]').exists()).toBe(true);
await pauseButton.trigger('click');
expect(wrapper.emitted('stop-timer')).toHaveLength(1);
expect(wrapper.emitted('stop-timer')![0]).toEqual([chore, activeTimer.id]);
});
});

View File

@ -5,3 +5,9 @@ export { default as Listbox } from './Listbox.vue'
export { default as Tabs } from './Tabs.vue'
export { default as Switch } from './Switch.vue'
export { default as TransitionExpand } from './TransitionExpand.vue'
export { default as Input } from './Input.vue'
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'

View File

@ -1,5 +1,5 @@
import { ref, computed } from 'vue'
import type { Expense } from '@/types/expense'
import type { Expense, SettlementActivityCreate } from '@/types/expense'
import type { CreateExpenseData, UpdateExpenseData } from '@/services/expenseService'
import { expenseService } from '@/services/expenseService'
@ -74,6 +74,16 @@ export function useExpenses() {
}
}
const settleExpenseSplit = async (expense_split_id: number, activity: SettlementActivityCreate) => {
try {
await expenseService.settleExpenseSplit(expense_split_id, activity)
// refetch or update locally later
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to settle split'
throw err
}
}
const getExpense = async (id: number) => {
loading.value = true
error.value = null
@ -96,6 +106,7 @@ export function useExpenses() {
createExpense,
updateExpense,
deleteExpense,
settleExpenseSplit,
getExpense,
}
}

View File

@ -0,0 +1,24 @@
import { ref } from 'vue'
/**
* Round-robin assignment helper for chores or other rotating duties.
* Keeps internal pointer in reactive `index`.
*/
export function useFairness<T>() {
const members = ref<T[]>([])
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 }
}

View File

@ -0,0 +1,34 @@
import { onMounted, onUnmounted } from 'vue'
import { useOfflineStore } from '@/stores/offline'
/**
* 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 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)
})
return {
isOnline: offlineStore.isOnline,
pendingActions: offlineStore.pendingActions,
processQueue: offlineStore.processQueue,
}
}

View File

@ -0,0 +1,33 @@
import { reactive } from 'vue'
/**
* Generic optimistic-update helper.
*
* Usage:
* const { apply, confirm, rollback } = useOptimisticUpdates()
* apply(id, () => { item.done = true }, () => { item.done = false })
* 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<string, () => void>())
function apply(id: string, mutate: () => void, rollback: () => void) {
if (pending.has(id)) return
mutate()
pending.set(id, rollback)
}
function confirm(id: string) {
pending.delete(id)
}
function rollback(id: string) {
const fn = pending.get(id)
if (fn) fn()
pending.delete(id)
}
return { apply, confirm, rollback }
}

View File

@ -0,0 +1,111 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useChoreStore } from '@/stores/choreStore'
import { useExpenses } from '@/composables/useExpenses'
import { useGroupStore } from '@/stores/groupStore'
import { useAuthStore } from '@/stores/auth'
import type { Chore } from '@/types/chore'
interface NextAction {
type: 'chore' | 'expense' | 'none';
title: string;
subtitle: string;
cta: string;
path: string;
priority: number; // 1 = highest
}
export function usePersonalStatus() {
const choreStore = useChoreStore()
const groupStore = useGroupStore()
const { expenses, fetchExpenses } = useExpenses()
const authStore = useAuthStore()
const isLoading = ref(true)
const fetchAllData = async (groupId: number) => {
isLoading.value = true
await Promise.all([
choreStore.fetchGroup(groupId),
choreStore.fetchPersonal(),
fetchExpenses({ group_id: groupId }),
])
isLoading.value = false
}
onMounted(() => {
if (groupStore.currentGroupId) {
fetchAllData(groupStore.currentGroupId)
}
})
watch(() => groupStore.currentGroupId, (newGroupId) => {
if (newGroupId) {
fetchAllData(newGroupId)
}
})
const userChoresWithAssignments = computed(() => {
const userId = authStore.user?.id
if (!userId) return []
return choreStore.allChores.flatMap(chore =>
chore.assignments
.filter(assignment => assignment.assigned_to_user_id === userId)
.map(assignment => ({
choreName: chore.name,
...assignment
}))
)
})
const nextAction = computed<NextAction>(() => {
const now = new Date()
// Priority 1: Overdue chores
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 {
type: 'chore',
title: `Overdue: ${overdueChoreAssignment.choreName}`,
subtitle: `Was due on ${new Date(overdueChoreAssignment.due_date).toLocaleDateString()}`,
cta: 'View Chore',
path: `/chores`,
priority: 1,
}
}
// Placeholder for expense logic - to be implemented
// Priority 2: Upcoming chores
const upcomingChoreAssignment = userChoresWithAssignments.value
.filter(assignment => !assignment.is_complete)
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
if (upcomingChoreAssignment) {
return {
type: 'chore',
title: `Next up: ${upcomingChoreAssignment.choreName}`,
subtitle: `Due on ${new Date(upcomingChoreAssignment.due_date).toLocaleDateString()}`,
cta: 'View Chore',
path: `/chores`,
priority: 2,
}
}
return {
type: 'none',
title: "You're all caught up!",
subtitle: 'No urgent tasks or expenses. Great job!',
cta: 'Go to Dashboard',
path: '/dashboard',
priority: 99,
}
})
return {
nextAction,
isLoading,
}
}

View File

@ -16,6 +16,8 @@ export const API_ENDPOINTS = {
VERIFY_EMAIL: '/auth/verify',
RESET_PASSWORD: '/auth/forgot-password',
FORGOT_PASSWORD: '/auth/forgot-password',
MAGIC_LINK: '/auth/magic-link',
MAGIC_LINK_VERIFY: '/auth/magic-link/verify',
},
// Users
@ -73,6 +75,11 @@ export const API_ENDPOINTS = {
CHORE_HISTORY: (groupId: string) => `/groups/${groupId}/chores/history`,
},
// Activity
ACTIVITY: {
GET_BY_GROUP: (groupId: number) => `/groups/${groupId}/activity`,
},
// Invites
INVITES: {
BASE: '/invites',

View File

@ -1,93 +1,122 @@
<template>
<main class="container page-padding">
<VHeading level="1" :text="$t('accountPage.title')" class="mb-3" />
<Heading :level="1" class="mb-3">{{ $t('accountPage.title') }}</Heading>
<div v-if="loading" class="text-center">
<VSpinner :label="$t('accountPage.loadingProfile')" />
<Spinner :label="$t('accountPage.loadingProfile')" />
</div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<Alert v-else-if="error" color="danger" class="mb-3">
{{ error }}
<template #actions>
<VButton variant="danger" size="sm" @click="fetchProfile">{{ $t('accountPage.retryButton') }}</VButton>
<Button color="danger" size="sm" @click="fetchProfile">{{ $t('accountPage.retryButton') }}</Button>
</template>
</VAlert>
</Alert>
<form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section -->
<VCard class="mb-3">
<template #header>
<VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
<VInput id="profileName" v-model="profile.name" required />
</VFormField>
<VFormField :label="$t('accountPage.profileSection.emailLabel')" class="flex-grow">
<VInput type="email" id="profileEmail" v-model="profile.email" required readonly />
</VFormField>
<Card class="mb-3">
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<Heading :level="3">{{ $t('accountPage.profileSection.header') }}</Heading>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="saving">
<VSpinner v-if="saving" size="sm" /> {{ $t('accountPage.profileSection.saveButton') }}
</VButton>
</template>
</VCard>
<div class="px-4 py-5 sm:p-6">
<div class="flex flex-wrap" style="gap: 1rem">
<div class="flex-grow">
<label for="profileName" class="block text-sm font-medium text-gray-700">{{
$t('accountPage.profileSection.nameLabel')
}}</label>
<Input id="profileName" v-model="profile.name" required class="mt-1" />
</div>
<div class="flex-grow">
<label for="profileEmail" class="block text-sm font-medium text-gray-700">{{
$t('accountPage.profileSection.emailLabel')
}}</label>
<Input type="email" id="profileEmail" v-model="profile.email" required readonly class="mt-1" />
</div>
</div>
</div>
<div class="px-4 py-3 bg-gray-50 sm:px-6">
<Button type="submit" color="primary" :disabled="saving">
<Spinner v-if="saving" size="sm" class="mr-1" />
{{ $t('accountPage.profileSection.saveButton') }}
</Button>
</div>
</Card>
</form>
<!-- Password Section -->
<form @submit.prevent="onChangePassword">
<VCard class="mb-3">
<template #header>
<VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
<VInput type="password" id="currentPassword" v-model="password.current" required />
</VFormField>
<VFormField :label="$t('accountPage.passwordSection.newPasswordLabel')" class="flex-grow">
<VInput type="password" id="newPassword" v-model="password.newPassword" required />
</VFormField>
<Card class="mb-3">
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<Heading :level="3">{{ $t('accountPage.passwordSection.header') }}</Heading>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="changingPassword">
<VSpinner v-if="changingPassword" size="sm" /> {{ $t('accountPage.passwordSection.changeButton') }}
</VButton>
</template>
</VCard>
<div class="px-4 py-5 sm:p-6">
<div class="flex flex-wrap" style="gap: 1rem">
<div class="flex-grow">
<label for="currentPassword" class="block text-sm font-medium text-gray-700">{{
$t('accountPage.passwordSection.currentPasswordLabel')
}}</label>
<Input type="password" id="currentPassword" v-model="password.current" required class="mt-1" />
</div>
<div class="flex-grow">
<label for="newPassword" class="block text-sm font-medium text-gray-700">{{
$t('accountPage.passwordSection.newPasswordLabel')
}}</label>
<Input type="password" id="newPassword" v-model="password.newPassword" required class="mt-1" />
</div>
</div>
</div>
<div class="px-4 py-3 bg-gray-50 sm:px-6">
<Button type="submit" color="primary" :disabled="changingPassword">
<Spinner v-if="changingPassword" size="sm" class="mr-1" />
{{ $t('accountPage.passwordSection.changeButton') }}
</Button>
</div>
</Card>
</form>
<!-- Notifications Section -->
<VCard>
<template #header>
<VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading>
</template>
<VList class="preference-list">
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
<Card>
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<Heading :level="3">{{ $t('accountPage.notificationsSection.header') }}</Heading>
</div>
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange"
<ul class="divide-y divide-gray-200">
<li class="flex items-center justify-between p-4">
<div class="flex flex-col">
<span class="font-medium text-gray-900">{{
$t('accountPage.notificationsSection.emailNotificationsLabel')
}}</span>
<small class="text-sm text-gray-500">{{
$t('accountPage.notificationsSection.emailNotificationsDescription')
}}</small>
</div>
<Switch v-model="preferences.emailNotifications" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
</li>
<li class="flex items-center justify-between p-4">
<div class="flex flex-col">
<span class="font-medium text-gray-900">{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small class="text-sm text-gray-500">{{
$t('accountPage.notificationsSection.listUpdatesDescription')
}}</small>
</div>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange"
<Switch v-model="preferences.listUpdates" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
</li>
<li class="flex items-center justify-between p-4">
<div class="flex flex-col">
<span class="font-medium text-gray-900">{{
$t('accountPage.notificationsSection.groupActivitiesLabel')
}}</span>
<small class="text-sm text-gray-500">{{
$t('accountPage.notificationsSection.groupActivitiesDescription')
}}</small>
</div>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange"
<Switch v-model="preferences.groupActivities" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle" />
</VListItem>
</VList>
</VCard>
</li>
</ul>
</Card>
</main>
</template>
@ -97,16 +126,7 @@ import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/auth';
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
import { Heading, Spinner, Alert, Card, Input, Button, Switch } from '@/components/ui';
const { t } = useI18n();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
<template>
<div class="p-4 space-y-4">
<h1 class="text-2xl font-bold">Dashboard</h1>
<PersonalStatusCard />
<QuickChoreAdd />
<ActivityFeed />
<UniversalFAB />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useGroupStore } from '@/stores/groupStore';
import PersonalStatusCard from '@/components/dashboard/PersonalStatusCard.vue';
import ActivityFeed from '@/components/dashboard/ActivityFeed.vue';
import UniversalFAB from '@/components/dashboard/UniversalFAB.vue';
import QuickChoreAdd from '@/components/QuickChoreAdd.vue';
const groupStore = useGroupStore();
onMounted(() => {
groupStore.fetchUserGroups();
});
</script>

View File

@ -1,829 +1,88 @@
<template>
<div class="container">
<header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">Expenses</h1>
<button @click="openCreateExpenseModal" class="btn btn-primary">
Add Expense
</button>
</header>
<main class="p-4 max-w-screen-md mx-auto space-y-6">
<!-- Overview -->
<ExpenseOverview />
<div class="mb-4 flex items-center gap-2 justify-between" v-if="!loading && !error">
<div class="text-sm font-medium" v-if="authStore.getUser">
Your outstanding balance: <span class="font-mono">{{ formatCurrency(userOutstanding, 'USD') }}</span>
</div>
<label class="flex items-center text-sm"><input type="checkbox" v-model="showRecurringOnly"
class="mr-2">Show recurring only</label>
<!-- Controls -->
<div class="flex justify-end">
<Button variant="solid" @click="showCreate = true">
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5 mr-1" />
{{ $t('expensePage.addExpense', 'Add Expense') }}
</Button>
</div>
<div v-if="loading" class="flex justify-center">
<div class="spinner-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert">
<strong class="font-bold">Error:</strong>
<span class="block sm:inline">{{ error }}</span>
</div>
<!-- Error & Loading -->
<Spinner v-if="loading" :label="$t('expensePage.loading', 'Loading expenses')" />
<Alert v-else-if="error" type="error" :message="error" />
<div v-else-if="filteredExpenses.length === 0" class="empty-state-card">
<h3>No Expenses Yet</h3>
<p>Get started by adding your first expense!</p>
<button class="btn btn-primary" @click="openCreateExpenseModal">
Add First Expense
</button>
</div>
<!-- Expense List -->
<ExpenseList v-else :expenses="expenses" @edit="openEdit" @delete="confirmDelete" @settle="openSettle" />
<div v-else class="schedule-list">
<div v-for="group in groupedExpenses" :key="group.title" class="schedule-group">
<h2 class="date-header">{{ group.title }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<li v-for="expense in group.expenses" :key="expense.id" class="neo-list-item"
:class="{ 'is-expanded': expandedExpenseId === expense.id }">
<div class="neo-item-content">
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
<span class="checkbox-text-span">{{ expense.description }}</span>
<span v-if="isExpenseRecurring(expense)"
class="ml-2 inline-block rounded-full bg-indigo-100 text-indigo-700 px-2 py-0.5 text-xs font-semibold">Recurring
<template v-if="getNextOccurrence(expense)">
next {{ getNextOccurrence(expense) }}
</template>
</span>
<div class="item-subtext">
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
'N/A'
}}
· {{ formatCurrency(expense.total_amount, expense.currency) }}
<span :class="getStatusClass(expense.overall_settlement_status)"
class="status-badge">
{{ expense.overall_settlement_status.replace('_', ' ') }}
</span>
</div>
</div>
<div class="neo-item-actions">
<button @click.stop="openEditExpenseModal(expense)"
class="btn btn-sm btn-neutral">Edit</button>
<button @click.stop="handleDeleteExpense(expense.id)"
class="btn btn-sm btn-danger">Delete</button>
</div>
</div>
<div v-if="expandedExpenseId === expense.id"
class="w-full mt-2 pt-2 border-t border-gray-200/50 expanded-details">
<div>
<h3 class="font-semibold text-gray-700 mb-2 text-sm">Splits ({{
expense.split_type.replace('_', ' ') }})</h3>
<ul class="space-y-1">
<li v-for="split in expense.splits" :key="split.id"
class="flex justify-between items-center py-1 text-sm gap-2 flex-wrap">
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|| 'N/A' }} owes</span>
<span class="font-mono text-gray-800 font-semibold">{{
formatCurrency(split.owed_amount, expense.currency) }}</span>
<!-- Settlement progress -->
<span
v-if="split.settlement_activities && split.settlement_activities.length"
class="text-xs text-gray-500">Paid: {{
formatCurrency(calculatePaidAmount(split), expense.currency) }}</span>
<span v-if="calculateRemainingAmount(split) > 0"
class="text-xs text-red-600">Remaining: {{
formatCurrency(calculateRemainingAmount(split), expense.currency)
}}</span>
<!-- Settle button for current user -->
<button
v-if="authStore.getUser && authStore.getUser.id === split.user_id && calculateRemainingAmount(split) > 0"
@click.stop="handleSettleSplit(split, expense)"
class="ml-auto btn btn-sm btn-primary">Settle</button>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Create/Edit Expense Modal -->
<div v-if="showModal" class="modal-backdrop open" @click.self="closeModal">
<div class="modal-container">
<form @submit.prevent="handleFormSubmit">
<div class="modal-header">
<h3>{{ editingExpense ? 'Edit Expense' : 'Create New Expense' }}</h3>
<button type="button" @click="closeModal" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="description">Description</label>
<input type="text" v-model="formState.description" id="description" class="form-input"
required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div class="form-group">
<label for="total_amount" class="form-label">Total Amount</label>
<input type="number" step="0.01" min="0.01" v-model="formState.total_amount"
id="total_amount" class="form-input" required>
</div>
<div class="form-group">
<label for="currency" class="form-label">Currency</label>
<input type="text" v-model="formState.currency" id="currency" class="form-input"
required>
</div>
<div class="form-group">
<label for="paid_by_user_id" class="form-label">Paid By (User ID)</label>
<input type="number" v-model="formState.paid_by_user_id" id="paid_by_user_id"
class="form-input" required>
</div>
<div class="form-group">
<label for="split_type" class="form-label">Split Type</label>
<select v-model="formState.split_type" id="split_type" class="form-input" required>
<option value="EQUAL">Equal</option>
<option value="EXACT_AMOUNTS">Exact Amounts</option>
<option value="PERCENTAGE">Percentage</option>
<option value="SHARES">Shares</option>
<option value="ITEM_BASED">Item Based</option>
</select>
</div>
<div class="form-group">
<label for="group_id" class="form-label">Group ID (optional)</label>
<input type="number" v-model="formState.group_id" id="group_id" class="form-input">
</div>
<div class="form-group">
<label for="list_id" class="form-label">List ID (optional)</label>
<input type="number" v-model="formState.list_id" id="list_id" class="form-input">
</div>
</div>
<div class="form-group flex items-center mt-4">
<input type="checkbox" v-model="formState.isRecurring" id="is_recurring"
class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<label for="is_recurring" class="ml-2 block text-sm text-gray-900">This is a
recurring expense</label>
</div>
<!-- Placeholder for recurring pattern form -->
<div v-if="formState.isRecurring" class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
<p class="text-sm text-gray-500">Recurring expense options will be shown here.
</p>
</div>
<!-- Placeholder for splits input form -->
<div v-if="formState.split_type === 'EXACT_AMOUNTS' || formState.split_type === 'PERCENTAGE' || formState.split_type === 'SHARES'"
class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
<p class="text-sm text-gray-500">Inputs for {{ formState.split_type }} splits
will be shown here.</p>
</div>
<div v-if="formError" class="mt-3 bg-red-100 border-l-4 border-red-500 text-red-700 p-3">
<p>{{ formError }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" @click="closeModal" class="btn btn-neutral">Cancel</button>
<button type="submit" class="btn btn-primary">
{{ editingExpense ? 'Update Expense' : 'Create Expense' }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sheets -->
<ExpenseCreationSheet v-model="showCreate" :expense="editingExpense" />
<SettlementFlow v-model="showSettlement" :split="selectedSplit" :expense="selectedExpense" />
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
import { apiClient } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
import { ref, onMounted, watch } from 'vue'
import { useExpenses } from '@/composables/useExpenses'
import { Button, Spinner, Alert } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
import ExpenseOverview from '@/components/expenses/ExpenseOverview.vue'
import ExpenseList from '@/components/expenses/ExpenseList.vue'
import ExpenseCreationSheet from '@/components/expenses/ExpenseCreationSheet.vue'
import SettlementFlow from '@/components/expenses/SettlementFlow.vue'
import type { Expense } from '@/types/expense'
const props = defineProps<{
groupId?: number | string;
}>();
const props = defineProps<{ groupId?: number | string }>()
// Pinia store for current user context
const authStore = useAuthStore()
const notifStore = useNotificationStore()
// Reactive state collections
const expenses = ref<Expense[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const expandedExpenseId = ref<number | null>(null)
const showModal = ref(false)
const showCreate = ref(false)
const showSettlement = ref(false)
const editingExpense = ref<Expense | null>(null)
const formError = ref<string | null>(null)
const selectedSplit = ref<any | null>(null)
const selectedExpense = ref<Expense | null>(null)
// UI-level filters
const showRecurringOnly = ref(false)
const { expenses, loading, error, fetchExpenses, deleteExpense } = useExpenses()
// Aggregate outstanding balance for current user across expenses
const userOutstanding = computed(() => {
const userId = authStore.getUser?.id
if (!userId) return 0
let remaining = 0
expenses.value.forEach((exp) => {
exp.splits.forEach((sp) => {
if (sp.user_id === userId) {
remaining += calculateRemainingAmount(sp)
}
})
})
return remaining
onMounted(() => {
load()
})
const initialFormState: CreateExpenseData = {
description: '',
total_amount: '',
currency: 'USD',
split_type: 'EQUAL',
isRecurring: false,
paid_by_user_id: 0, // Should be current user id by default
list_id: undefined,
group_id: undefined,
splits_in: [],
watch(
() => props.groupId,
() => load(),
)
async function load() {
await fetchExpenses({ group_id: props.groupId ? Number(props.groupId) : undefined })
}
const formState = reactive<any>({ ...initialFormState })
const filteredExpenses = computed(() => {
let data = expenses.value
if (props.groupId) {
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
data = data.filter((expense) => expense.group_id === groupIdNum)
}
if (showRecurringOnly.value) {
data = data.filter((expense) => (expense as any).isRecurring || (expense as any).is_recurring)
}
return data
})
onMounted(async () => {
try {
loading.value = true
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch expenses.'
console.error(err)
} finally {
loading.value = false
}
})
const groupedExpenses = computed(() => {
if (!filteredExpenses.value) return [];
const expensesByDate = filteredExpenses.value.reduce((acc, expense) => {
const dateKey = expense.expense_date ? new Date(expense.expense_date).toISOString().split('T')[0] : 'nodate';
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(expense);
return acc;
}, {} as Record<string, Expense[]>);
return Object.keys(expensesByDate)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
.map(dateStr => {
const date = dateStr === 'nodate' ? null : new Date(dateStr);
return {
date,
title: date ? formatDateHeader(date) : 'No Date',
expenses: expensesByDate[dateStr]
};
});
});
const toggleExpenseDetails = (expenseId: number) => {
expandedExpenseId.value = expandedExpenseId.value === expenseId ? null : expenseId
}
const formatDateHeader = (date: Date) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const itemDate = new Date(date)
itemDate.setHours(0, 0, 0, 0)
const isToday = itemDate.getTime() === today.getTime()
if (isToday) {
return `Today, ${new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate)}`
}
return new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate);
}
const formatDate = (dateString?: string | Date) => {
if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric'
})
}
const formatCurrency = (amount: string | number, currency: string = 'USD') => {
const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(numericAmount);
}
const getStatusClass = (status: string) => {
const statusMap: Record<string, string> = {
unpaid: 'status-overdue',
partially_paid: 'status-due-today',
paid: 'status-completed',
}
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
const getNextOccurrence = (expense: Expense): string | null => {
const raw = (expense as any).next_occurrence ?? (expense as any).nextOccurrence ?? null
return raw ? formatDate(raw) : null
}
const isExpenseRecurring = (expense: Expense): boolean => {
return Boolean((expense as any).isRecurring ?? (expense as any).is_recurring)
}
const openCreateExpenseModal = () => {
editingExpense.value = null
Object.assign(formState, initialFormState)
if (props.groupId) {
formState.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
}
// TODO: Set formState.paid_by_user_id to current user's ID
// TODO: Fetch users/groups/lists for dropdowns
showModal.value = true
}
const openEditExpenseModal = (expense: Expense) => {
function openEdit(expense: Expense) {
editingExpense.value = expense
// Map snake_case from Expense to camelCase for CreateExpenseData/UpdateExpenseData
formState.description = expense.description
formState.total_amount = expense.total_amount
formState.currency = expense.currency
formState.split_type = expense.split_type
formState.isRecurring = (expense as any).is_recurring ?? (expense as any).isRecurring ?? false
formState.list_id = expense.list_id
formState.group_id = expense.group_id
formState.item_id = expense.item_id
formState.paid_by_user_id = expense.paid_by_user_id
formState.version = expense.version
// recurrencePattern and splits_in would need more complex mapping
showModal.value = true
showCreate.value = true
}
const closeModal = () => {
showModal.value = false
editingExpense.value = null
formError.value = null
}
const handleFormSubmit = async () => {
formError.value = null
const data: any = { ...formState }
if (data.list_id === '' || data.list_id === null) data.list_id = undefined
if (data.group_id === '' || data.group_id === null) data.group_id = undefined
try {
if (editingExpense.value) {
const updateData: UpdateExpenseData = {
...data,
version: editingExpense.value.version,
}
const updatedExpense = (await expenseService.updateExpense(editingExpense.value.id, updateData)) as any as Expense;
const index = expenses.value.findIndex(e => e.id === updatedExpense.id)
if (index !== -1) {
expenses.value[index] = updatedExpense
}
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
} else {
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
expenses.value.unshift(newExpense)
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense created', type: 'success' })
}
closeModal()
} catch (err: any) {
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
console.error(err)
}
}
const handleDeleteExpense = async (expenseId: number) => {
if (!confirm('Are you sure you want to delete this expense? This action cannot be undone.')) return
try {
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
await expenseService.deleteExpense(expenseId)
expenses.value = expenses.value.filter(e => e.id !== expenseId)
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete expense.'
console.error(err)
}
}
// -----------------------------
// Settlement-related helpers
// -----------------------------
const calculatePaidAmount = (split: ExpenseSplit): number =>
(split.settlement_activities || []).reduce((sum: number, act: SettlementActivity) => sum + parseFloat(act.amount_paid), 0)
const calculateRemainingAmount = (split: ExpenseSplit): number =>
parseFloat(split.owed_amount) - calculatePaidAmount(split)
const handleSettleSplit = async (split: ExpenseSplit, parentExpense: Expense) => {
if (!authStore.getUser?.id) {
alert('You need to be logged in to settle an expense.')
return
}
const remaining = calculateRemainingAmount(split)
if (remaining <= 0) return
if (!confirm(`Settle ${formatCurrency(remaining, parentExpense.currency)} now?`)) return
try {
await apiClient.post(`/financials/expense_splits/${split.id}/settle`, {
expense_split_id: split.id,
paid_by_user_id: authStore.getUser.id,
amount_paid: remaining.toFixed(2),
watch(showCreate, val => {
if (!val) editingExpense.value = null
})
// refresh expense list to get updated data
expenses.value = (await expenseService.getExpenses()) as Expense[]
} catch (err: any) {
console.error('Failed to settle split', err)
alert(err.response?.data?.detail || 'Failed to settle split.')
async function confirmDelete(expense: Expense) {
if (confirm('Delete this expense?')) {
await deleteExpense(expense.id)
}
}
function toggleDetails(expense: Expense) {
// TODO: open details expanded row or dialog
console.debug('details', expense)
}
function openSettle(payload: { split: any; expense: Expense }) {
selectedSplit.value = payload.split
selectedExpense.value = payload.expense
showSettlement.value = true
}
</script>
<style scoped lang="scss">
.container {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
header {
margin-bottom: 1.5rem;
}
.schedule-list {
margin-top: 1.5rem;
}
.schedule-group {
margin-bottom: 2rem;
position: relative;
}
.date-header {
font-size: clamp(1rem, 4vw, 1.2rem);
font-weight: bold;
color: var(--dark);
text-transform: none;
letter-spacing: normal;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--dark);
position: sticky;
top: 0;
background-color: var(--light);
z-index: 10;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 0.5rem 1rem;
margin: 0;
}
.neo-list-item {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-subtext {
font-size: 0.8rem;
color: var(--dark);
opacity: 0.7;
margin-top: 0.2rem;
}
.status-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
border-radius: 9999px;
font-weight: 600;
text-transform: capitalize;
margin-left: 0.5rem;
}
.status-unpaid {
background-color: #fef2f2;
color: #991b1b;
}
.status-partially_paid {
background-color: #fffbeb;
color: #92400e;
}
.status-paid {
background-color: #f0fdf4;
color: #166534;
}
.is-expanded {
.expanded-details {
max-height: 500px;
/* or a suitable value */
transition: max-height 0.5s ease-in-out;
}
}
.expanded-details {
padding-left: 1.5rem;
/* Indent details */
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-backdrop.open {
opacity: 1;
visibility: visible;
}
.modal-container {
background-color: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
transform: translateY(-20px);
transition: transform 0.3s ease;
}
.modal-backdrop.open .modal-container {
transform: translateY(0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.modal-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding-bottom: 1rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
color: #374151;
}
.form-input,
select.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4f46e5;
color: white;
border: 1px solid transparent;
}
.btn-primary:hover {
background-color: #4338ca;
}
.btn-neutral {
background-color: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-neutral:hover {
background-color: #f9fafb;
}
.btn-danger {
background-color: #dc2626;
color: white;
border: 1px solid transparent;
}
.btn-danger:hover {
background-color: #b91c1c;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.spinner-dots {
display: flex;
justify-content: center;
align-items: center;
}
.spinner-dots span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4f46e5;
margin: 0 4px;
animation: spinner-grow 1.4s infinite ease-in-out both;
}
.spinner-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.spinner-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes spinner-grow {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
.empty-state-card {
text-align: center;
padding: 3rem 1.5rem;
background-color: #f9fafb;
border: 2px dashed #e5e7eb;
border-radius: 0.75rem;
margin-top: 2rem;
}
.empty-state-card h3 {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.empty-state-card p {
margin-top: 0.5rem;
color: #6b7280;
}
.empty-state-card .btn {
margin-top: 1.5rem;
}
.neo-section-header {
font-weight: 900;
font-size: 1.25rem;
margin: 0;
margin-bottom: 1rem;
letter-spacing: 0.5px;
}
.badge-recurring {
background-color: #e0e7ff;
color: #4338ca;
}
</style>
<style scoped></style>

View File

@ -2,17 +2,19 @@
<main class="container page-padding">
<div class="group-detail-container">
<div v-if="loading" class="text-center">
<VSpinner :label="t('groupDetailPage.loadingLabel')" />
<Spinner :label="t('groupDetailPage.loadingLabel')" />
</div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}
</VButton>
</template>
</VAlert>
<Alert v-else-if="error" type="error" :message="error" class="mb-3">
<Button color="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}
</Button>
</Alert>
<div v-else-if="group">
<div class="flex justify-between items-start mb-4">
<VHeading :level="1" :text="group.name" class="header-title-text" />
<Heading :level="1">{{ group.name }}</Heading>
<Button variant="ghost" size="sm" @click="goToSettings"
:aria-label="t('groupDetailPage.settingsButton', 'Settings')" class="ml-2">
<BaseIcon name="heroicons:cog-6-tooth" class="h-6 w-6" />
</Button>
<div class="member-avatar-list">
<div ref="avatarsContainerRef" class="member-avatars">
<div v-for="member in group.members" :key="member.id" class="member-avatar">
@ -22,17 +24,20 @@
<div v-show="activeMemberMenu === member.id" ref="memberMenuRef" class="member-menu" @click.stop>
<div class="popup-header">
<span class="font-semibold truncate">{{ member.email }}</span>
<VButton variant="neutral" size="sm" :icon-only="true" iconLeft="x" @click="activeMemberMenu = null"
:aria-label="t('groupDetailPage.members.closeMenuLabel')" />
<Button variant="ghost" size="sm" @click="activeMemberMenu = null"
:aria-label="t('groupDetailPage.members.closeMenuLabel')">
<BaseIcon name="heroicons:x-mark" class="h-5 w-5" />
</Button>
</div>
<div class="member-menu-content">
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')"
:variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" class="w-full text-left"
<span :class="badgeClasses(member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary')">
{{ member.role || t('groupDetailPage.members.defaultRole') }}
</span>
<Button v-if="canRemoveMember(member)" color="danger" size="sm" class="w-full text-left mt-2"
@click="removeMember(member.id)" :disabled="removingMember === member.id">
<VSpinner v-if="removingMember === member.id" size="sm" class="mr-1" />
<Spinner v-if="removingMember === member.id" size="sm" class="mr-1" />
{{ t('groupDetailPage.members.removeButton') }}
</VButton>
</Button>
</div>
</div>
</div>
@ -44,25 +49,31 @@
<div v-show="showInviteUI" ref="inviteUIRef" class="invite-popup">
<div class="popup-header">
<VHeading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }}
</VHeading>
<VButton variant="neutral" size="sm" :icon-only="true" iconLeft="x" @click="showInviteUI = false"
:aria-label="t('groupDetailPage.invites.closeInviteLabel')" />
<Heading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }}
</Heading>
<Button variant="ghost" size="sm" @click="showInviteUI = false"
:aria-label="t('groupDetailPage.invites.closeInviteLabel')">
<BaseIcon name="heroicons:x-mark" class="h-5 w-5" />
</Button>
</div>
<p class="text-sm text-gray-500 my-2">{{ t('groupDetailPage.invites.description') }}</p>
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
<VSpinner v-if="generatingInvite" size="sm" /> {{ inviteCode ?
<Button color="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
<Spinner v-if="generatingInvite" size="sm" /> {{ inviteCode ?
t('groupDetailPage.invites.regenerateButton') :
t('groupDetailPage.invites.generateButton') }}
</VButton>
</Button>
<div v-if="inviteCode" class="neo-invite-code mt-3">
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
<div class="mb-1">
<label for="inviteCodeInput" class="block text-sm font-medium mb-1">{{
t('groupDetailPage.invites.activeCodeLabel') }}</label>
<div class="flex items-center gap-2">
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler"
:aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
<Input id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
<Button variant="outline" color="secondary" @click="copyInviteCodeHandler"
:aria-label="t('groupDetailPage.invites.copyButtonLabel')">
<BaseIcon name="heroicons:clipboard-document" class="h-5 w-5" />
</Button>
</div>
</div>
</VFormField>
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}
</p>
</div>
@ -84,9 +95,9 @@
<div class="mt-4 neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
<Heading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</Heading>
<div v-if="groupHistoryLoading" class="text-center">
<VSpinner />
<Spinner />
</div>
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
@ -98,21 +109,25 @@
</div>
</div>
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
<Alert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
<template #default>
<Dialog v-model="showChoreDetailModal" class="max-w-2xl w-full">
<div v-if="selectedChore" class="chore-detail-content">
<Heading :level="2" class="mb-4">{{ selectedChore.name }}</Heading>
<div class="chore-overview-section">
<div class="chore-status-summary">
<div class="status-badges">
<VBadge :text="formatFrequency(selectedChore.frequency)"
:variant="getFrequencyBadgeVariant(selectedChore.frequency)" />
<VBadge v-if="getDueDateStatus(selectedChore) === 'overdue'" text="Overdue" variant="danger" />
<VBadge v-if="getDueDateStatus(selectedChore) === 'due-today'" text="Due Today" variant="warning" />
<VBadge v-if="getChoreStatusInfo(selectedChore).isCompleted" text="Completed" variant="success" />
<div class="status-badges flex flex-wrap gap-2 mb-4">
<span :class="badgeClasses(getFrequencyBadgeVariant(selectedChore.frequency))">
{{ formatFrequency(selectedChore.frequency) }}
</span>
<span v-if="getDueDateStatus(selectedChore) === 'overdue'"
:class="badgeClasses('danger')">Overdue</span>
<span v-if="getDueDateStatus(selectedChore) === 'due-today'" :class="badgeClasses('warning')">Due
Today</span>
<span v-if="getChoreStatusInfo(selectedChore).isCompleted"
:class="badgeClasses('success')">Completed</span>
</div>
<div class="chore-meta-info">
<div class="meta-item">
@ -138,35 +153,38 @@
</div>
</div>
<div v-if="selectedChore.description" class="chore-description-full">
<VHeading :level="5">Description</VHeading>
<Heading :level="5">Description</Heading>
<p>{{ selectedChore.description }}</p>
</div>
</div>
<div class="assignments-section">
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
<Heading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</Heading>
<div v-if="loadingAssignments" class="loading-assignments">
<VSpinner size="sm" />
<Spinner size="sm" />
<span>Loading assignments...</span>
</div>
<div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list">
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card">
<template v-if="editingAssignment?.id === assignment.id">
<div class="editing-assignment">
<VFormField label="Assigned to:">
<VSelect v-if="group?.members"
<div class="editing-assignment space-y-3">
<div>
<label class="block text-sm font-medium mb-1">Assigned to:</label>
<Listbox v-if="group?.members"
:options="group.members.map(m => ({ value: m.id, label: m.email }))"
:model-value="editingAssignment.assigned_to_user_id || 0"
@update:model-value="val => editingAssignment && (editingAssignment.assigned_to_user_id = val)" />
</VFormField>
<VFormField label="Due date:">
<VInput type="date" :model-value="editingAssignment.due_date ?? ''"
@update:model-value="val => editingAssignment && (editingAssignment.assigned_to_user_id = Number(val))" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Due date:</label>
<Input type="date" :model-value="editingAssignment.due_date ?? ''"
@update:model-value="val => editingAssignment && (editingAssignment.due_date = val)" />
</VFormField>
<div class="editing-actions">
<VButton @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</VButton>
<VButton @click="cancelAssignmentEdit" variant="neutral" size="sm">{{ t('shared.cancel') }}
</VButton>
</div>
<div class="editing-actions flex gap-2">
<Button @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</Button>
<Button @click="cancelAssignmentEdit" variant="ghost" color="neutral" size="sm">{{
t('shared.cancel') }}
</Button>
</div>
</div>
</template>
@ -176,14 +194,14 @@
<div class="assigned-user-info">
<span class="user-name">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|| 'Unknown User' }}</span>
<VBadge v-if="assignment.is_complete" text="Completed" variant="success" />
<VBadge v-else-if="isAssignmentOverdue(assignment)" text="Overdue" variant="danger" />
<span v-if="assignment.is_complete" :class="badgeClasses('success')">Completed</span>
<span v-else-if="isAssignmentOverdue(assignment)" :class="badgeClasses('danger')">Overdue</span>
</div>
<div class="assignment-actions">
<VButton v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
variant="neutral">
<Button v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
variant="ghost" color="secondary">
{{ t('shared.edit') }}
</VButton>
</Button>
</div>
</div>
<div class="assignment-details">
@ -209,7 +227,7 @@
<div
v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)"
class="assignment-history-section">
<VHeading :level="4">Assignment History</VHeading>
<Heading :level="4">Assignment History</Heading>
<div class="history-timeline">
<div v-for="assignment in selectedChore.assignments" :key="`history-${assignment.id}`">
<div v-if="assignment.history && assignment.history.length > 0">
@ -225,7 +243,7 @@
</div>
<div class="chore-history-section">
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
<Heading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</Heading>
<div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline">
<div v-for="entry in selectedChore.history" :key="entry.id" class="history-entry">
<div class="history-timestamp">{{ format(new Date(entry.timestamp), 'MMM d, yyyy HH:mm') }}</div>
@ -236,22 +254,29 @@
<p v-else class="no-history">{{ t('groupDetailPage.choreDetailModal.noHistory') }}</p>
</div>
</div>
</template>
</VModal>
</Dialog>
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
<VInput type="date" v-model="scheduleForm.start_date" />
</VFormField>
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
<VInput type="date" v-model="scheduleForm.end_date" />
</VFormField>
<template #footer>
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
t('groupDetailPage.generateScheduleModal.generateButton') }}</VButton>
</template>
</VModal>
<Dialog v-model="showGenerateScheduleModal">
<Heading :level="3">{{ t('groupDetailPage.generateScheduleModal.title') }}</Heading>
<div class="mt-4 space-y-4">
<div>
<label class="block text-sm font-medium mb-1">{{ t('groupDetailPage.generateScheduleModal.startDateLabel')
}}</label>
<Input type="date" v-model="scheduleForm.start_date" />
</div>
<div>
<label class="block text-sm font-medium mb-1">{{ t('groupDetailPage.generateScheduleModal.endDateLabel')
}}</label>
<Input type="date" v-model="scheduleForm.end_date" />
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<Button @click="showGenerateScheduleModal = false" variant="ghost" color="neutral">{{ t('shared.cancel')
}}</Button>
<Button @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
t('groupDetailPage.generateScheduleModal.generateButton') }}</Button>
</div>
</Dialog>
</div>
</main>
</template>
@ -268,24 +293,21 @@ import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignm
import { format, formatDistanceToNow, parseISO, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
import { useAuthStore } from '@/stores/auth';
import type { BadgeVariant } from '@/components/valerie/VBadge.vue';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
import VButton from '@/components/valerie/VButton.vue';
import VBadge from '@/components/valerie/VBadge.vue';
import VInput from '@/components/valerie/VInput.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VIcon from '@/components/valerie/VIcon.vue';
import VModal from '@/components/valerie/VModal.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import {
Button,
Dialog,
Input,
Heading,
Spinner,
Alert,
Listbox,
} from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
import { onClickOutside } from '@vueuse/core'
import { groupService } from '../services/groupService';
import ChoresPage from './ChoresPage.vue';
import ExpensesPage from './ExpensesPage.vue';
import { useRouter } from 'vue-router';
const { t } = useI18n();
@ -369,6 +391,22 @@ const groupHistoryLoading = ref(false);
const loadingAssignments = ref(false);
const selectedChoreAssignments = ref<ChoreAssignment[]>([]);
const router = useRouter();
const badgeClasses = (variant: string) => {
const colorMap: Record<string, string> = {
primary: 'bg-primary text-white',
secondary: 'bg-secondary text-white',
success: 'bg-green-500 text-white',
warning: 'bg-yellow-500 text-white',
danger: 'bg-red-500 text-white',
neutral: 'bg-gray-500 text-white',
info: 'bg-blue-500 text-white',
accent: 'bg-purple-500 text-white'
};
return `inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colorMap[variant] || colorMap.neutral}`;
}
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
if (err && typeof err === 'object') {
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
@ -579,8 +617,8 @@ const formatFrequency = (frequency: ChoreFrequency) => {
return options[frequency] || frequency;
};
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
const colorMap: Record<ChoreFrequency, BadgeVariant> = {
const getFrequencyBadgeVariant = (frequency: ChoreFrequency) => {
const colorMap: Record<ChoreFrequency, string> = {
one_time: 'neutral',
daily: 'info',
weekly: 'success',
@ -761,6 +799,12 @@ const isAssignmentOverdue = (assignment: ChoreAssignment): boolean => {
return dueDate < today;
};
function goToSettings() {
if (groupId.value) {
router.push(`/groups/${groupId.value}/settings`);
}
}
onMounted(() => {
fetchGroupDetails();
loadUpcomingChores();

View File

@ -127,8 +127,6 @@ import { useStorage } from '@vueuse/core';
import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import CreateListModal from '@/components/CreateListModal.vue';
import VButton from '@/components/valerie/VButton.vue';
import VIcon from '@/components/valerie/VIcon.vue';
const { t } = useI18n();

View File

@ -0,0 +1,157 @@
<template>
<main class="container mx-auto max-w-3xl p-4">
<Heading :level="1" class="mb-6">{{ group?.name || t('householdSettings.title', 'Household Settings') }}
</Heading>
<!-- Rename household -->
<section class="mb-8">
<Heading :level="3" class="mb-2">{{ t('householdSettings.rename.title', 'Rename Household') }}</Heading>
<div class="flex gap-2 w-full max-w-md">
<Input v-model="editedName" class="flex-1"
:placeholder="t('householdSettings.rename.placeholder', 'New household name')" />
<Button :disabled="savingName || !editedName.trim() || editedName === group?.name" @click="saveName">
<Spinner v-if="savingName" size="sm" class="mr-1" />
{{ t('shared.save', 'Save') }}
</Button>
</div>
<Alert v-if="nameError" type="error" :message="nameError" class="mt-2" />
</section>
<!-- Members list -->
<section class="mb-8">
<Heading :level="3" class="mb-4">{{ t('householdSettings.members.title', 'Members') }}</Heading>
<ul v-if="group" class="space-y-3">
<li v-for="member in group.members" :key="member.id"
class="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 p-3 rounded-md">
<span>{{ member.email }}</span>
<Button v-if="canRemove(member)" variant="ghost" color="danger" size="sm"
@click="confirmRemove(member)">
{{ t('householdSettings.members.remove', 'Remove') }}
</Button>
</li>
</ul>
<Alert v-else type="info" :message="t('householdSettings.members.loading', 'Loading members...')" />
</section>
<!-- Invite manager -->
<InviteManager v-if="group" :group-id="group.id" />
<!-- Remove member confirm dialog -->
<Dialog v-model="showRemoveDialog">
<div class="space-y-4">
<Heading :level="3">{{ t('householdSettings.members.confirmTitle', 'Remove member?') }}</Heading>
<p>{{ confirmPrompt }}</p>
<div class="flex justify-end gap-2">
<Button variant="ghost" color="neutral" @click="showRemoveDialog = false">{{ t('shared.cancel',
'Cancel') }}</Button>
<Button variant="solid" color="danger" :disabled="removing" @click="removeConfirmed">
<Spinner v-if="removing" size="sm" class="mr-1" />
{{ t('shared.remove', 'Remove') }}
</Button>
</div>
</div>
</Dialog>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import Heading from '@/components/ui/Heading.vue'
import Input from '@/components/ui/Input.vue'
import Button from '@/components/ui/Button.vue'
import Alert from '@/components/ui/Alert.vue'
import Spinner from '@/components/ui/Spinner.vue'
import Dialog from '@/components/ui/Dialog.vue'
import InviteManager from '@/components/InviteManager.vue'
interface Member {
id: number
email: string
role?: string
}
interface Group {
id: number
name: string
members: Member[]
}
const route = useRoute()
const { t } = useI18n()
const groupId = Number(route.params.id || route.params.groupId)
const group = ref<Group | null>(null)
const loading = ref(true)
const loadError = ref<string | null>(null)
const editedName = ref('')
const savingName = ref(false)
const nameError = ref<string | null>(null)
const showRemoveDialog = ref(false)
const memberToRemove = ref<Member | null>(null)
const removing = ref(false)
const confirmPrompt = computed(() => {
const email = memberToRemove.value?.email ?? ''
return t('householdSettings.members.confirmMessage', { email }, `Are you sure you want to remove ${email} from the household?`)
})
function canRemove(member: Member): boolean {
return member.role !== 'owner'
}
async function fetchGroup() {
loading.value = true
loadError.value = null
try {
const res = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)))
group.value = res.data as Group
editedName.value = group.value.name
} catch (err: any) {
loadError.value = err?.response?.data?.detail || err.message || t('householdSettings.loadError', 'Failed to load household info')
} finally {
loading.value = false
}
}
async function saveName() {
if (!group.value || editedName.value.trim() === group.value.name) return
savingName.value = true
nameError.value = null
try {
const res = await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { name: editedName.value.trim() })
group.value = { ...group.value, name: res.data.name }
} catch (err: any) {
nameError.value = err?.response?.data?.detail || err.message || t('householdSettings.rename.error', 'Failed to rename household')
} finally {
savingName.value = false
}
}
function confirmRemove(member: Member) {
memberToRemove.value = member
showRemoveDialog.value = true
}
async function removeConfirmed() {
if (!group.value || !memberToRemove.value) return
removing.value = true
try {
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId), String(memberToRemove.value.id)))
group.value.members = group.value.members.filter(m => m.id !== memberToRemove.value!.id)
showRemoveDialog.value = false
} catch (err: any) {
// show error alert inside dialog maybe
} finally {
removing.value = false
}
}
onMounted(fetchGroup)
</script>
<style scoped></style>

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,25 @@
<template>
<main class="container page-padding">
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
</template>
</VAlert>
<Alert v-if="error" type="error" :message="error" class="mb-3">
<Button color="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</Button>
</Alert>
<VCard v-else-if="filteredLists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="t(noListsMessageKey)">
<template #default>
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
<p v-else>{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
</template>
<template #empty-actions>
<VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
<Card v-else-if="filteredLists.length === 0 && !loading">
<div class="text-center p-4">
<BaseIcon name="heroicons:clipboard-document-list" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">{{ t(noListsMessageKey) }}</h3>
<p v-if="!currentGroupId" class="mt-1 text-sm text-gray-500">{{ t('listsPage.emptyState.personalGlobalInfo') }}
</p>
<p v-else class="mt-1 text-sm text-gray-500">{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
<div class="mt-6">
<Button @click="showCreateModal = true">
<BaseIcon name="heroicons:plus" class="-ml-1 mr-2 h-5 w-5" />
{{ t('listsPage.createNewListButton') }}
</VButton>
</template>
</VCard>
</Button>
</div>
</div>
</Card>
<div v-else-if="loading && filteredLists.length === 0" class="loading-placeholder">
{{ t('listsPage.loadingLists') }}
@ -38,11 +39,12 @@
<div v-if="actionsMenuVisibleFor === list.id" class="actions-dropdown">
<ul>
<li @click.stop="archiveOrUnarchive(list)">
<VIcon :name="list.archived_at ? 'unarchive' : 'archive'" style="margin-right: 0.5rem;" />
<BaseIcon :name="list.archived_at ? 'heroicons:archive-box-x-mark' : 'heroicons:archive-box'"
class="mr-2 h-5 w-5" />
<span>{{ list.archived_at ? 'Unarchive' : 'Archive' }}</span>
</li>
<li @click.stop="deleteList(list)" class="text-danger">
<VIcon name="delete" style="margin-right: 0.5rem;" />
<BaseIcon name="heroicons:trash" class="mr-2 h-5 w-5 text-danger" />
<span>Delete</span>
</li>
</ul>
@ -90,11 +92,8 @@ import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import CreateListModal from '@/components/CreateListModal.vue';
import { useStorage, onClickOutside } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VButton from '@/components/valerie/VButton.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import VIcon from '@/components/valerie/VIcon.vue';
import { Alert, Button, Card } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
const { t } = useI18n();

View File

@ -7,39 +7,38 @@
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="email" class="form-label">{{ t('loginPage.emailLabel') }}</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
<Input v-model="email" :label="t('loginPage.emailLabel')" type="email" id="email" :error="formErrors.email"
autocomplete="email" />
</div>
<div class="form-group mb-3">
<label for="password" class="form-label">{{ t('loginPage.passwordLabel') }}</label>
<div class="input-with-icon-append">
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="current-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
:aria-label="t('loginPage.togglePasswordVisibilityLabel')">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg>
<Input v-model="password" :label="t('loginPage.passwordLabel')" :type="isPwdVisible ? 'text' : 'password'"
id="password" :error="formErrors.password" autocomplete="current-password" />
<button type="button" class="absolute right-3 top-[34px] text-sm text-gray-500"
@click="isPwdVisible = !isPwdVisible" :aria-label="t('loginPage.togglePasswordVisibilityLabel')">
{{ isPwdVisible ? 'Hide' : 'Show' }}
</button>
</div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
</div>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
{{ t('loginPage.loginButton') }}
</button>
<Button type="submit" :disabled="loading" class="w-full mt-2">
<span v-if="loading" class="animate-pulse">{{ t('loginPage.loginButton') }}</span>
<span v-else>{{ t('loginPage.loginButton') }}</span>
</Button>
<div class="divider my-3">or</div>
<button type="button" class="btn btn-secondary w-full" @click="handleGuestLogin" :disabled="loading">
<Button variant="outline" color="secondary" class="w-full" :disabled="loading" @click="handleGuestLogin">
Continue as Guest
</Button>
<button type="button" class="btn btn-outline w-full mt-2" @click="openSheet">
Email Magic Link
</button>
<AuthenticationSheet ref="sheet" />
<div class="text-center mt-2">
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
</div>
@ -58,6 +57,8 @@ import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useNotificationStore } from '@/stores/notifications';
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
import AuthenticationSheet from '@/components/AuthenticationSheet.vue';
import { Input, Button } from '@/components/ui';
const router = useRouter();
const route = useRoute();
@ -71,6 +72,11 @@ const isPwdVisible = ref(false);
const loading = ref(false);
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
const sheet = ref<InstanceType<typeof AuthenticationSheet>>()
function openSheet() {
sheet.value?.show()
}
const isValidEmail = (val: string): boolean => {
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return emailPattern.test(val);

View File

@ -6,49 +6,40 @@
</div>
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="name" class="form-label">{{ $t('signupPage.fullNameLabel') }}</label>
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name" />
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
<div class="form-group mb-2 relative">
<Input v-model="name" :label="$t('signupPage.fullNameLabel')" id="name" :error="formErrors.name"
autocomplete="name" />
</div>
<div class="form-group mb-2">
<label for="email" class="form-label">{{ $t('signupPage.emailLabel') }}</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
<Input v-model="email" :label="$t('signupPage.emailLabel')" type="email" id="email"
:error="formErrors.email" autocomplete="email" />
</div>
<div class="form-group mb-2">
<label for="password" class="form-label">{{ $t('signupPage.passwordLabel') }}</label>
<div class="input-with-icon-append">
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="new-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
:aria-label="$t('signupPage.togglePasswordVisibility')">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons -->
<div class="form-group mb-2 relative">
<Input v-model="password" :label="$t('signupPage.passwordLabel')" :type="isPwdVisible ? 'text' : 'password'"
id="password" :error="formErrors.password" autocomplete="new-password" />
<button type="button" class="absolute right-3 top-[34px] text-sm text-gray-500"
@click="isPwdVisible = !isPwdVisible" :aria-label="$t('signupPage.togglePasswordVisibility')">
{{ isPwdVisible ? 'Hide' : 'Show' }}
</button>
</div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
</div>
<div class="form-group mb-3">
<label for="confirmPassword" class="form-label">{{ $t('signupPage.confirmPasswordLabel') }}</label>
<input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
class="form-input" required autocomplete="new-password" />
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
<Input v-model="confirmPassword" :label="$t('signupPage.confirmPasswordLabel')"
:type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" :error="formErrors.confirmPassword"
autocomplete="new-password" />
</div>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
{{ $t('signupPage.submitButton') }}
</button>
<Button type="submit" class="w-full mt-2" :disabled="loading">
<span v-if="loading" class="animate-pulse">{{ $t('signupPage.submitButton') }}</span>
<span v-else>{{ $t('signupPage.submitButton') }}</span>
</Button>
<div class="text-center mt-2">
<router-link to="auth/login" class="link-styled">{{ $t('signupPage.loginLink') }}</router-link>
<router-link to="/auth/login" class="link-styled">{{ $t('signupPage.loginLink') }}</router-link>
</div>
</form>
</div>
@ -62,6 +53,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
import { useNotificationStore } from '@/stores/notifications';
import { Input, Button } from '@/components/ui';
const { t } = useI18n();
const router = useRouter();

View File

@ -1,266 +0,0 @@
import { mount, flushPromises, DOMWrapper } from '@vue/test-utils';
import ChoresPage from '../ChoresPage.vue'; // Adjust path
import { choreService } from '@/services/choreService';
import { groupService } from '@/services/groupService'; // Assuming loadGroups will use this
import { useNotificationStore } from '@/stores/notifications';
import { vi } from 'vitest';
import { format } from 'date-fns'; // Used by the component
// --- Mocks ---
vi.mock('@/services/choreService');
vi.mock('@/services/groupService');
vi.mock('@/stores/notifications', () => ({
useNotificationStore: vi.fn(() => ({
addNotification: vi.fn(),
})),
}));
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({ query: {} })), // Default mock for useRoute
}));
const mockChoreService = choreService as vi.Mocked<typeof choreService>;
const mockGroupService = groupService as vi.Mocked<typeof groupService>;
let mockNotificationStore: ReturnType<typeof useNotificationStore>;
// --- Test Data ---
const mockUserGroups = [
{ id: 1, name: 'Family', members: [], owner_id: 1, created_at: '', updated_at: '' },
{ id: 2, name: 'Work', members: [], owner_id: 1, created_at: '', updated_at: '' },
];
const mockPersonalChores = [
{ id: 101, name: 'Personal Task 1', type: 'personal' as const, frequency: 'daily' as const, next_due_date: '2023-10-01', description: 'My personal chore' },
];
const mockFamilyChores = [
{ id: 201, name: 'Clean Kitchen', type: 'group' as const, group_id: 1, frequency: 'weekly' as const, next_due_date: '2023-10-05', description: 'Family group chore' },
];
const mockWorkChores = [
{ id: 301, name: 'Project Report', type: 'group' as const, group_id: 2, frequency: 'monthly' as const, next_due_date: '2023-10-10', description: 'Work group chore' },
];
const allMockChores = [...mockPersonalChores, ...mockFamilyChores, ...mockWorkChores];
// Helper function to create a wrapper
const createWrapper = (props = {}) => {
return mount(ChoresPage, {
props,
global: {
stubs: {
// Could stub complex child components if needed, but not strictly necessary here yet
}
}
});
};
describe('ChoresPage.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
mockChoreService.getAllChores.mockResolvedValue([...allMockChores]); // Default success
// Mock for loadGroups, assuming it will call groupService.getUserGroups
// The component's loadGroups is a placeholder, so this mock is for when it's implemented.
// For now, we'll manually set groups ref in tests that need it for the modal.
mockGroupService.getUserGroups.mockResolvedValue([...mockUserGroups]);
mockNotificationStore = useNotificationStore() as vi.Mocked<ReturnType<typeof useNotificationStore>>;
(useNotificationStore as vi.Mock).mockReturnValue(mockNotificationStore);
});
describe('Rendering and Initial Data Fetching', () => {
it('fetches and displays chores on mount, grouped correctly', async () => {
const wrapper = createWrapper();
await flushPromises(); // For onMounted, getAllChores, loadGroups
expect(mockChoreService.getAllChores).toHaveBeenCalled();
// expect(mockGroupService.getUserGroups).toHaveBeenCalled(); // If loadGroups was implemented
// Check personal chores
expect(wrapper.text()).toContain('Personal Chores');
expect(wrapper.text()).toContain('Personal Task 1');
expect(wrapper.text()).toContain(format(new Date(mockPersonalChores[0].next_due_date), 'MMM d, yyyy'));
// Check group chores
expect(wrapper.text()).toContain(mockUserGroups[0].name); // Family
expect(wrapper.text()).toContain('Clean Kitchen');
expect(wrapper.text()).toContain(format(new Date(mockFamilyChores[0].next_due_date), 'MMM d, yyyy'));
expect(wrapper.text()).toContain(mockUserGroups[1].name); // Work
expect(wrapper.text()).toContain('Project Report');
expect(wrapper.text()).toContain(format(new Date(mockWorkChores[0].next_due_date), 'MMM d, yyyy'));
});
it('displays "No chores found" message when no chores exist', async () => {
mockChoreService.getAllChores.mockResolvedValueOnce([]);
const wrapper = createWrapper();
await flushPromises();
expect(wrapper.text()).toContain('No chores found. Get started by adding a new chore!');
});
it('displays "No chores in this group" if a group has no chores but others do', async () => {
mockChoreService.getAllChores.mockResolvedValueOnce([...mockPersonalChores]); // Only personal chores
const wrapper = createWrapper();
// Manually set groups for the dropdown/display logic
wrapper.vm.groups = [...mockUserGroups];
await flushPromises();
expect(wrapper.text()).toContain('Personal Chores');
expect(wrapper.text()).toContain('Personal Task 1');
// Check for group sections and their "no chores" message
for (const group of mockUserGroups) {
expect(wrapper.text()).toContain(group.name); // Group title should be there
// This relies on group.chores being empty in the computed `groupedChores`
// which it will be if getAllChores returns no chores for that group.
const groupSection = wrapper.findAll('h2.chores-group-title').find(h => h.text() === group.name);
expect(groupSection).toBeTruthy();
// Find the <p>No chores in this group.</p> within this group's section.
// This is a bit tricky; better to add test-ids or more specific selectors.
// For now, a broad check:
const groupCards = groupSection?.element.parentElement?.querySelectorAll('.neo-grid .neo-card');
if (!groupCards || groupCards.length === 0) {
expect(groupSection?.element.parentElement?.textContent).toContain('No chores in this group.');
}
}
});
it('handles error during chore fetching', async () => {
const error = new Error('Failed to load');
mockChoreService.getAllChores.mockRejectedValueOnce(error);
const wrapper = createWrapper();
await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
message: 'Failed to load chores', type: 'error'
});
});
});
describe('Chore Creation', () => {
it('opens create modal, submits form for personal chore, and reloads chores', async () => {
const wrapper = createWrapper();
await flushPromises(); // Initial load
// Manually set groups for the dropdown in the modal
wrapper.vm.groups = [...mockUserGroups];
await flushPromises();
await wrapper.find('button.btn-primary').filter(b => b.text().includes('New Chore')).trigger('click');
expect(wrapper.vm.showChoreModal).toBe(true);
expect(wrapper.vm.isEditing).toBe(false);
// Fill form
await wrapper.find('#name').setValue('New Test Personal Chore');
await wrapper.find('input[type="radio"][value="personal"]').setValue(true);
await wrapper.find('#description').setValue('A description');
await wrapper.find('#dueDate').setValue('2023-12-01');
// Frequency is 'daily' by default
mockChoreService.createChore.mockResolvedValueOnce({ id: 999, name: 'New Test Personal Chore', type: 'personal' } as any);
mockChoreService.getAllChores.mockResolvedValueOnce([]); // For reload
await wrapper.find('.neo-modal-footer .btn-primary').trigger('click'); // Save button
await flushPromises();
expect(mockChoreService.createChore).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Test Personal Chore',
type: 'personal',
description: 'A description',
next_due_date: '2023-12-01',
}));
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore created successfully', type: 'success' });
expect(wrapper.vm.showChoreModal).toBe(false);
expect(mockChoreService.getAllChores).toHaveBeenCalledTimes(2); // Initial + reload
});
it('submits form for group chore correctly', async () => {
const wrapper = createWrapper();
await flushPromises();
wrapper.vm.groups = [...mockUserGroups];
await flushPromises();
await wrapper.find('button.btn-primary').filter(b => b.text().includes('New Chore')).trigger('click');
await wrapper.find('#name').setValue('New Test Group Chore');
await wrapper.find('input[type="radio"][value="group"]').setValue(true);
await wrapper.find('#group').setValue(mockUserGroups[0].id.toString()); // Select first group
await wrapper.find('#description').setValue('Group chore desc');
await wrapper.find('#dueDate').setValue('2023-12-02');
mockChoreService.createChore.mockResolvedValueOnce({ id: 998, name: 'New Test Group Chore', type: 'group', group_id: mockUserGroups[0].id } as any);
await wrapper.find('.neo-modal-footer .btn-primary').trigger('click'); // Save button
await flushPromises();
expect(mockChoreService.createChore).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Test Group Chore',
type: 'group',
group_id: mockUserGroups[0].id,
description: 'Group chore desc',
next_due_date: '2023-12-02',
}));
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore created successfully', type: 'success' });
});
});
describe('Chore Editing', () => {
it('opens edit modal with chore data, submits changes, and reloads', async () => {
const wrapper = createWrapper();
await flushPromises(); // Initial load
wrapper.vm.groups = [...mockUserGroups];
await flushPromises();
// Find the first "Edit" button (for the first personal chore)
const editButton = wrapper.findAll('.btn-primary.btn-sm').find(b => b.text().includes('Edit'));
expect(editButton).toBeTruthy();
await editButton!.trigger('click');
expect(wrapper.vm.showChoreModal).toBe(true);
expect(wrapper.vm.isEditing).toBe(true);
expect(wrapper.find<HTMLInputElement>('#name').element.value).toBe(mockPersonalChores[0].name);
await wrapper.find('#name').setValue('Updated Personal Chore Name');
// The next_due_date in form is 'yyyy-MM-dd', original data might be different format
// The component tries to format it.
expect(wrapper.find<HTMLInputElement>('#dueDate').element.value).toBe(format(new Date(mockPersonalChores[0].next_due_date), 'yyyy-MM-dd'));
mockChoreService.updateChore.mockResolvedValueOnce({ ...mockPersonalChores[0], name: 'Updated Personal Chore Name' } as any);
mockChoreService.getAllChores.mockResolvedValueOnce([]); // For reload
await wrapper.find('.neo-modal-footer .btn-primary').trigger('click'); // Save button
await flushPromises();
expect(mockChoreService.updateChore).toHaveBeenCalledWith(
mockPersonalChores[0].id,
expect.objectContaining({ name: 'Updated Personal Chore Name' })
);
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore updated successfully', type: 'success' });
expect(wrapper.vm.showChoreModal).toBe(false);
expect(mockChoreService.getAllChores).toHaveBeenCalledTimes(2); // Initial + reload
});
});
describe('Chore Deletion', () => {
it('opens delete confirmation, confirms deletion, and reloads', async () => {
const wrapper = createWrapper();
await flushPromises(); // Initial load
const choreToDelete = mockPersonalChores[0];
// Find the first "Delete" button
const deleteButton = wrapper.findAll('.btn-danger.btn-sm').find(b => b.text().includes('Delete'));
expect(deleteButton).toBeTruthy();
await deleteButton!.trigger('click');
expect(wrapper.vm.showDeleteDialog).toBe(true);
expect(wrapper.vm.selectedChore).toEqual(choreToDelete);
mockChoreService.deleteChore.mockResolvedValueOnce(); // Mock successful deletion
mockChoreService.getAllChores.mockResolvedValueOnce([]); // For reload
await wrapper.find('.neo-modal-footer .btn-danger').trigger('click'); // Confirm Delete button
await flushPromises();
expect(mockChoreService.deleteChore).toHaveBeenCalledWith(choreToDelete.id, choreToDelete.type, choreToDelete.group_id);
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore deleted successfully', type: 'success' });
expect(wrapper.vm.showDeleteDialog).toBe(false);
expect(mockChoreService.getAllChores).toHaveBeenCalledTimes(2); // Initial + reload
});
});
});

View File

@ -5,7 +5,13 @@ const routes: RouteRecordRaw[] = [
path: '/',
component: () => import('../layouts/MainLayout.vue'),
children: [
{ path: '', redirect: '/lists' },
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../pages/DashboardPage.vue'),
meta: { keepAlive: true },
},
{
path: 'lists',
name: 'PersonalLists',
@ -32,6 +38,13 @@ const routes: RouteRecordRaw[] = [
props: true,
meta: { keepAlive: true },
},
{
path: 'groups/:id/settings',
name: 'GroupSettings',
component: () => import('../pages/HouseholdSettings.vue'),
props: true,
meta: { keepAlive: false },
},
{
path: 'groups/:groupId/lists',
name: 'GroupLists',

View File

@ -0,0 +1,20 @@
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { PaginatedActivityResponse } from '@/types/activity'
async function getGroupActivity(
groupId: number,
limit: number,
cursor?: number | null
): Promise<PaginatedActivityResponse> {
const endpoint = API_ENDPOINTS.ACTIVITY.GET_BY_GROUP(groupId);
const params: Record<string, any> = { limit };
if (cursor) {
params.cursor = cursor;
}
const response = await apiClient.get(endpoint, { params });
return response.data as PaginatedActivityResponse;
}
export const activityService = {
getGroupActivity,
};

View File

@ -1,4 +1,4 @@
import type { Expense, RecurrencePattern } from '@/types/expense'
import type { Expense, RecurrencePattern, SettlementActivityCreate } from '@/types/expense'
import { api, API_ENDPOINTS } from '@/services/api'
export interface CreateExpenseData {
@ -78,4 +78,10 @@ export const expenseService = {
async getRecurringExpenses(): Promise<Expense[]> {
return this.getExpenses({ isRecurring: true })
},
async settleExpenseSplit(expense_split_id: number, activity: SettlementActivityCreate) {
const endpoint = `/financials/expense_splits/${expense_split_id}/settle`
const response = await api.post(endpoint, activity)
return response.data
},
}

View File

@ -0,0 +1,138 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useChoreStore } from '../choreStore'
import type { Chore } from '@/types/chore'
import { apiClient } from '@/services/api'
import type { AxiosResponse } from 'axios'
// Mock choreService
vi.mock('@/services/choreService', () => ({
choreService: {
getPersonalChores: vi.fn(),
getChores: vi.fn(),
createChore: vi.fn(),
updateChore: vi.fn(),
deleteChore: vi.fn(),
},
}))
import { choreService } from '@/services/choreService'
const mockSvc = choreService as unknown as {
getPersonalChores: ReturnType<typeof vi.fn>
getChores: ReturnType<typeof vi.fn>
createChore: ReturnType<typeof vi.fn>
updateChore: ReturnType<typeof vi.fn>
deleteChore: ReturnType<typeof vi.fn>
}
// Helper to create a mock Axios response
const mockAxiosResponse = <T>(data: T): AxiosResponse<T> => ({
data,
status: 200,
statusText: 'OK',
headers: {},
config: {} as any,
})
vi.mock('@/services/api', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
API_ENDPOINTS: {
CHORES: {
ASSIGNMENT_BY_ID: (id: number) => `/assignment/${id}`,
TIME_ENTRY: (id: number) => `/time-entry/${id}`,
}
}
}));
describe('choreStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(apiClient.get).mockClear()
vi.mocked(apiClient.post).mockClear()
vi.mocked(apiClient.put).mockClear()
})
it('fetchPersonal populates personal chores', async () => {
const mock: Chore[] = [
{
id: 1,
name: 'Dishwashing',
created_by_id: 1,
frequency: 'daily',
next_due_date: '',
created_at: '',
updated_at: '',
type: 'personal',
assignments: [],
} as unknown as Chore,
]
mockSvc.getPersonalChores.mockResolvedValue(mock)
const store = useChoreStore()
await store.fetchPersonal()
expect(mockSvc.getPersonalChores).toHaveBeenCalled()
expect(store.personal).toEqual(mock)
})
it('fetches time entries for an assignment', async () => {
const store = useChoreStore()
const assignmentId = 1
const mockEntries = [{ id: 101, start_time: new Date().toISOString() }]
vi.mocked(apiClient.get).mockResolvedValue(mockAxiosResponse(mockEntries))
await store.fetchTimeEntries(assignmentId)
expect(apiClient.get).toHaveBeenCalledWith(`/assignment/${assignmentId}/time-entries`)
expect(store.timeEntriesByAssignment[assignmentId]).toEqual(mockEntries)
expect(store.error).toBeNull()
})
it('starts a timer for an assignment', async () => {
const store = useChoreStore()
const assignmentId = 2
const newEntry = { id: 102, start_time: new Date().toISOString(), assignment_id: assignmentId }
vi.mocked(apiClient.post).mockResolvedValue(mockAxiosResponse(newEntry))
const result = await store.startTimer(assignmentId)
expect(apiClient.post).toHaveBeenCalledWith(`/assignment/${assignmentId}/time-entries`)
expect(store.timeEntriesByAssignment[assignmentId]).toContainEqual(newEntry)
expect(result).toEqual(newEntry)
})
it('stops a timer for a time entry', async () => {
const store = useChoreStore()
const assignmentId = 3
const timeEntryId = 103
const initialEntry = { id: timeEntryId, start_time: '2023-01-01T10:00:00Z', end_time: null, assignment_id: assignmentId }
const updatedEntry = { ...initialEntry, end_time: new Date().toISOString() }
store.timeEntriesByAssignment[assignmentId] = [initialEntry]
vi.mocked(apiClient.put).mockResolvedValue(mockAxiosResponse(updatedEntry))
const result = await store.stopTimer(assignmentId, timeEntryId)
expect(apiClient.put).toHaveBeenCalledWith(`/time-entry/${timeEntryId}`)
expect(store.timeEntriesByAssignment[assignmentId][0]).toEqual(updatedEntry)
expect(result).toEqual(updatedEntry)
})
it('handles errors when fetching time entries', async () => {
const store = useChoreStore()
const assignmentId = 4
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network Error'))
await store.fetchTimeEntries(assignmentId)
expect(store.error).toContain('Failed to fetch time entries')
expect(store.timeEntriesByAssignment[assignmentId]).toBeUndefined()
})
})

View File

@ -0,0 +1,56 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useItemStore } from '../itemStore'
import type { Item } from '@/types/item'
import { API_ENDPOINTS } from '@/config/api-config'
// Mock the apiClient used inside the store
vi.mock('@/services/api', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
API_ENDPOINTS,
}))
import { apiClient } from '@/services/api'
const mockApi = apiClient as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
describe('itemStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('fetchItems populates state', async () => {
const listId = 1
const mockItems: Item[] = [
{
id: 10,
name: 'Milk',
quantity: 1,
is_complete: false,
list_id: listId,
created_at: '',
updated_at: '',
version: 1,
},
]
mockApi.get.mockResolvedValue({ data: mockItems })
const store = useItemStore()
await store.fetchItems(listId)
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.LISTS.ITEMS(String(listId)))
expect(store.itemsByList[listId]).toEqual(mockItems)
})
})

View File

@ -0,0 +1,93 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { activityService } from '@/services/activityService'
import { API_BASE_URL } from '@/config/api-config'
import type { Activity } from '@/types/activity'
export const useActivityStore = defineStore('activity', () => {
const activities = ref<Activity[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const cursor = ref<number | null>(null)
const hasMore = ref(true)
const currentGroupId = ref<number | null>(null)
const socket = ref<WebSocket | null>(null)
async function fetchActivities(groupId: number, limit: number = 20) {
if (isLoading.value || !hasMore.value) return;
isLoading.value = true
error.value = null
if (groupId !== currentGroupId.value) {
// Reset if group changes
activities.value = []
cursor.value = null
hasMore.value = true
}
currentGroupId.value = groupId
try {
const response = await activityService.getGroupActivity(groupId, limit, cursor.value)
activities.value.push(...response.items)
cursor.value = response.cursor
if (!response.cursor || response.items.length < limit) {
hasMore.value = false
}
} catch (e: any) {
error.value = e.message || 'Failed to fetch activities'
} finally {
isLoading.value = false
}
}
function buildWsUrl(groupId: number, token: string | null): string {
const base = import.meta.env.DEV ? `ws://${location.host}/ws/${groupId}` : `${API_BASE_URL.replace('https', 'wss').replace('http', 'ws')}/ws/${groupId}`
return token ? `${base}?token=${token}` : base
}
function connectWebSocket(groupId: number, token: string | null) {
if (socket.value) {
socket.value.close()
}
const url = buildWsUrl(groupId, token)
socket.value = new WebSocket(url)
socket.value.onopen = () => {
console.debug('[WS] Connected to', url)
}
socket.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data && data.event_type) {
activities.value.unshift(data as Activity)
}
} catch (e) {
console.error('[WS] failed to parse message', e)
}
}
socket.value.onclose = () => {
console.debug('[WS] closed')
socket.value = null
}
socket.value.onerror = (e) => {
console.error('[WS] error', e)
}
}
function disconnectWebSocket() {
if (socket.value) {
socket.value.close()
socket.value = null
}
}
return {
activities,
isLoading,
error,
hasMore,
fetchActivities,
connectWebSocket,
disconnectWebSocket,
}
})

View File

@ -141,6 +141,19 @@ export const useAuthStore = defineStore('auth', () => {
await router.push('/auth/login')
}
const requestMagicLink = async (email: string) => {
const response = await api.post(API_ENDPOINTS.AUTH.MAGIC_LINK, { email })
return response.data as { detail: string; token: string }
}
const verifyMagicLink = async (token: string) => {
const response = await api.get(API_ENDPOINTS.AUTH.MAGIC_LINK_VERIFY, { params: { token } })
const { access_token, refresh_token } = response.data
setTokens({ access_token, refresh_token })
await fetchCurrentUser()
return response.data
}
return {
accessToken,
user,
@ -158,5 +171,7 @@ export const useAuthStore = defineStore('auth', () => {
loginAsGuest,
signup,
logout,
requestMagicLink,
verifyMagicLink,
}
})

211
fe/src/stores/choreStore.ts Normal file
View File

@ -0,0 +1,211 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment } from '@/types/chore'
import { choreService } from '@/services/choreService'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { TimeEntry } from '@/types/time_entry'
export const useChoreStore = defineStore('chores', () => {
// ---- State ----
const personal = ref<Chore[]>([])
const groupById = ref<Record<number, Chore[]>>({})
const isLoading = ref(false)
const error = ref<string | null>(null)
const timeEntriesByAssignment = ref<Record<number, TimeEntry[]>>({})
// ---- Getters ----
const allChores = computed<Chore[]>(() => [
...personal.value,
...Object.values(groupById.value).flat(),
])
const activeTimerEntry = computed<TimeEntry | null>(() => {
for (const assignmentId in timeEntriesByAssignment.value) {
const entry = timeEntriesByAssignment.value[assignmentId].find((te) => !te.end_time);
if (entry) return entry;
}
return null;
});
function setError(message: string) {
error.value = message
isLoading.value = false
}
// ---- Actions ----
async function fetchPersonal() {
isLoading.value = true
error.value = null
try {
personal.value = await choreService.getPersonalChores()
} catch (e: any) {
setError(e?.message || 'Failed to load personal chores')
} finally {
isLoading.value = false
}
}
async function fetchGroup(groupId: number) {
isLoading.value = true
error.value = null
try {
const chores = await choreService.getChores(groupId)
groupById.value[groupId] = chores
} catch (e: any) {
setError(e?.message || 'Failed to load group chores')
} finally {
isLoading.value = false
}
}
async function create(chore: ChoreCreate) {
try {
const created = await choreService.createChore(chore)
if (created.type === 'personal') {
personal.value.push(created)
} else if (created.group_id != null) {
if (!groupById.value[created.group_id]) groupById.value[created.group_id] = []
groupById.value[created.group_id].push(created)
}
return created
} catch (e: any) {
setError(e?.message || 'Failed to create chore')
throw e
}
}
async function update(choreId: number, updates: ChoreUpdate, original: Chore) {
try {
const updated = await choreService.updateChore(choreId, updates, original)
// Remove from previous list
if (original.type === 'personal') {
personal.value = personal.value.filter((c) => c.id !== choreId)
} else if (original.group_id != null) {
groupById.value[original.group_id] = (groupById.value[original.group_id] || []).filter((c) => c.id !== choreId)
}
// Add to new list
if (updated.type === 'personal') {
personal.value.push(updated)
} else if (updated.group_id != null) {
if (!groupById.value[updated.group_id]) groupById.value[updated.group_id] = []
groupById.value[updated.group_id].push(updated)
}
return updated
} catch (e: any) {
setError(e?.message || 'Failed to update chore')
throw e
}
}
async function remove(choreId: number, choreType: ChoreType, groupId?: number) {
try {
await choreService.deleteChore(choreId, choreType, groupId)
if (choreType === 'personal') {
personal.value = personal.value.filter((c) => c.id !== choreId)
} else if (groupId != null) {
groupById.value[groupId] = (groupById.value[groupId] || []).filter((c) => c.id !== choreId)
}
} catch (e: any) {
setError(e?.message || 'Failed to delete chore')
throw e
}
}
async function fetchTimeEntries(assignmentId: number) {
try {
const response = await apiClient.get(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`)
timeEntriesByAssignment.value[assignmentId] = response.data
} catch (e: any) {
setError(e?.message || `Failed to fetch time entries for assignment ${assignmentId}`)
}
}
async function startTimer(assignmentId: number) {
try {
const response = await apiClient.post(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`)
if (!timeEntriesByAssignment.value[assignmentId]) {
timeEntriesByAssignment.value[assignmentId] = []
}
timeEntriesByAssignment.value[assignmentId].push(response.data)
return response.data
} catch (e: any) {
setError(e?.message || `Failed to start timer for assignment ${assignmentId}`)
throw e
}
}
async function stopTimer(assignmentId: number, timeEntryId: number) {
try {
const response = await apiClient.put(`${API_ENDPOINTS.CHORES.TIME_ENTRY(timeEntryId)}`)
const entries = timeEntriesByAssignment.value[assignmentId] || []
const index = entries.findIndex((t) => t.id === timeEntryId)
if (index > -1) {
entries[index] = response.data
}
return response.data
} catch (e: any) {
setError(e?.message || `Failed to stop timer for entry ${timeEntryId}`)
throw e
}
}
async function toggleCompletion(chore: Chore, assignment: ChoreAssignment | null) {
try {
let currentAssignment = assignment;
if (!currentAssignment) {
// create assignment default user
currentAssignment = await choreService.createAssignment({
chore_id: chore.id,
assigned_to_user_id: chore.created_by_id,
due_date: chore.next_due_date,
});
}
const newStatus = !currentAssignment.is_complete;
let updatedAssignment: ChoreAssignment;
if (newStatus) {
updatedAssignment = await choreService.completeAssignment(currentAssignment.id);
} else {
updatedAssignment = await choreService.updateAssignment(currentAssignment.id, { is_complete: false });
}
// update local state: find chore and update assignment status.
function applyToList(list: Chore[]) {
const cIndex = list.findIndex((c) => c.id === chore.id);
if (cIndex === -1) return;
const c = list[cIndex];
const aIndex = c.assignments.findIndex((a) => a.id === updatedAssignment.id);
if (aIndex !== -1) {
c.assignments[aIndex] = updatedAssignment;
}
}
if (chore.type === 'personal') {
applyToList(personal.value);
} else if (chore.group_id != null) {
applyToList(groupById.value[chore.group_id] || []);
}
return updatedAssignment;
} catch (e: any) {
setError(e?.message || 'Failed to toggle completion');
throw e;
}
}
return {
personal,
groupById,
allChores,
isLoading,
error,
fetchPersonal,
fetchGroup,
create,
update,
remove,
setError,
timeEntriesByAssignment,
activeTimerEntry,
fetchTimeEntries,
startTimer,
stopTimer,
toggleCompletion
}
})

View File

@ -1,37 +1,52 @@
import { defineStore } from 'pinia';
import { groupService, type Group } from '@/services/groupService';
import { ref, computed } from 'vue';
import { groupService } from '@/services/groupService';
import type { GroupPublic as Group } from '@/types/group';
export const useGroupStore = defineStore('group', {
state: () => ({
groups: [] as Group[],
isLoading: false,
error: null as Error | null,
}),
actions: {
async fetchGroups() {
// Small cache implemented to prevent re-fetching on every mount
if (this.groups.length > 0) {
return;
}
this.isLoading = true;
this.error = null;
export const useGroupStore = defineStore('group', () => {
const groups = ref<Group[]>([]);
const isLoading = ref(false);
const currentGroupId = ref<number | null>(null);
const fetchUserGroups = async () => {
isLoading.value = true;
try {
this.groups = await groupService.getUserGroups();
groups.value = await groupService.getUserGroups();
// Set the first group as current by default if not already set
if (!currentGroupId.value && groups.value.length > 0) {
currentGroupId.value = groups.value[0].id;
}
} catch (error) {
this.error = error as Error;
console.error('Failed to fetch groups:', error);
} finally {
this.isLoading = false;
}
},
},
getters: {
groupCount: (state) => state.groups.length,
firstGroupId: (state): number | null => {
if (state.groups.length === 1) {
return state.groups[0].id;
}
return null;
}
isLoading.value = false;
}
};
const setCurrentGroupId = (id: number) => {
currentGroupId.value = id;
};
const currentGroup = computed(() => {
if (!currentGroupId.value) return null;
return groups.value.find(g => g.id === currentGroupId.value);
});
const groupCount = computed(() => groups.value.length);
const firstGroupId = computed(() => groups.value[0]?.id ?? null);
// Alias for backward compatibility
const fetchGroups = fetchUserGroups;
return {
groups,
isLoading,
currentGroupId,
currentGroup,
fetchUserGroups,
fetchGroups,
setCurrentGroupId,
groupCount,
firstGroupId,
};
});

111
fe/src/stores/itemStore.ts Normal file
View File

@ -0,0 +1,111 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Item } from '@/types/item'
import { apiClient, API_ENDPOINTS } from '@/services/api'
interface CreateItemPayload {
listId: number
data: {
name: string
quantity?: string | number | null
category_id?: number | null
}
}
interface UpdateItemPayload {
listId: number
itemId: number
data: Partial<
Omit<
Item,
| 'id'
| 'list_id'
| 'added_by_id'
| 'completed_by_id'
| 'added_by_user'
| 'completed_by_user'
| 'created_at'
| 'updated_at'
> & { version: number }
>
}
export const useItemStore = defineStore('items', () => {
const itemsByList = ref<Record<number, Item[]>>({})
const isLoading = ref(false)
const error = ref<string | null>(null)
function setError(message: string) {
error.value = message
isLoading.value = false
}
async function fetchItems(listId: number) {
isLoading.value = true
error.value = null
try {
const endpoint = API_ENDPOINTS.LISTS.ITEMS(String(listId))
const { data } = await apiClient.get(endpoint)
itemsByList.value[listId] = data as Item[]
} catch (e: any) {
setError(e?.response?.data?.detail || e.message || 'Failed to load items')
} finally {
isLoading.value = false
}
}
async function addItem(payload: CreateItemPayload) {
const { listId, data } = payload
try {
const endpoint = API_ENDPOINTS.LISTS.ITEMS(String(listId))
const { data: created } = await apiClient.post(endpoint, data)
if (!itemsByList.value[listId]) itemsByList.value[listId] = []
itemsByList.value[listId].push(created as Item)
return created as Item
} catch (e: any) {
setError(e?.response?.data?.detail || e.message || 'Failed to add item')
throw e
}
}
async function updateItem(payload: UpdateItemPayload) {
const { listId, itemId, data } = payload
try {
const endpoint = API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId))
const { data: updated } = await apiClient.put(endpoint, data)
const arr = itemsByList.value[listId]
if (arr) {
const idx = arr.findIndex((it) => it.id === itemId)
if (idx !== -1) arr[idx] = updated as Item
}
return updated as Item
} catch (e: any) {
setError(e?.response?.data?.detail || e.message || 'Failed to update item')
throw e
}
}
async function deleteItem(listId: number, itemId: number, expectedVersion?: number) {
try {
const endpoint = API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId))
const config = expectedVersion ? { params: { expected_version: expectedVersion } } : {}
await apiClient.delete(endpoint, config)
const arr = itemsByList.value[listId]
if (arr) itemsByList.value[listId] = arr.filter((it) => it.id !== itemId)
} catch (e: any) {
setError(e?.response?.data?.detail || e.message || 'Failed to delete item')
throw e
}
}
return {
itemsByList,
isLoading,
error,
fetchItems,
addItem,
updateItem,
deleteItem,
setError,
}
})

311
fe/src/stores/listsStore.ts Normal file
View File

@ -0,0 +1,311 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { List } from '@/types/list';
import type { Item } from '@/types/item';
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
import { useAuthStore } from './auth';
import { API_BASE_URL } from '@/config/api-config';
export interface ListWithDetails extends List {
items: Item[];
expenses: Expense[];
}
export const useListsStore = defineStore('lists', () => {
// --- STATE ---
const currentList = ref<ListWithDetails | null>(null);
const isLoading = ref(false);
const error = ref<string | null>(null);
const isSettlingSplit = ref(false);
const socket = ref<WebSocket | null>(null);
// Properties to support legacy polling logic in ListDetailPage, will be removed later.
const lastListUpdate = ref<string | null>(null);
const lastItemCount = ref<number | null>(null);
// Getters
const items = computed(() => currentList.value?.items || []);
const expenses = computed(() => currentList.value?.expenses || []);
function getPaidAmountForSplit(splitId: number): number {
let totalPaid = 0;
if (currentList.value && currentList.value.expenses) {
for (const expense of currentList.value.expenses) {
const split = expense.splits.find((s) => s.id === splitId);
if (split && split.settlement_activities) {
totalPaid = split.settlement_activities.reduce((sum, activity) => {
return sum + parseFloat(activity.amount_paid);
}, 0);
break;
}
}
}
return totalPaid;
}
// --- ACTIONS ---
async function fetchListDetails(listId: string) {
isLoading.value = true;
error.value = null;
try {
const listResponse = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(listId));
const itemsResponse = await apiClient.get(API_ENDPOINTS.LISTS.ITEMS(listId));
const expensesResponse = await apiClient.get(API_ENDPOINTS.LISTS.EXPENSES(listId));
currentList.value = {
...listResponse.data,
items: itemsResponse.data,
expenses: expensesResponse.data,
};
// Update polling properties
lastListUpdate.value = listResponse.data.updated_at;
lastItemCount.value = itemsResponse.data.length;
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch list details.';
console.error(err);
} finally {
isLoading.value = false;
}
}
async function claimItem(itemId: number) {
const item = currentList.value?.items.find((i: Item) => i.id === itemId);
if (!item || !currentList.value) return;
const originalClaimedById = item.claimed_by_user_id;
const authStore = useAuthStore();
const userId = Number(authStore.user!.id);
item.claimed_by_user_id = userId;
item.claimed_by_user = { id: userId, name: authStore.user!.name, email: authStore.user!.email };
item.claimed_at = new Date().toISOString();
try {
const response = await apiClient.post(`/items/${itemId}/claim`);
item.version = response.data.version;
} catch (err: any) {
item.claimed_by_user_id = originalClaimedById;
item.claimed_by_user = null;
item.claimed_at = null;
error.value = err.response?.data?.detail || 'Failed to claim item.';
}
}
async function unclaimItem(itemId: number) {
const item = currentList.value?.items.find((i: Item) => i.id === itemId);
if (!item) return;
const originalClaimedById = item.claimed_by_user_id;
const originalClaimedByUser = item.claimed_by_user;
const originalClaimedAt = item.claimed_at;
item.claimed_by_user_id = null;
item.claimed_by_user = null;
item.claimed_at = null;
try {
const response = await apiClient.delete(`/items/${itemId}/claim`);
item.version = response.data.version;
} catch (err: any) {
item.claimed_by_user_id = originalClaimedById;
item.claimed_by_user = originalClaimedByUser;
item.claimed_at = originalClaimedAt;
error.value = err.response?.data?.detail || 'Failed to unclaim item.';
}
}
async function settleExpenseSplit(payload: { expense_split_id: number; activity_data: SettlementActivityCreate }) {
isSettlingSplit.value = true;
error.value = null;
try {
const endpoint = `/financials/expense_splits/${payload.expense_split_id}/settle`;
await apiClient.post(endpoint, payload.activity_data);
if (currentList.value?.id) {
await fetchListDetails(String(currentList.value.id));
}
return true;
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to settle expense split.';
return false;
} finally {
isSettlingSplit.value = false;
}
}
function buildWsUrl(listId: number, token: string | null): string {
const base = import.meta.env.DEV
? `ws://${location.host}/ws/lists/${listId}`
: `${API_BASE_URL.replace(/^http/, 'ws')}/ws/lists/${listId}`;
return token ? `${base}?token=${token}` : base;
}
function connectWebSocket(listId: number, token: string | null) {
if (socket.value) {
socket.value.close();
}
const url = buildWsUrl(listId, token);
socket.value = new WebSocket(url);
socket.value.onopen = () => console.debug(`[WS] Connected to list channel ${listId}`);
socket.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'item_claimed') {
handleItemClaimed(data.payload);
} else if (data.type === 'item_unclaimed') {
handleItemUnclaimed(data.payload);
}
// Handle other item events here
} catch (e) {
console.error('[WS] Failed to parse message:', e);
}
};
socket.value.onclose = () => {
console.debug(`[WS] Disconnected from list channel ${listId}`);
socket.value = null;
};
socket.value.onerror = (e) => console.error('[WS] Error:', e);
}
function disconnectWebSocket() {
if (socket.value) {
socket.value.close();
}
}
// --- WEBSOCKET HANDLERS ---
function handleItemClaimed(payload: any) {
if (!currentList.value) return;
const item = currentList.value.items.find((i: Item) => i.id === payload.item_id);
if (item) {
item.claimed_by_user_id = payload.claimed_by.id;
item.claimed_by_user = payload.claimed_by;
item.claimed_at = payload.claimed_at;
item.version = payload.version;
}
}
function handleItemUnclaimed(payload: any) {
const item = items.value.find((i: Item) => i.id === payload.item_id);
if (item) {
item.claimed_by_user_id = null;
item.claimed_by_user = null;
item.claimed_at = null;
item.version = payload.version;
}
}
async function addItem(listId: string | number, payload: { name: string; quantity?: string | null; category_id?: number | null }) {
if (!currentList.value) return;
error.value = null;
try {
// Optimistic: push placeholder item with temporary id (-Date.now())
const tempId = -Date.now();
const tempItem: Item = {
id: tempId,
name: payload.name,
quantity: payload.quantity ?? null,
is_complete: false,
price: null,
list_id: Number(listId),
category_id: payload.category_id ?? null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
completed_at: null,
version: 0,
added_by_id: useAuthStore().user?.id ?? 0,
completed_by_id: null,
} as unknown as Item;
currentList.value.items.push(tempItem);
const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(listId)), payload);
// Replace temp item with actual item
const index = currentList.value.items.findIndex(i => i.id === tempId);
if (index !== -1) {
currentList.value.items[index] = response.data;
} else {
currentList.value.items.push(response.data);
}
// Update lastListUpdate etc.
lastItemCount.value = currentList.value.items.length;
lastListUpdate.value = response.data.updated_at;
return response.data;
} catch (err: any) {
// Rollback optimistic
currentList.value.items = currentList.value.items.filter(i => i.id >= 0);
error.value = err.response?.data?.detail || 'Failed to add item.';
throw err;
}
}
async function updateItem(listId: string | number, itemId: number, updates: Partial<Item> & { version: number }) {
if (!currentList.value) return;
error.value = null;
const item = currentList.value.items.find(i => i.id === itemId);
if (!item) return;
const original = { ...item };
// Optimistic merge
Object.assign(item, updates);
try {
const response = await apiClient.put(API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId)), updates);
// ensure store item updated with server version
const index = currentList.value.items.findIndex(i => i.id === itemId);
if (index !== -1) {
currentList.value.items[index] = response.data;
}
return response.data;
} catch (err: any) {
// rollback
const index = currentList.value.items.findIndex(i => i.id === itemId);
if (index !== -1) {
currentList.value.items[index] = original;
}
error.value = err.response?.data?.detail || 'Failed to update item.';
throw err;
}
}
async function deleteItem(listId: string | number, itemId: number, version: number) {
if (!currentList.value) return;
error.value = null;
const index = currentList.value.items.findIndex(i => i.id === itemId);
if (index === -1) return;
const [removed] = currentList.value.items.splice(index, 1);
try {
await apiClient.delete(`${API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId))}?expected_version=${version}`);
lastItemCount.value = currentList.value.items.length;
return true;
} catch (err: any) {
// rollback
currentList.value.items.splice(index, 0, removed);
error.value = err.response?.data?.detail || 'Failed to delete item.';
throw err;
}
}
return {
currentList,
items,
expenses,
isLoading,
error,
isSettlingSplit,
lastListUpdate,
lastItemCount,
fetchListDetails,
claimItem,
unclaimItem,
settleExpenseSplit,
getPaidAmountForSplit,
handleItemClaimed,
handleItemUnclaimed,
connectWebSocket,
disconnectWebSocket,
addItem,
updateItem,
deleteItem,
};
});

View File

@ -1,79 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { TimeEntry, TimeEntryCreate, TimeEntryUpdate } from '@/types';
export interface TimeEntry {
id: number;
chore_assignment_id: number;
user_id: number;
start_time: string;
end_time?: string | null;
duration_seconds?: number | null;
}
export const useTimeEntryStore = defineStore('timeEntry', () => {
const timeEntries = ref<Record<number, TimeEntry[]>>({});
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchTimeEntriesForAssignment(assignmentId: number) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.get(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`);
timeEntries.value[assignmentId] = response.data;
} catch (e) {
error.value = 'Failed to fetch time entries.';
console.error(e);
} finally {
loading.value = false;
}
}
async function startTimeEntry(assignmentId: number) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.post(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`);
if (!timeEntries.value[assignmentId]) {
timeEntries.value[assignmentId] = [];
}
timeEntries.value[assignmentId].push(response.data);
} catch (e) {
error.value = 'Failed to start time entry.';
console.error(e);
} finally {
loading.value = false;
}
}
async function stopTimeEntry(assignmentId: number, timeEntryId: number) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.put(`${API_ENDPOINTS.CHORES.TIME_ENTRY(timeEntryId)}`);
const assignmentEntries = timeEntries.value[assignmentId];
if (assignmentEntries) {
const index = assignmentEntries.findIndex(te => te.id === timeEntryId);
if (index !== -1) {
assignmentEntries[index] = response.data;
}
}
} catch (e) {
error.value = 'Failed to stop time entry.';
console.error(e);
} finally {
loading.value = false;
}
}
return {
timeEntries,
loading,
error,
fetchTimeEntriesForAssignment,
startTimeEntry,
stopTimeEntry,
};
});

28
fe/src/types/activity.ts Normal file
View File

@ -0,0 +1,28 @@
export enum ActivityEventType {
CHORE_COMPLETED = "chore_completed",
CHORE_CREATED = "chore_created",
EXPENSE_CREATED = "expense_created",
EXPENSE_SETTLED = "expense_settled",
ITEM_ADDED = "item_added",
ITEM_COMPLETED = "item_completed",
USER_JOINED_GROUP = "user_joined_group",
}
export interface ActivityUser {
id: number;
name: string | null;
}
export interface Activity {
id: string;
event_type: ActivityEventType;
timestamp: string; // ISO 8601 date string
user: ActivityUser;
details: Record<string, any>;
message: string;
}
export interface PaginatedActivityResponse {
items: Activity[];
cursor: number | null;
}

View File

@ -77,6 +77,7 @@ export interface ChoreWithCompletion extends Chore {
updating: boolean;
assigned_user_name?: string;
completed_by_name?: string;
assigned_to_user_id?: number | null;
parent_chore_id?: number | null;
child_chores?: ChoreWithCompletion[];
subtext?: string;

View File

@ -1,3 +1,5 @@
import type { UserPublic } from './user';
export interface UserReference {
id: number;
name: string;
@ -13,9 +15,13 @@ export interface Item {
category_id?: number | null;
created_at: string;
updated_at: string;
completed_at?: string | null;
version: number;
added_by_id?: number;
completed_by_id?: number | null;
added_by_user?: { id: number; name: string };
completed_by_user?: { id: number; name: string } | null;
added_by_user?: UserPublic;
completed_by_user?: UserPublic | null;
claimed_by_user_id?: number | null;
claimed_at?: string | null;
claimed_by_user?: UserPublic | null;
}

View File

@ -0,0 +1,9 @@
export interface TimeEntry {
id: number;
chore_assignment_id: number;
start_time: string; // ISO
end_time: string | null;
duration_seconds?: number | null;
created_at?: string;
updated_at?: string;
}