Merge pull request 'ph5' (#60) from ph5 into prod

Reviewed-on: #60
This commit is contained in:
mo 2025-06-08 02:04:07 +02:00
commit d13a231113
31 changed files with 4431 additions and 1489 deletions

View File

@ -0,0 +1,75 @@
"""Add chore history and scheduling tables
Revision ID: 05bf96a9e18b
Revises: 91d00c100f5b
Create Date: 2025-06-08 00:41:10.516324
"""
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 = '05bf96a9e18b'
down_revision: Union[str, None] = '91d00c100f5b'
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.create_table('chore_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chore_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chore_history_chore_id'), 'chore_history', ['chore_id'], unique=False)
op.create_index(op.f('ix_chore_history_group_id'), 'chore_history', ['group_id'], unique=False)
op.create_index(op.f('ix_chore_history_id'), 'chore_history', ['id'], unique=False)
op.create_table('chore_assignment_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chore_assignment_history_assignment_id'), 'chore_assignment_history', ['assignment_id'], unique=False)
op.create_index(op.f('ix_chore_assignment_history_id'), 'chore_assignment_history', ['id'], unique=False)
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
op.drop_table('apscheduler_jobs')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
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)
op.drop_index(op.f('ix_chore_assignment_history_id'), table_name='chore_assignment_history')
op.drop_index(op.f('ix_chore_assignment_history_assignment_id'), table_name='chore_assignment_history')
op.drop_table('chore_assignment_history')
op.drop_index(op.f('ix_chore_history_id'), table_name='chore_history')
op.drop_index(op.f('ix_chore_history_group_id'), table_name='chore_history')
op.drop_index(op.f('ix_chore_history_chore_id'), table_name='chore_history')
op.drop_table('chore_history')
# ### end Alembic commands ###

View File

@ -8,8 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session
from app.auth import current_active_user
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic
from app.schemas.chore import (
ChoreCreate, ChoreUpdate, ChorePublic,
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
)
from app.crud import chore as crud_chore
from app.crud import history as crud_history
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
logger = logging.getLogger(__name__)
@ -451,3 +456,65 @@ async def complete_chore_assignment(
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
# === CHORE HISTORY ENDPOINTS ===
@router.get(
"/{chore_id}/history",
response_model=PyList[ChoreHistoryPublic],
summary="Get Chore History",
tags=["Chores", "History"]
)
async def get_chore_history(
chore_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the history of a specific chore."""
# First, check if user has permission to view the chore itself
chore = await crud_chore.get_chore_by_id(db, chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=chore_id)
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise PermissionDeniedError("You can only view history for your own personal chores.")
if chore.type == ChoreTypeEnum.group:
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
if not is_member:
raise PermissionDeniedError("You must be a member of the group to view this chore's history.")
logger.info(f"User {current_user.email} getting history for chore {chore_id}")
return await crud_history.get_chore_history(db=db, chore_id=chore_id)
@router.get(
"/assignments/{assignment_id}/history",
response_model=PyList[ChoreAssignmentHistoryPublic],
summary="Get Chore Assignment History",
tags=["Chore Assignments", "History"]
)
async def get_chore_assignment_history(
assignment_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the history of a specific chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
# Check permission by checking permission on the parent chore
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=assignment.chore_id) # Should not happen if assignment exists
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise PermissionDeniedError("You can only view history for assignments of your own personal chores.")
if chore.type == ChoreTypeEnum.group:
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
if not is_member:
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)

View File

@ -8,13 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session
from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum # Import model and enum
from app.schemas.group import GroupCreate, GroupPublic
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic, ListDetail
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
from app.crud import group as crud_group
from app.crud import invite as crud_invite
from app.crud import list as crud_list
from app.crud import history as crud_history
from app.crud import schedule as crud_schedule
from app.core.exceptions import (
GroupNotFoundError,
GroupPermissionError,
@ -265,3 +268,54 @@ async def read_group_lists(
group_lists = [list for list in lists if list.group_id == group_id]
return group_lists
@router.post(
"/{group_id}/chores/generate-schedule",
response_model=List[ChoreAssignmentPublic],
summary="Generate Group Chore Schedule",
tags=["Groups", "Chores"]
)
async def generate_group_chore_schedule(
group_id: int,
schedule_in: GroupScheduleGenerateRequest,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Generates a round-robin chore schedule for a group."""
logger.info(f"User {current_user.email} generating chore schedule for group {group_id}")
# Permission check: ensure user is a member (or owner/admin if stricter rules are needed)
if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "generate chore schedule for this group")
try:
assignments = await crud_schedule.generate_group_chore_schedule(
db=db,
group_id=group_id,
start_date=schedule_in.start_date,
end_date=schedule_in.end_date,
user_id=current_user.id,
member_ids=schedule_in.member_ids,
)
return assignments
except Exception as e:
logger.error(f"Error generating schedule for group {group_id}: {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get(
"/{group_id}/chores/history",
response_model=List[ChoreHistoryPublic],
summary="Get Group Chore History",
tags=["Groups", "Chores", "History"]
)
async def get_group_chore_history(
group_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chore-related history for a specific group."""
logger.info(f"User {current_user.email} requesting chore history for group {group_id}")
# Permission check
if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "view chore history for this group")
return await crud_history.get_group_chore_history(db=db, group_id=group_id)

View File

@ -332,9 +332,17 @@ class UserOperationError(HTTPException):
detail=detail
)
class ChoreOperationError(HTTPException):
"""Raised when a chore-related operation fails."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail
)
class ChoreNotFoundError(HTTPException):
"""Raised when a chore is not found."""
def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None):
"""Raised when a chore or assignment is not found."""
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
if detail:
error_detail = detail
elif group_id is not None:

View File

@ -6,10 +6,11 @@ from typing import List, Optional
import logging
from datetime import date, datetime
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
from app.core.chore_utils import calculate_next_due_date
from app.crud.group import get_group_by_id, is_user_member
from app.crud.history import create_chore_history_entry, create_assignment_history_entry
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
logger = logging.getLogger(__name__)
@ -39,7 +40,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
personal_chores_query
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -56,7 +59,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -99,6 +104,16 @@ async def create_chore(
db.add(db_chore)
await db.flush() # Get the ID for the chore
# Log history
await create_chore_history_entry(
db,
chore_id=db_chore.id,
group_id=db_chore.group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.CREATED,
event_data={"chore_name": db_chore.name}
)
try:
# Load relationships for the response with eager loading
result = await db.execute(
@ -107,7 +122,9 @@ async def create_chore(
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
)
)
return result.scalar_one()
@ -120,7 +137,13 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
result = await db.execute(
select(Chore)
.where(Chore.id == chore_id)
.options(selectinload(Chore.creator), selectinload(Chore.group), selectinload(Chore.assignments))
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
)
)
return result.scalar_one_or_none()
@ -152,7 +175,9 @@ async def get_personal_chores(
)
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -175,7 +200,9 @@ async def get_chores_by_group_id(
)
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
)
.order_by(Chore.next_due_date, Chore.name)
)
@ -194,6 +221,9 @@ async def update_chore(
if not db_chore:
raise ChoreNotFoundError(chore_id, group_id)
# Store original state for history
original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)}
# Check permissions
if db_chore.type == ChoreTypeEnum.group:
if not group_id:
@ -245,6 +275,23 @@ async def update_chore(
if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None:
raise ValueError("custom_interval_days must be set for custom frequency chores.")
# Log history for changes
changes = {}
for field, old_value in original_data.items():
new_value = getattr(db_chore, field)
if old_value != new_value:
changes[field] = {"old": str(old_value), "new": str(new_value)}
if changes:
await create_chore_history_entry(
db,
chore_id=chore_id,
group_id=db_chore.group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.UPDATED,
event_data=changes
)
try:
await db.flush() # Flush changes within the transaction
result = await db.execute(
@ -253,7 +300,9 @@ async def update_chore(
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history)
)
)
return result.scalar_one()
@ -273,6 +322,16 @@ async def delete_chore(
if not db_chore:
raise ChoreNotFoundError(chore_id, group_id)
# Log history before deleting
await create_chore_history_entry(
db,
chore_id=chore_id,
group_id=db_chore.group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.DELETED,
event_data={"chore_name": db_chore.name}
)
# Check permissions
if db_chore.type == ChoreTypeEnum.group:
if not group_id:
@ -324,6 +383,15 @@ async def create_chore_assignment(
db.add(db_assignment)
await db.flush() # Get the ID for the assignment
# Log history
await create_assignment_history_entry(
db,
assignment_id=db_assignment.id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.ASSIGNED,
event_data={"assigned_to_user_id": db_assignment.assigned_to_user_id, "due_date": db_assignment.due_date.isoformat()}
)
try:
# Load relationships for the response
result = await db.execute(
@ -331,7 +399,8 @@ async def create_chore_assignment(
.where(ChoreAssignment.id == db_assignment.id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
)
return result.scalar_one()
@ -346,7 +415,8 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
.where(ChoreAssignment.id == assignment_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
)
return result.scalar_one_or_none()
@ -364,7 +434,8 @@ async def get_user_assignments(
query = query.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
result = await db.execute(query)
@ -393,7 +464,8 @@ async def get_chore_assignments(
.where(ChoreAssignment.chore_id == chore_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
)
@ -411,7 +483,6 @@ async def update_chore_assignment(
if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
# Load the chore for permission checking
chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
@ -427,19 +498,27 @@ async def update_chore_assignment(
update_data = assignment_in.model_dump(exclude_unset=True)
original_assignee = db_assignment.assigned_to_user_id
original_due_date = db_assignment.due_date
# Check specific permissions for different updates
if 'is_complete' in update_data and not can_complete:
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
if 'due_date' in update_data and not can_manage:
if 'due_date' in update_data and update_data['due_date'] != original_due_date:
if not can_manage:
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.DUE_DATE_CHANGED, event_data={"old": original_due_date.isoformat(), "new": update_data['due_date'].isoformat()})
if 'assigned_to_user_id' in update_data and update_data['assigned_to_user_id'] != original_assignee:
if not can_manage:
raise PermissionDeniedError(detail="Only chore managers can reassign assignments")
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']})
# Handle completion logic
if 'is_complete' in update_data and update_data['is_complete']:
if not db_assignment.is_complete: # Only if not already complete
if 'is_complete' in update_data:
if update_data['is_complete'] and not db_assignment.is_complete:
update_data['completed_at'] = datetime.utcnow()
# Update parent chore's last_completed_at and recalculate next_due_date
chore.last_completed_at = update_data['completed_at']
chore.next_due_date = calculate_next_due_date(
current_due_date=chore.next_due_date,
@ -447,24 +526,25 @@ async def update_chore_assignment(
custom_interval_days=chore.custom_interval_days,
last_completed_date=chore.last_completed_at
)
elif 'is_complete' in update_data and not update_data['is_complete']:
# If marking as incomplete, clear completed_at
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
elif not update_data['is_complete'] and db_assignment.is_complete:
update_data['completed_at'] = None
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED)
# Apply updates
for field, value in update_data.items():
setattr(db_assignment, field, value)
try:
await db.flush() # Flush changes within the transaction
await db.flush()
# Load relationships for the response
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
)
return result.scalar_one()
@ -483,6 +563,15 @@ async def delete_chore_assignment(
if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
# Log history before deleting
await create_assignment_history_entry(
db,
assignment_id=assignment_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.UNASSIGNED,
event_data={"unassigned_user_id": db_assignment.assigned_to_user_id}
)
# Load the chore for permission checking
chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore:

View File

@ -79,7 +79,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
.options(
selectinload(GroupModel.member_associations).options(
selectinload(UserGroupModel.user)
)
),
selectinload(GroupModel.chore_history) # Eager load chore history
)
)
return result.scalars().all()
@ -95,7 +96,8 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode
select(GroupModel)
.where(GroupModel.id == group_id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user)
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
selectinload(GroupModel.chore_history) # Eager load chore history
)
)
return result.scalars().first()

83
be/app/crud/history.py Normal file
View File

@ -0,0 +1,83 @@
# be/app/crud/history.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from typing import List, Optional, Any, Dict
from app.models import ChoreHistory, ChoreAssignmentHistory, ChoreHistoryEventTypeEnum, User, Chore, Group
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentHistoryPublic
async def create_chore_history_entry(
db: AsyncSession,
*,
chore_id: Optional[int],
group_id: Optional[int],
changed_by_user_id: Optional[int],
event_type: ChoreHistoryEventTypeEnum,
event_data: Optional[Dict[str, Any]] = None,
) -> ChoreHistory:
"""Logs an event in the chore history."""
history_entry = ChoreHistory(
chore_id=chore_id,
group_id=group_id,
changed_by_user_id=changed_by_user_id,
event_type=event_type,
event_data=event_data or {},
)
db.add(history_entry)
await db.flush()
await db.refresh(history_entry)
return history_entry
async def create_assignment_history_entry(
db: AsyncSession,
*,
assignment_id: int,
changed_by_user_id: int,
event_type: ChoreHistoryEventTypeEnum,
event_data: Optional[Dict[str, Any]] = None,
) -> ChoreAssignmentHistory:
"""Logs an event in the chore assignment history."""
history_entry = ChoreAssignmentHistory(
assignment_id=assignment_id,
changed_by_user_id=changed_by_user_id,
event_type=event_type,
event_data=event_data or {},
)
db.add(history_entry)
await db.flush()
await db.refresh(history_entry)
return history_entry
async def get_chore_history(db: AsyncSession, chore_id: int) -> List[ChoreHistory]:
"""Gets all history for a specific chore."""
result = await db.execute(
select(ChoreHistory)
.where(ChoreHistory.chore_id == chore_id)
.options(selectinload(ChoreHistory.changed_by_user))
.order_by(ChoreHistory.timestamp.desc())
)
return result.scalars().all()
async def get_assignment_history(db: AsyncSession, assignment_id: int) -> List[ChoreAssignmentHistory]:
"""Gets all history for a specific assignment."""
result = await db.execute(
select(ChoreAssignmentHistory)
.where(ChoreAssignmentHistory.assignment_id == assignment_id)
.options(selectinload(ChoreAssignmentHistory.changed_by_user))
.order_by(ChoreAssignmentHistory.timestamp.desc())
)
return result.scalars().all()
async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[ChoreHistory]:
"""Gets all chore-related history for a group, including chore-specific and group-level events."""
result = await db.execute(
select(ChoreHistory)
.where(ChoreHistory.group_id == group_id)
.options(
selectinload(ChoreHistory.changed_by_user),
selectinload(ChoreHistory.chore) # Also load chore info if available
)
.order_by(ChoreHistory.timestamp.desc())
)
return result.scalars().all()

120
be/app/crud/schedule.py Normal file
View File

@ -0,0 +1,120 @@
# be/app/crud/schedule.py
import logging
from datetime import date, timedelta
from typing import List
from itertools import cycle
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models import Chore, Group, User, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from app.crud.group import get_group_by_id
from app.crud.history import create_chore_history_entry
from app.core.exceptions import GroupNotFoundError, ChoreOperationError
logger = logging.getLogger(__name__)
async def generate_group_chore_schedule(
db: AsyncSession,
*,
group_id: int,
start_date: date,
end_date: date,
user_id: int, # The user initiating the action
member_ids: List[int] = None
) -> List[ChoreAssignment]:
"""
Generates a round-robin chore schedule for all group chores within a date range.
"""
if start_date > end_date:
raise ChoreOperationError("Start date cannot be after end date.")
group = await get_group_by_id(db, group_id)
if not group:
raise GroupNotFoundError(group_id)
if not member_ids:
# If no members are specified, use all members from the group
members_result = await db.execute(
select(UserGroup.user_id).where(UserGroup.group_id == group_id)
)
member_ids = members_result.scalars().all()
if not member_ids:
raise ChoreOperationError("Cannot generate schedule with no members.")
# Fetch all chores belonging to this group
chores_result = await db.execute(
select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group)
)
group_chores = chores_result.scalars().all()
if not group_chores:
logger.info(f"No chores found in group {group_id} to generate a schedule for.")
return []
member_cycle = cycle(member_ids)
new_assignments = []
current_date = start_date
while current_date <= end_date:
for chore in group_chores:
# Check if a chore is due on the current day based on its frequency
# This is a simplified check. A more robust system would use the chore's next_due_date
# and frequency to see if it falls on the current_date.
# For this implementation, we assume we generate assignments for ALL chores on ALL days
# in the range, which might not be desired.
# A better approach is needed here. Let's assume for now we just create assignments for each chore
# on its *next* due date if it falls within the range.
if start_date <= chore.next_due_date <= end_date:
# Check if an assignment for this chore on this due date already exists
existing_assignment_result = await db.execute(
select(ChoreAssignment.id)
.where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date)
.limit(1)
)
if existing_assignment_result.scalar_one_or_none():
logger.info(f"Skipping assignment for chore '{chore.name}' on {chore.next_due_date} as it already exists.")
continue
assigned_to_user_id = next(member_cycle)
assignment = ChoreAssignment(
chore_id=chore.id,
assigned_to_user_id=assigned_to_user_id,
due_date=chore.next_due_date, # Assign on the chore's own next_due_date
is_complete=False
)
db.add(assignment)
new_assignments.append(assignment)
logger.info(f"Created assignment for chore '{chore.name}' to user {assigned_to_user_id} on {chore.next_due_date}")
current_date += timedelta(days=1)
if not new_assignments:
logger.info(f"No new assignments were generated for group {group_id} in the specified date range.")
return []
# Log a single group-level event for the schedule generation
await create_chore_history_entry(
db,
chore_id=None, # This is a group-level event
group_id=group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED,
event_data={
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"member_ids": member_ids,
"assignments_created": len(new_assignments)
}
)
await db.flush()
# Refresh assignments to load relationships if needed, although not strictly necessary
# as the objects are already in the session.
for assign in new_assignments:
await db.refresh(assign)
return new_assignments

View File

@ -24,6 +24,7 @@ from sqlalchemy import (
Date # Added Date for Chore model
)
from sqlalchemy.orm import relationship, backref
from sqlalchemy.dialects.postgresql import JSONB
from .database import Base
@ -71,6 +72,20 @@ class ChoreTypeEnum(enum.Enum):
personal = "personal"
group = "group"
class ChoreHistoryEventTypeEnum(str, enum.Enum):
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
COMPLETED = "completed"
REOPENED = "reopened"
ASSIGNED = "assigned"
UNASSIGNED = "unassigned"
REASSIGNED = "reassigned"
SCHEDULE_GENERATED = "schedule_generated"
# Add more specific events as needed
DUE_DATE_CHANGED = "due_date_changed"
DETAILS_CHANGED = "details_changed"
# --- User Model ---
class User(Base):
__tablename__ = "users"
@ -109,6 +124,11 @@ class User(Base):
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
# --- End Relationships for Chores ---
# --- History Relationships ---
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- Group Model ---
class Group(Base):
@ -137,6 +157,10 @@ class Group(Base):
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
# --- End Relationship for Chores ---
# --- History Relationships ---
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- UserGroup Association Model ---
class UserGroup(Base):
@ -383,6 +407,7 @@ class Chore(Base):
group = relationship("Group", back_populates="chores")
creator = relationship("User", back_populates="created_chores")
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
# --- ChoreAssignment Model ---
@ -403,6 +428,7 @@ class ChoreAssignment(Base):
# --- Relationships ---
chore = relationship("Chore", back_populates="assignments")
assigned_user = relationship("User", back_populates="assigned_chores")
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
# === NEW: RecurrencePattern Model ===
@ -430,3 +456,35 @@ class RecurrencePattern(Base):
# === END: RecurrencePattern Model ===
# === NEW: Chore History Models ===
class ChoreHistory(Base):
__tablename__ = "chore_history"
id = Column(Integer, primary_key=True, index=True)
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True) # For group-level events
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
event_data = Column(JSONB, nullable=True) # e.g., {'field': 'name', 'old': 'Old', 'new': 'New'}
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable if system-generated
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
chore = relationship("Chore", back_populates="history")
group = relationship("Group", back_populates="chore_history")
changed_by_user = relationship("User", back_populates="chore_history_entries")
class ChoreAssignmentHistory(Base):
__tablename__ = "chore_assignment_history"
id = Column(Integer, primary_key=True, index=True)
assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) # Reusing enum
event_data = Column(JSONB, nullable=True)
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
assignment = relationship("ChoreAssignment", back_populates="history")
changed_by_user = relationship("User", back_populates="assignment_history_entries")

View File

@ -1,13 +1,37 @@
from datetime import date, datetime
from typing import Optional, List
from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator
# Assuming ChoreFrequencyEnum is imported from models
# Adjust the import path if necessary based on your project structure.
# e.g., from app.models import ChoreFrequencyEnum
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel # For UserPublic relation
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # For UserPublic relation
from .user import UserPublic # For embedding user information
# Forward declaration for circular dependencies
class ChoreAssignmentPublic(BaseModel):
pass
# History Schemas
class ChoreHistoryPublic(BaseModel):
id: int
event_type: ChoreHistoryEventTypeEnum
event_data: Optional[dict[str, Any]] = None
changed_by_user: Optional[UserPublic] = None
timestamp: datetime
model_config = ConfigDict(from_attributes=True)
class ChoreAssignmentHistoryPublic(BaseModel):
id: int
event_type: ChoreHistoryEventTypeEnum
event_data: Optional[dict[str, Any]] = None
changed_by_user: Optional[UserPublic] = None
timestamp: datetime
model_config = ConfigDict(from_attributes=True)
# Chore Schemas
class ChoreBase(BaseModel):
name: str
@ -75,7 +99,8 @@ class ChorePublic(ChoreBase):
created_at: datetime
updated_at: datetime
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
# group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed
assignments: List[ChoreAssignmentPublic] = []
history: List[ChoreHistoryPublic] = []
model_config = ConfigDict(from_attributes=True)
@ -92,6 +117,7 @@ class ChoreAssignmentUpdate(BaseModel):
# Only completion status and perhaps due_date can be updated for an assignment
is_complete: Optional[bool] = None
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed
assigned_to_user_id: Optional[int] = None # For reassigning the chore
class ChoreAssignmentPublic(ChoreAssignmentBase):
id: int
@ -102,10 +128,11 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
# Embed ChorePublic and UserPublic for richer responses
chore: Optional[ChorePublic] = None
assigned_user: Optional[UserPublic] = None
history: List[ChoreAssignmentHistoryPublic] = []
model_config = ConfigDict(from_attributes=True)
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
# We can update forward refs after all models are defined.
# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings
# ChoreAssignmentPublic.model_rebuild()
ChorePublic.model_rebuild()
ChoreAssignmentPublic.model_rebuild()

View File

@ -1,14 +1,21 @@
# app/schemas/group.py
from pydantic import BaseModel, ConfigDict, computed_field
from datetime import datetime
from datetime import datetime, date
from typing import Optional, List
from .user import UserPublic # Import UserPublic to represent members
from .chore import ChoreHistoryPublic # Import for history
# Properties to receive via API on creation
class GroupCreate(BaseModel):
name: str
# New schema for generating a schedule
class GroupScheduleGenerateRequest(BaseModel):
start_date: date
end_date: date
member_ids: Optional[List[int]] = None # Optional: if not provided, use all members
# Properties to return to client
class GroupPublic(BaseModel):
id: int
@ -16,6 +23,7 @@ class GroupPublic(BaseModel):
created_by_id: int
created_at: datetime
member_associations: Optional[List["UserGroupPublic"]] = None
chore_history: Optional[List[ChoreHistoryPublic]] = []
@computed_field
@property
@ -40,3 +48,6 @@ class UserGroupPublic(BaseModel):
# Properties stored in DB (if needed, often GroupPublic is sufficient)
# class GroupInDB(GroupPublic):
# pass
# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic
GroupPublic.model_rebuild()

View File

@ -917,11 +917,13 @@ select.form-input {
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(57, 62, 70, 0.7);
background-color: rgba(57, 62, 70, 0.9);
/* Increased opacity for better visibility */
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 9999;
/* Increased z-index to ensure it's above other elements */
opacity: 0;
visibility: hidden;
transition:
@ -941,16 +943,18 @@ select.form-input {
background-color: var(--light);
border: var(--border);
width: 90%;
max-width: 550px;
max-width: 850px;
box-shadow: var(--shadow-lg);
position: relative;
overflow-y: scroll;
/* Can cause tooltip clipping */
overflow-y: auto;
/* Changed from scroll to auto */
transform: scale(0.95) translateY(-20px);
transition: transform var(--transition-speed) var(--transition-ease-out);
max-height: 90vh;
display: flex;
flex-direction: column;
z-index: 10000;
/* Ensure modal content is above backdrop */
}
.modal-container::before {

View File

@ -2,7 +2,7 @@
export const API_VERSION = 'v1'
// API Base URL
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev'
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000'
// API Endpoints
export const API_ENDPOINTS = {
@ -33,7 +33,6 @@ export const API_ENDPOINTS = {
BASE: '/lists',
BY_ID: (id: string) => `/lists/${id}`,
STATUS: (id: string) => `/lists/${id}/status`,
STATUSES: '/lists/statuses',
ITEMS: (listId: string) => `/lists/${listId}/items`,
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
EXPENSES: (listId: string) => `/lists/${listId}/expenses`,
@ -62,13 +61,15 @@ export const API_ENDPOINTS = {
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
ROLES: (groupId: string) => `/groups/${groupId}/roles`,
ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`,
GENERATE_SCHEDULE: (groupId: string) => `/groups/${groupId}/chores/generate-schedule`,
CHORE_HISTORY: (groupId: string) => `/groups/${groupId}/chores/history`,
},
// Invites
INVITES: {
BASE: '/invites',
BY_ID: (id: string) => `/invites/${id}`,
ACCEPT: '/invites/accept',
ACCEPT: (id: string) => `/invites/accept/${id}`,
DECLINE: (id: string) => `/invites/decline/${id}`,
REVOKE: (id: string) => `/invites/revoke/${id}`,
LIST: '/invites',
@ -120,4 +121,12 @@ export const API_ENDPOINTS = {
METRICS: '/health/metrics',
LOGS: '/health/logs',
},
CHORES: {
BASE: '/chores',
BY_ID: (id: number) => `/chores/${id}`,
HISTORY: (id: number) => `/chores/${id}/history`,
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
},
}

View File

@ -1,5 +1,6 @@
import { api } from '@/services/api';
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
import { API_BASE_URL, API_VERSION } from './api-config';
export { API_ENDPOINTS } from './api-config';
// Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => {
@ -14,5 +15,3 @@ export const apiClient = {
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
};
export { API_ENDPOINTS };

File diff suppressed because it is too large Load Diff

View File

@ -73,7 +73,10 @@
"groupNameRequired": "Group name is required",
"createFailed": "Failed to create group. Please try again.",
"inviteCodeRequired": "Invite code is required",
"joinFailed": "Failed to join group. Please check the invite code and try again."
"joinFailed": "Failed to join group. Please check the invite code and try again.",
"invalidDataFromServer": "Invalid data received from server.",
"createFailedConsole": "Error creating group:",
"joinFailedConsole": "Error joining group:"
},
"notifications": {
"groupCreatedSuccess": "Group '{groupName}' created successfully.",
@ -85,7 +88,8 @@
"authCallbackPage": {
"redirecting": "Redirecting...",
"errors": {
"authenticationFailed": "Authentication failed"
"authenticationFailed": "Authentication failed",
"noTokenProvided": "No token provided"
}
},
"choresPage": {
@ -125,7 +129,17 @@
"save": "Save Changes",
"create": "Create",
"editChore": "Edit Chore",
"createChore": "Create Chore"
"createChore": "Create Chore",
"intervalPlaceholder": "e.g., 10"
},
"consoleErrors": {
"loadFailed": "Failed to load all chores:",
"loadGroupsFailed": "Failed to load groups",
"createAssignmentForNewChoreFailed": "Failed to create assignment for new chore:",
"saveFailed": "Failed to save chore:",
"deleteFailed": "Failed to delete chore:",
"createAssignmentFailed": "Failed to create assignment:",
"updateCompletionStatusFailed": "Failed to update chore completion status:"
},
"deleteConfirm": {
"title": "Confirm Deletion",
@ -160,10 +174,14 @@
"title": "Group Members",
"defaultRole": "Member",
"removeButton": "Remove",
"emptyState": "No members found."
"emptyState": "No members found.",
"closeMenuLabel": "Close menu"
},
"invites": {
"title": "Invite Members",
"description": "Invite new members by generating a shareable code.",
"addMemberButtonLabel": "Add member",
"closeInviteLabel": "Close invite",
"regenerateButton": "Regenerate Invite Code",
"generateButton": "Generate Invite Code",
"activeCodeLabel": "Current Active Invite Code:",
@ -174,6 +192,15 @@
"newDataInvalid": "New invite code data is invalid."
}
},
"errors": {
"failedToFetchActiveInvite": "Failed to fetch active invite code.",
"failedToFetchGroupDetails": "Failed to fetch group details.",
"failedToLoadUpcomingChores": "Error loading upcoming chores:",
"failedToLoadRecentExpenses": "Error loading recent expenses:"
},
"console": {
"noActiveInvite": "No active invite code found for this group."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
@ -191,6 +218,8 @@
"settleShareButton": "Settle My Share",
"activityLabel": "Activity:",
"byUser": "by",
"fallbackUserName": "User ID: {userId}",
"activityByUserFallback": "User {userId}",
"splitTypes": {
"equal": "Equal",
"exactAmounts": "Exact Amounts",
@ -526,5 +555,15 @@
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
},
"languageSelector": {
"title": "Language",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,13 @@
import en from './en.json';
import de from './de.json';
import nl from './nl.json';
import fr from './fr.json';
import es from './es.json';
export default {
en,
de,
nl,
fr,
es
};

641
fe/src/i18n/nl.json Normal file
View File

@ -0,0 +1,641 @@
{
"message": {
"hello": "Hallo"
},
"loginPage": {
"emailLabel": "E-mail",
"passwordLabel": "Wachtwoord",
"togglePasswordVisibilityLabel": "Wachtwoord zichtbaarheid wisselen",
"loginButton": "Inloggen",
"signupLink": "Geen account? Aanmelden",
"errors": {
"emailRequired": "E-mail is vereist",
"emailInvalid": "Ongeldig e-mailformaat",
"passwordRequired": "Wachtwoord is vereist",
"loginFailed": "Inloggen mislukt. Controleer uw gegevens."
},
"notifications": {
"loginSuccess": "Succesvol ingelogd"
}
},
"listsPage": {
"retryButton": "Opnieuw proberen",
"emptyState": {
"noListsForGroup": "Geen lijsten gevonden voor deze groep.",
"noListsYet": "U heeft nog geen lijsten.",
"personalGlobalInfo": "Maak een persoonlijke lijst of word lid van een groep om gedeelde lijsten te zien.",
"groupSpecificInfo": "Deze groep heeft nog geen lijsten."
},
"createNewListButton": "Nieuwe lijst maken",
"loadingLists": "Lijsten laden...",
"noDescription": "Geen beschrijving",
"addItemPlaceholder": "Nieuw item toevoegen...",
"createCard": {
"title": "+ Lijst"
},
"pageTitle": {
"forGroup": "Lijsten voor {groupName}",
"forGroupId": "Lijsten voor Groep {groupId}",
"myLists": "Mijn Lijsten"
},
"errors": {
"fetchFailed": "Ophalen van lijsten mislukt."
}
},
"groupsPage": {
"retryButton": "Opnieuw proberen",
"emptyState": {
"title": "Nog geen groepen!",
"description": "U bent nog geen lid van groepen. Maak er een aan of word lid met een uitnodigingscode.",
"createButton": "Nieuwe groep maken"
},
"groupCard": {
"newListButton": "Lijst"
},
"createCard": {
"title": "+ Groep"
},
"joinGroup": {
"title": "Lid worden van een groep met uitnodigingscode",
"inputLabel": "Voer uitnodigingscode in",
"inputPlaceholder": "Voer uitnodigingscode in",
"joinButton": "Deelnemen"
},
"createDialog": {
"title": "Nieuwe groep maken",
"closeButtonLabel": "Sluiten",
"groupNameLabel": "Groepsnaam",
"cancelButton": "Annuleren",
"createButton": "Maken"
},
"errors": {
"fetchFailed": "Laden van groepen mislukt",
"groupNameRequired": "Groepsnaam is vereist",
"createFailed": "Maken van groep mislukt. Probeer het opnieuw.",
"inviteCodeRequired": "Uitnodigingscode is vereist",
"joinFailed": "Deelnemen aan groep mislukt. Controleer de uitnodigingscode en probeer het opnieuw.",
"invalidDataFromServer": "Ongeldige gegevens ontvangen van server.",
"createFailedConsole": "Fout bij het maken van groep:",
"joinFailedConsole": "Fout bij het deelnemen aan groep:"
},
"notifications": {
"groupCreatedSuccess": "Groep '{groupName}' succesvol aangemaakt.",
"joinSuccessNamed": "Succesvol lid geworden van groep '{groupName}'.",
"joinSuccessGeneric": "Succesvol lid geworden van groep.",
"listCreatedSuccess": "Lijst '{listName}' succesvol aangemaakt."
}
},
"authCallbackPage": {
"redirecting": "Bezig met omleiden...",
"errors": {
"authenticationFailed": "Authenticatie mislukt",
"noTokenProvided": "Geen token opgegeven"
}
},
"choresPage": {
"title": "Taken",
"tabs": {
"overdue": "Achterstallig",
"today": "Vandaag",
"upcoming": "Aankomend",
"allPending": "Alle openstaande",
"completed": "Voltooid"
},
"viewToggle": {
"calendarLabel": "Kalenderweergave",
"calendarText": "Kalender",
"listLabel": "Lijstweergave",
"listText": "Lijst"
},
"newChoreButtonLabel": "Nieuwe taak",
"newChoreButtonText": "Nieuwe taak",
"loadingState": {
"loadingChores": "Taken laden..."
},
"calendar": {
"prevMonthLabel": "Vorige maand",
"nextMonthLabel": "Volgende maand",
"weekdays": {
"sun": "Zo",
"mon": "Ma",
"tue": "Di",
"wed": "Wo",
"thu": "Do",
"fri": "Vr",
"sat": "Za"
},
"addChoreToDayLabel": "Taak aan deze dag toevoegen",
"emptyState": "Geen taken om weer te geven voor deze periode."
},
"listView": {
"choreTypePersonal": "Persoonlijk",
"choreTypeGroupFallback": "Groep",
"completedDatePrefix": "Voltooid:",
"actions": {
"doneTitle": "Markeer als voltooid",
"doneText": "Gedaan",
"undoTitle": "Markeer als niet voltooid",
"undoText": "Ongedaan maken",
"editTitle": "Bewerken",
"editLabel": "Taak bewerken",
"editText": "Bewerken",
"deleteTitle": "Verwijderen",
"deleteLabel": "Taak verwijderen",
"deleteText": "Verwijderen"
},
"emptyState": {
"message": "Geen taken in deze weergave. Goed gedaan!",
"viewAllButton": "Alle openstaande bekijken"
}
},
"choreModal": {
"editTitle": "Taak bewerken",
"newTitle": "Nieuwe taak",
"closeButtonLabel": "Modal sluiten",
"nameLabel": "Naam",
"namePlaceholder": "Voer taaknaam in",
"typeLabel": "Type",
"typePersonal": "Persoonlijk",
"typeGroup": "Groep",
"groupLabel": "Groep",
"groupSelectDefault": "Selecteer een groep",
"descriptionLabel": "Beschrijving",
"descriptionPlaceholder": "Voeg een beschrijving toe (optioneel)",
"frequencyLabel": "Frequentie",
"intervalLabel": "Interval (dagen)",
"intervalPlaceholder": "bijv. 10",
"dueDateLabel": "Vervaldatum",
"quickDueDateToday": "Vandaag",
"quickDueDateTomorrow": "Morgen",
"quickDueDateNextWeek": "Volgende week",
"cancelButton": "Annuleren",
"saveButton": "Opslaan"
},
"consoleErrors": {
"loadFailed": "Laden van alle taken mislukt:",
"loadGroupsFailed": "Laden van groepen mislukt",
"createAssignmentForNewChoreFailed": "Toewijzing voor nieuwe taak kon niet worden gemaakt:",
"saveFailed": "Opslaan van taak mislukt:",
"deleteFailed": "Verwijderen van taak mislukt:",
"createAssignmentFailed": "Toewijzing kon niet worden gemaakt:",
"updateCompletionStatusFailed": "Voltooiingsstatus van taak kon niet worden bijgewerkt:"
},
"deleteDialog": {
"title": "Taak verwijderen",
"confirmationText": "Weet u zeker dat u deze taak wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleteButton": "Verwijderen"
},
"shortcutsModal": {
"title": "Sneltoetsen",
"descNewChore": "Nieuwe taak",
"descToggleView": "Weergave wisselen (Lijst/Kalender)",
"descToggleShortcuts": "Sneltoetsen tonen/verbergen",
"descCloseModal": "Open Modal/Dialoog sluiten"
},
"frequencyOptions": {
"oneTime": "Eenmalig",
"daily": "Dagelijks",
"weekly": "Wekelijks",
"monthly": "Maandelijks",
"custom": "Aangepast"
},
"frequency": {
"customInterval": "Elke {n} dag | Elke {n} dagen"
},
"formatters": {
"noDueDate": "Geen vervaldatum",
"dueToday": "Vandaag te doen",
"dueTomorrow": "Morgen te doen",
"overdueFull": "Achterstallig: {date}",
"dueFull": "Vervalt op {date}",
"invalidDate": "Ongeldige datum"
},
"notifications": {
"loadFailed": "Laden van taken mislukt.",
"loadGroupsFailed": "Laden van groepen mislukt.",
"updateSuccess": "Taak '{name}' succesvol bijgewerkt.",
"createSuccess": "Taak '{name}' succesvol aangemaakt.",
"updateFailed": "Bijwerken van taak mislukt.",
"createFailed": "Aanmaken van taak mislukt.",
"deleteSuccess": "Taak '{name}' succesvol verwijderd.",
"deleteFailed": "Verwijderen van taak mislukt.",
"markedDone": "{name} gemarkeerd als voltooid.",
"markedNotDone": "{name} gemarkeerd als niet voltooid.",
"statusUpdateFailed": "Status van taak kon niet worden bijgewerkt.",
"createAssignmentFailed": "Toewijzing voor taak kon niet worden gemaakt."
},
"validation": {
"nameRequired": "Taaknaam is vereist.",
"groupRequired": "Selecteer een groep voor groepstaken.",
"intervalRequired": "Aangepast interval moet minimaal 1 dag zijn.",
"dueDateRequired": "Vervaldatum is vereist.",
"invalidDueDate": "Ongeldig formaat vervaldatum."
},
"unsavedChangesConfirmation": "U heeft niet-opgeslagen wijzigingen in het taakformulier. Weet u zeker dat u wilt vertrekken?"
},
"errorNotFoundPage": {
"errorCode": "404",
"errorMessage": "Oeps. Hier is niets...",
"goHomeButton": "Naar de startpagina"
},
"groupDetailPage": {
"loadingLabel": "Groepsdetails laden...",
"retryButton": "Opnieuw proberen",
"groupNotFound": "Groep niet gevonden of er is een fout opgetreden.",
"members": {
"title": "Groepsleden",
"defaultRole": "Lid",
"removeButton": "Verwijderen",
"emptyState": "Geen leden gevonden.",
"closeMenuLabel": "Menu sluiten"
},
"invites": {
"title": "Leden uitnodigen",
"description": "Nodig nieuwe leden uit door een deelbare code te genereren.",
"addMemberButtonLabel": "Lid toevoegen",
"closeInviteLabel": "Uitnodiging sluiten",
"regenerateButton": "Uitnodigingscode opnieuw genereren",
"generateButton": "Uitnodigingscode genereren",
"activeCodeLabel": "Huidige actieve uitnodigingscode:",
"copyButtonLabel": "Kopieer uitnodigingscode",
"copySuccess": "Uitnodigingscode gekopieerd naar klembord!",
"emptyState": "Geen actieve uitnodigingscode. Klik op de knop hierboven om er een te genereren.",
"errors": {
"newDataInvalid": "Gegevens van nieuwe uitnodigingscode zijn ongeldig."
}
},
"errors": {
"failedToFetchActiveInvite": "Ophalen van actieve uitnodigingscode mislukt.",
"failedToFetchGroupDetails": "Ophalen van groepsdetails mislukt.",
"failedToLoadUpcomingChores": "Fout bij het laden van aankomende taken:",
"failedToLoadRecentExpenses": "Fout bij het laden van recente uitgaven:"
},
"console": {
"noActiveInvite": "Geen actieve uitnodigingscode gevonden voor deze groep."
},
"chores": {
"title": "Groepstaken",
"manageButton": "Taken beheren",
"duePrefix": "Vervalt:",
"emptyState": "Geen taken gepland. Klik op \"Taken beheren\" om er enkele aan te maken!"
},
"expenses": {
"title": "Groepsuitgaven",
"manageButton": "Uitgaven beheren",
"emptyState": "Geen uitgaven geregistreerd. Klik op \"Uitgaven beheren\" om er enkele toe te voegen!",
"paidBy": "Betaald door:",
"owes": "is verschuldigd",
"paidAmount": "Betaald:",
"onDate": "op",
"settleShareButton": "Mijn deel vereffenen",
"activityLabel": "Activiteit:",
"byUser": "door",
"fallbackUserName": "Gebruikers-ID: {userId}",
"activityByUserFallback": "Gebruiker {userId}",
"splitTypes": {
"equal": "Gelijk",
"exactAmounts": "Exacte bedragen",
"percentage": "Percentage",
"shares": "Aandelen",
"itemBased": "Op item gebaseerd"
}
},
"notifications": {
"fetchDetailsFailed": "Ophalen van groepsdetails mislukt.",
"fetchInviteFailed": "Ophalen van actieve uitnodigingscode mislukt.",
"generateInviteSuccess": "Nieuwe uitnodigingscode succesvol gegenereerd!",
"generateInviteError": "Genereren van uitnodigingscode mislukt.",
"clipboardNotSupported": "Klembord niet ondersteund of geen code om te kopiëren.",
"copyInviteFailed": "Kopiëren van uitnodigingscode mislukt.",
"removeMemberSuccess": "Lid succesvol verwijderd",
"removeMemberFailed": "Verwijderen van lid mislukt",
"loadExpensesFailed": "Laden van recente uitgaven mislukt.",
"cannotSettleOthersShares": "U kunt alleen uw eigen aandelen vereffenen.",
"settlementDataMissing": "Kan vereffening niet verwerken: gegevens ontbreken.",
"settleShareSuccess": "Aandeel succesvol vereffend!",
"settleShareFailed": "Vereffenen van aandeel mislukt."
},
"loading": {
"settlement": "Bezig met vereffenen..."
},
"settleShareModal": {
"title": "Aandeel vereffenen",
"settleAmountFor": "Bedrag vereffenen voor {userName}:",
"amountLabel": "Bedrag",
"cancelButton": "Annuleren",
"confirmButton": "Bevestigen",
"errors": {
"enterAmount": "Voer een bedrag in.",
"positiveAmount": "Voer een positief bedrag in.",
"exceedsRemaining": "Bedrag mag resterend bedrag niet overschrijden: {amount}.",
"noSplitSelected": "Fout: Geen verdeling geselecteerd."
}
},
"status": {
"settled": "Vereffend",
"partiallySettled": "Gedeeltelijk vereffend",
"unsettled": "Openstaand",
"paid": "Betaald",
"partiallyPaid": "Gedeeltelijk betaald",
"unpaid": "Onbetaald",
"unknown": "Onbekende status"
}
},
"accountPage": {
"title": "Accountinstellingen",
"loadingProfile": "Profiel laden...",
"retryButton": "Opnieuw proberen",
"profileSection": {
"header": "Profielinformatie",
"nameLabel": "Naam",
"emailLabel": "E-mail",
"saveButton": "Wijzigingen opslaan"
},
"passwordSection": {
"header": "Wachtwoord wijzigen",
"currentPasswordLabel": "Huidig wachtwoord",
"newPasswordLabel": "Nieuw wachtwoord",
"changeButton": "Wachtwoord wijzigen"
},
"notificationsSection": {
"header": "Notificatievoorkeuren",
"emailNotificationsLabel": "E-mailnotificaties",
"emailNotificationsDescription": "Ontvang e-mailnotificaties voor belangrijke updates",
"listUpdatesLabel": "Lijstupdates",
"listUpdatesDescription": "Ontvang een melding wanneer lijsten worden bijgewerkt",
"groupActivitiesLabel": "Groepsactiviteiten",
"groupActivitiesDescription": "Ontvang meldingen voor groepsactiviteiten"
},
"notifications": {
"profileLoadFailed": "Laden van profiel mislukt",
"profileUpdateSuccess": "Profiel succesvol bijgewerkt",
"profileUpdateFailed": "Bijwerken van profiel mislukt",
"passwordFieldsRequired": "Vul zowel het huidige als het nieuwe wachtwoordveld in.",
"passwordTooShort": "Nieuw wachtwoord moet minimaal 8 tekens lang zijn.",
"passwordChangeSuccess": "Wachtwoord succesvol gewijzigd",
"passwordChangeFailed": "Wijzigen van wachtwoord mislukt",
"preferencesUpdateSuccess": "Voorkeuren succesvol bijgewerkt",
"preferencesUpdateFailed": "Bijwerken van voorkeuren mislukt"
},
"saving": "Opslaan..."
},
"signupPage": {
"header": "Aanmelden",
"fullNameLabel": "Volledige naam",
"emailLabel": "E-mail",
"passwordLabel": "Wachtwoord",
"confirmPasswordLabel": "Bevestig wachtwoord",
"togglePasswordVisibility": "Wachtwoord zichtbaarheid wisselen",
"submitButton": "Aanmelden",
"loginLink": "Heeft u al een account? Inloggen",
"validation": {
"nameRequired": "Naam is vereist",
"emailRequired": "E-mail is vereist",
"emailInvalid": "Ongeldig e-mailformaat",
"passwordRequired": "Wachtwoord is vereist",
"passwordLength": "Wachtwoord moet minimaal 8 tekens lang zijn",
"confirmPasswordRequired": "Bevestig uw wachtwoord",
"passwordsNoMatch": "Wachtwoorden komen niet overeen"
},
"notifications": {
"signupFailed": "Aanmelden mislukt. Probeer het opnieuw.",
"signupSuccess": "Account succesvol aangemaakt. Log in alstublieft."
}
},
"listDetailPage": {
"loading": {
"list": "Lijst laden...",
"items": "Items laden...",
"ocrProcessing": "Afbeelding verwerken...",
"addingOcrItems": "OCR-items toevoegen...",
"costSummary": "Samenvatting laden...",
"expenses": "Uitgaven laden...",
"settlement": "Bezig met vereffenen..."
},
"errors": {
"fetchFailed": "Laden van lijstdetails mislukt.",
"genericLoadFailure": "Groep niet gevonden of er is een fout opgetreden.",
"ocrNoItems": "Geen items uit de afbeelding gehaald.",
"ocrFailed": "Verwerken van afbeelding mislukt.",
"addItemFailed": "Toevoegen van item mislukt.",
"updateItemFailed": "Bijwerken van item mislukt.",
"updateItemPriceFailed": "Bijwerken van itemprijs mislukt.",
"deleteItemFailed": "Verwijderen van item mislukt.",
"addOcrItemsFailed": "Toevoegen van OCR-items mislukt.",
"fetchItemsFailed": "Laden van items mislukt: {errorMessage}",
"loadCostSummaryFailed": "Laden van kostensamenvatting mislukt."
},
"retryButton": "Opnieuw proberen",
"buttons": {
"addViaOcr": "Toevoegen via OCR",
"addItem": "Toevoegen",
"addItems": "Items toevoegen",
"cancel": "Annuleren",
"confirm": "Bevestigen",
"saveChanges": "Wijzigingen opslaan",
"close": "Sluiten",
"costSummary": "Kostensamenvatting"
},
"badges": {
"groupList": "Groepslijst",
"personalList": "Persoonlijke lijst"
},
"items": {
"emptyState": {
"title": "Nog geen items!",
"message": "Voeg items toe via het onderstaande formulier."
},
"addItemForm": {
"placeholder": "Nieuw item toevoegen",
"quantityPlaceholder": "Aantal",
"itemNameSrLabel": "Naam nieuw item",
"quantitySrLabel": "Hoeveelheid"
},
"pricePlaceholder": "Prijs",
"editItemAriaLabel": "Item bewerken",
"deleteItemAriaLabel": "Item verwijderen"
},
"modals": {
"ocr": {
"title": "Items toevoegen via OCR",
"uploadLabel": "Afbeelding uploaden"
},
"confirmation": {
"title": "Bevestiging"
},
"editItem": {
"title": "Item bewerken",
"nameLabel": "Itemnaam",
"quantityLabel": "Hoeveelheid"
},
"costSummary": {
"title": "Kostensamenvatting lijst",
"totalCostLabel": "Totale kosten lijst:",
"equalShareLabel": "Gelijk deel per gebruiker:",
"participantsLabel": "Deelnemende gebruikers:",
"userBalancesHeader": "Gebruikerssaldi",
"tableHeaders": {
"user": "Gebruiker",
"itemsAddedValue": "Waarde toegevoegde items",
"amountDue": "Verschuldigd bedrag",
"balance": "Saldo"
},
"emptyState": "Geen kostensamenvatting beschikbaar."
},
"settleShare": {
"title": "Aandeel vereffenen",
"settleAmountFor": "Bedrag vereffenen voor {userName}:",
"amountLabel": "Bedrag",
"errors": {
"enterAmount": "Voer een bedrag in.",
"positiveAmount": "Voer een positief bedrag in.",
"exceedsRemaining": "Bedrag mag resterend bedrag niet overschrijden: {amount}.",
"noSplitSelected": "Fout: Geen verdeling geselecteerd."
}
}
},
"confirmations": {
"updateMessage": "'{itemName}' markeren als {status}?",
"statusComplete": "voltooid",
"statusIncomplete": "onvolledig",
"deleteMessage": "'{itemName}' verwijderen? Dit kan niet ongedaan worden gemaakt."
},
"notifications": {
"itemAddedSuccess": "Item succesvol toegevoegd.",
"itemsAddedSuccessOcr": "{count} item(s) succesvol toegevoegd via OCR.",
"itemUpdatedSuccess": "Item succesvol bijgewerkt.",
"itemDeleteSuccess": "Item succesvol verwijderd.",
"enterItemName": "Voer een itemnaam in.",
"costSummaryLoadFailed": "Laden van kostensamenvatting mislukt.",
"cannotSettleOthersShares": "U kunt alleen uw eigen aandelen vereffenen.",
"settlementDataMissing": "Kan vereffening niet verwerken: gegevens ontbreken.",
"settleShareSuccess": "Aandeel succesvol vereffend!",
"settleShareFailed": "Vereffenen van aandeel mislukt."
},
"expensesSection": {
"title": "Uitgaven",
"addExpenseButton": "Uitgave toevoegen",
"loading": "Uitgaven laden...",
"emptyState": "Nog geen uitgaven geregistreerd voor deze lijst.",
"paidBy": "Betaald door:",
"onDate": "op",
"owes": "is verschuldigd",
"paidAmount": "Betaald:",
"activityLabel": "Activiteit:",
"byUser": "door",
"settleShareButton": "Mijn deel vereffenen",
"retryButton": "Opnieuw proberen"
},
"status": {
"settled": "Vereffend",
"partiallySettled": "Gedeeltelijk vereffend",
"unsettled": "Openstaand",
"paid": "Betaald",
"partiallyPaid": "Gedeeltelijk betaald",
"unpaid": "Onbetaald",
"unknown": "Onbekende status"
}
},
"myChoresPage": {
"title": "Mijn toegewezen taken",
"showCompletedToggle": "Voltooide tonen",
"timelineHeaders": {
"overdue": "Achterstallig",
"today": "Vandaag te doen",
"thisWeek": "Deze week",
"later": "Later",
"completed": "Voltooid"
},
"choreCard": {
"personal": "Persoonlijk",
"group": "Groep",
"duePrefix": "Vervalt",
"completedPrefix": "Voltooid",
"dueToday": "Vandaag te doen",
"markCompleteButton": "Markeer als voltooid"
},
"frequencies": {
"one_time": "Eenmalig",
"daily": "Dagelijks",
"weekly": "Wekelijks",
"monthly": "Maandelijks",
"custom": "Aangepast",
"unknown": "Onbekende frequentie"
},
"dates": {
"invalidDate": "Ongeldige datum",
"unknownDate": "Onbekende datum"
},
"emptyState": {
"title": "Nog geen toewijzingen!",
"noAssignmentsPending": "U heeft geen openstaande taaktoewijzingen.",
"noAssignmentsAll": "U heeft geen taaktoewijzingen (voltooid of openstaand).",
"viewAllChoresButton": "Alle taken bekijken"
},
"notifications": {
"loadFailed": "Laden van toewijzingen mislukt",
"markedComplete": "\"{choreName}\" gemarkeerd als voltooid!",
"markCompleteFailed": "Markeren van toewijzing als voltooid mislukt"
}
},
"personalChoresPage": {
"title": "Persoonlijke taken",
"newChoreButton": "Nieuwe taak",
"editButton": "Bewerken",
"deleteButton": "Verwijderen",
"cancelButton": "Annuleren",
"saveButton": "Opslaan",
"modals": {
"editChoreTitle": "Taak bewerken",
"newChoreTitle": "Nieuwe taak",
"deleteChoreTitle": "Taak verwijderen"
},
"form": {
"nameLabel": "Naam",
"descriptionLabel": "Beschrijving",
"frequencyLabel": "Frequentie",
"intervalLabel": "Interval (dagen)",
"dueDateLabel": "Volgende vervaldatum"
},
"deleteDialog": {
"confirmationText": "Weet u zeker dat u deze taak wilt verwijderen?"
},
"frequencies": {
"one_time": "Eenmalig",
"daily": "Dagelijks",
"weekly": "Wekelijks",
"monthly": "Maandelijks",
"custom": "Aangepast",
"unknown": "Onbekende frequentie"
},
"dates": {
"invalidDate": "Ongeldige datum",
"duePrefix": "Vervalt"
},
"notifications": {
"loadFailed": "Laden van persoonlijke taken mislukt",
"updateSuccess": "Persoonlijke taak succesvol bijgewerkt",
"createSuccess": "Persoonlijke taak succesvol aangemaakt",
"saveFailed": "Opslaan van persoonlijke taak mislukt",
"deleteSuccess": "Persoonlijke taak succesvol verwijderd",
"deleteFailed": "Verwijderen van persoonlijke taak mislukt"
}
},
"indexPage": {
"welcomeMessage": "Welkom bij de Valerie UI App",
"mainPageInfo": "Dit is de hoofdindexpagina.",
"sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)",
"totalCountLabel": "Totaal aantal uit meta:",
"noTodos": "Geen taken om weer te geven."
},
"languageSelector": {
"title": "Taal",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -2,6 +2,28 @@
<div class="main-layout">
<header class="app-header">
<div class="toolbar-title">mitlist</div>
<div class="flex align-end">
<div class="language-selector" v-if="authStore.isAuthenticated">
<button @click="toggleLanguageMenu" class="language-menu-button">
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54">
<path d="M0 0h24v24H0z" fill="none" />
<path
d="m12.87 15.07-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
</svg>
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
</button>
<div v-if="languageMenuOpen" class="dropdown-menu language-dropdown" ref="languageMenuDropdown">
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
<a v-for="(name, code) in availableLanguages" :key="code" href="#" @click.prevent="changeLanguage(code)"
class="language-option" :class="{ 'active': currentLanguageCode === code }">
{{ name }}
</a>
</div>
</div>
<div class="user-menu" v-if="authStore.isAuthenticated">
<button @click="toggleUserMenu" class="user-menu-button">
<!-- Placeholder for user icon -->
@ -15,6 +37,7 @@
<a href="#" @click.prevent="handleLogout">Logout</a>
</div>
</div>
</div>
</header>
<main class="page-container">
@ -53,13 +76,14 @@
</template>
<script setup lang="ts">
import { ref, defineComponent, onMounted } from 'vue';
import { ref, defineComponent, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import OfflineIndicator from '@/components/OfflineIndicator.vue';
import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import { useGroupStore } from '@/stores/groupStore';
import { useI18n } from 'vue-i18n';
defineComponent({
name: 'MainLayout'
@ -70,6 +94,7 @@ const route = useRoute();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const groupStore = useGroupStore();
const { t, locale } = useI18n();
// Add initialization logic
const initializeApp = async () => {
@ -90,6 +115,12 @@ onMounted(() => {
if (authStore.isAuthenticated) {
groupStore.fetchGroups();
}
// Load saved language from localStorage
const savedLanguage = localStorage.getItem('language');
if (savedLanguage && ['en', 'de', 'nl', 'fr', 'es'].includes(savedLanguage)) {
locale.value = savedLanguage;
}
});
const userMenuOpen = ref(false);
@ -103,6 +134,37 @@ onClickOutside(userMenuDropdown, () => {
userMenuOpen.value = false;
}, { ignore: ['.user-menu-button'] });
// Language selector state and functions
const languageMenuOpen = ref(false);
const languageMenuDropdown = ref<HTMLElement | null>(null);
const availableLanguages = computed(() => ({
en: t('languageSelector.languages.en'),
de: t('languageSelector.languages.de'),
nl: t('languageSelector.languages.nl'),
fr: t('languageSelector.languages.fr'),
es: t('languageSelector.languages.es')
}));
const currentLanguageCode = computed(() => locale.value);
const toggleLanguageMenu = () => {
languageMenuOpen.value = !languageMenuOpen.value;
};
const changeLanguage = (languageCode: string) => {
locale.value = languageCode;
localStorage.setItem('language', languageCode);
languageMenuOpen.value = false;
notificationStore.addNotification({
type: 'success',
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
});
};
onClickOutside(languageMenuDropdown, () => {
languageMenuOpen.value = false;
}, { ignore: ['.language-menu-button'] });
const handleLogout = async () => {
try {
@ -163,23 +225,61 @@ const navigateToGroups = () => {
color: var(--primary);
}
.user-menu {
.language-selector {
position: relative;
}
.user-menu-button {
.language-menu-button {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 123, 84, 0.1);
}
}
.current-language {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.5px;
}
.language-dropdown {
min-width: 180px;
.dropdown-header {
padding: 0.5rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #e0e0e0;
}
.language-option {
display: block;
padding: 0.75rem 1rem;
color: var(--text-color);
text-decoration: none;
&:hover {
background-color: #f5f5f5;
}
&.active {
background-color: rgba(255, 123, 84, 0.1);
color: var(--primary);
font-weight: 500;
}
}
}
@ -207,6 +307,25 @@ const navigateToGroups = () => {
}
}
.user-menu {
position: relative;
}
.user-menu-button {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
.page-container {
flex-grow: 1;

View File

@ -9,6 +9,7 @@ import enMessages from './i18n/en.json' // Import en.json directly
import deMessages from './i18n/de.json'
import frMessages from './i18n/fr.json'
import esMessages from './i18n/es.json'
import nlMessages from './i18n/nl.json'
// Global styles
import './assets/main.scss'
@ -39,6 +40,7 @@ const i18n = createI18n({
de: deMessages,
fr: frMessages,
es: esMessages,
nl: nlMessages,
},
})

View File

@ -38,7 +38,7 @@ onMounted(async () => {
const tokenToUse = accessToken || legacyToken;
if (!tokenToUse) {
throw new Error('No token provided');
throw new Error(t('authCallbackPage.errors.noTokenProvided'));
}
await authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate } from '../types/chore'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory } from '../types/chore'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
@ -16,6 +16,8 @@ interface ChoreWithCompletion extends Chore {
is_completed: boolean;
completed_at: string | null;
updating: boolean;
assigned_user_name?: string;
completed_by_name?: string;
}
interface ChoreFormData {
@ -35,8 +37,14 @@ const chores = ref<ChoreWithCompletion[]>([])
const groups = ref<{ id: number, name: string }[]>([])
const showChoreModal = ref(false)
const showDeleteDialog = ref(false)
const showChoreDetailModal = ref(false)
const showHistoryModal = ref(false)
const isEditing = ref(false)
const selectedChore = ref<ChoreWithCompletion | null>(null)
const selectedChoreHistory = ref<ChoreHistory[]>([])
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
const loadingHistory = ref(false)
const loadingAssignments = ref(false)
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
@ -71,8 +79,10 @@ const loadChores = async () => {
return {
...c,
current_assignment_id: currentAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false,
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null,
is_completed: currentAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? null,
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
updating: false,
}
});
@ -80,7 +90,7 @@ const loadChores = async () => {
cachedChores.value = mappedChores;
cachedTimestamp.value = Date.now()
} catch (error) {
console.error('Failed to load all chores:', error)
console.error(t('choresPage.consoleErrors.loadFailed'), error)
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' })
} finally {
isLoading.value = false
@ -91,7 +101,7 @@ const loadGroups = async () => {
try {
groups.value = await groupService.getUserGroups();
} catch (error) {
console.error("Failed to load groups", error);
console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
}
}
@ -113,13 +123,24 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
if (chore.is_completed && chore.completed_at) {
const completedDate = new Date(chore.completed_at);
if (isTodayDate(completedDate)) {
return t('choresPage.completedToday');
return t('choresPage.completedToday') + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
}
return t('choresPage.completedOn', { date: format(completedDate, 'd MMM') });
const timeAgo = formatDistanceToNow(completedDate, { addSuffix: true });
return `Completed ${timeAgo}` + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
}
const parts: string[] = [];
// Show who it's assigned to if there's an assignment
if (chore.current_assignment_id && chore.assigned_user_name) {
parts.push(`Assigned to ${chore.assigned_user_name}`);
}
// Show creator info for group chores
if (chore.type === 'group' && chore.creator) {
parts.push(`Created by ${chore.creator.name || chore.creator.email}`);
}
if (chore.frequency && chore.frequency !== 'one_time') {
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
if (freqOption) {
@ -227,7 +248,7 @@ const handleFormSubmit = async () => {
due_date: createdChore.next_due_date
});
} catch (assignmentError) {
console.error('Failed to create assignment for new chore:', assignmentError);
console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError);
// Continue anyway since the chore was created
}
}
@ -237,7 +258,7 @@ const handleFormSubmit = async () => {
showChoreModal.value = false;
await loadChores();
} catch (error) {
console.error('Failed to save chore:', error);
console.error(t('choresPage.consoleErrors.saveFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
}
}
@ -255,7 +276,7 @@ const deleteChore = async () => {
showDeleteDialog.value = false
await loadChores()
} catch (error) {
console.error('Failed to delete chore:', error)
console.error(t('choresPage.consoleErrors.deleteFailed'), error)
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
}
}
@ -271,7 +292,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
});
chore.current_assignment_id = assignment.id;
} catch (error) {
console.error('Failed to create assignment:', error);
console.error(t('choresPage.consoleErrors.createAssignmentFailed'), error);
notificationStore.addNotification({
message: t('choresPage.notifications.createAssignmentFailed', 'Failed to create assignment for chore.'),
type: 'error'
@ -299,13 +320,84 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
});
await loadChores();
} catch (error) {
console.error('Failed to update chore completion status:', error);
console.error(t('choresPage.consoleErrors.updateCompletionStatusFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'), type: 'error' });
chore.is_completed = originalCompleted;
} finally {
chore.updating = false;
}
};
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showChoreDetailModal.value = true;
// Load assignments for this chore
loadingAssignments.value = true;
try {
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
} catch (error) {
console.error('Failed to load chore assignments:', error);
notificationStore.addNotification({
message: 'Failed to load chore assignments.',
type: 'error'
});
} finally {
loadingAssignments.value = false;
}
};
const openHistoryModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showHistoryModal.value = true;
// Load history for this chore
loadingHistory.value = true;
try {
selectedChoreHistory.value = await choreService.getChoreHistory(chore.id);
} catch (error) {
console.error('Failed to load chore history:', error);
notificationStore.addNotification({
message: 'Failed to load chore history.',
type: 'error'
});
} finally {
loadingHistory.value = false;
}
};
const formatHistoryEntry = (entry: ChoreHistory) => {
const timestamp = format(parseISO(entry.timestamp), 'MMM d, h:mm a');
const user = entry.changed_by_user?.name || entry.changed_by_user?.email || 'System';
switch (entry.event_type) {
case 'created':
return `${timestamp} - ${user} created this chore`;
case 'updated':
return `${timestamp} - ${user} updated this chore`;
case 'deleted':
return `${timestamp} - ${user} deleted this chore`;
case 'assigned':
return `${timestamp} - ${user} assigned this chore`;
case 'completed':
return `${timestamp} - ${user} completed this chore`;
case 'reopened':
return `${timestamp} - ${user} reopened this chore`;
default:
return `${timestamp} - ${user} performed action: ${entry.event_type}`;
}
};
const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return 'completed';
const today = startOfDay(new Date());
const dueDate = startOfDay(new Date(chore.next_due_date));
if (dueDate < today) return 'overdue';
if (isEqual(dueDate, today)) return 'due-today';
return 'upcoming';
};
</script>
<template>
@ -338,19 +430,35 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item">
<li v-for="chore in group.chores" :key="chore.id" 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="toggleCompletion(chore)">
<div class="checkbox-content">
<div class="chore-main-info">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ 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>
</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>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="openChoreDetailModal(chore)" title="View Details">
📋
</button>
<button class="btn btn-sm btn-neutral" @click="openHistoryModal(chore)" title="View History">
📅
</button>
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
{{ t('choresPage.edit', 'Edit') }}
</button>
@ -403,7 +511,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" placeholder="e.g., 10" min="1">
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div>
<div class="form-group">
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
@ -460,6 +568,113 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
</div>
</div>
</div>
<!-- Chore Detail Modal -->
<div v-if="showChoreDetailModal" class="modal-backdrop open" @click.self="showChoreDetailModal = false">
<div class="modal-container detail-modal">
<div class="modal-header">
<h3>{{ selectedChore?.name }}</h3>
<button type="button" @click="showChoreDetailModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body" v-if="selectedChore">
<div class="detail-section">
<h4>Details</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Type:</span>
<span class="value">{{ selectedChore.type === 'group' ? 'Group' : 'Personal' }}</span>
</div>
<div class="detail-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
</div>
<div class="detail-item">
<span class="label">Due date:</span>
<span class="value">{{ format(new Date(selectedChore.next_due_date), 'PPP') }}</span>
</div>
<div class="detail-item">
<span class="label">Frequency:</span>
<span class="value">
{{selectedChore?.frequency === 'custom' && selectedChore?.custom_interval_days
? `Every ${selectedChore.custom_interval_days} days`
: frequencyOptions.find(f => f.value === selectedChore?.frequency)?.label || selectedChore?.frequency
}}
</span>
</div>
<div v-if="selectedChore.description" class="detail-item full-width">
<span class="label">Description:</span>
<span class="value">{{ selectedChore.description }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Assignments</h4>
<div v-if="loadingAssignments" class="loading-spinner">Loading...</div>
<div v-else-if="selectedChoreAssignments.length === 0" class="no-data">
No assignments found for this chore.
</div>
<div v-else class="assignments-list">
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span>
</div>
<div class="assignment-details">
<span>Due: {{ format(new Date(assignment.due_date), 'PPP') }}</span>
<span v-if="assignment.completed_at">
Completed: {{ format(new Date(assignment.completed_at), 'PPP') }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
</div>
</div>
</div>
<!-- History Modal -->
<div v-if="showHistoryModal" class="modal-backdrop open" @click.self="showHistoryModal = false">
<div class="modal-container history-modal">
<div class="modal-header">
<h3>History: {{ selectedChore?.name }}</h3>
<button type="button" @click="showHistoryModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body">
<div v-if="loadingHistory" class="loading-spinner">Loading history...</div>
<div v-else-if="selectedChoreHistory.length === 0" class="no-data">
No history found for this chore.
</div>
<div v-else class="history-list">
<div v-for="entry in selectedChoreHistory" :key="entry.id" class="history-item">
<div class="history-content">
<span class="history-text">{{ formatHistoryEntry(entry) }}</span>
<div v-if="entry.event_data && Object.keys(entry.event_data).length > 0" class="history-data">
<details>
<summary>Details</summary>
<pre>{{ JSON.stringify(entry.event_data, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showHistoryModal = false">Close</button>
</div>
</div>
</div>
</div>
</template>
@ -679,4 +894,212 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
transform: scaleX(1);
transform-origin: left;
}
/* New styles for enhanced UX */
.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;
}
.chore-description {
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
margin-top: 0.25rem;
font-style: italic;
}
/* Status-based styling */
.status-overdue {
border-left: 4px solid #ef4444;
}
.status-due-today {
border-left: 4px solid #f59e0b;
}
.status-completed {
opacity: 0.7;
}
/* Modal styles */
.detail-modal .modal-container,
.history-modal .modal-container {
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h4 {
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--dark);
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item .label {
font-weight: 600;
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
}
.detail-item .value {
font-size: 0.875rem;
color: var(--dark);
}
.assignments-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #f9fafb;
}
.assignment-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.assigned-user {
font-weight: 500;
color: var(--dark);
}
.assignment-status {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: #fbbf24;
color: white;
}
.assignment-status.completed {
background-color: #10b981;
}
.assignment-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.history-item {
padding: 0.75rem;
border-left: 3px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 0 0.25rem 0.25rem 0;
}
.history-text {
font-size: 0.875rem;
color: var(--dark);
}
.history-data {
margin-top: 0.5rem;
}
.history-data details {
font-size: 0.75rem;
}
.history-data summary {
cursor: pointer;
color: var(--primary);
font-weight: 500;
}
.history-data pre {
margin-top: 0.25rem;
padding: 0.5rem;
background-color: #f3f4f6;
border-radius: 0.25rem;
font-size: 0.75rem;
overflow-x: auto;
}
.loading-spinner {
text-align: center;
padding: 1rem;
color: var(--dark);
opacity: 0.7;
}
.no-data {
text-align: center;
padding: 1rem;
color: var(--dark);
opacity: 0.7;
font-style: italic;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -281,12 +281,12 @@ const handleCreateGroup = async () => {
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
} else {
throw new Error('Invalid data received from server.');
throw new Error(t('groupsPage.errors.invalidDataFromServer'));
}
} catch (error: any) {
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.createFailed'));
createGroupFormError.value = message;
console.error('Error creating group:', error);
console.error(t('groupsPage.errors.createFailedConsole'), error);
notificationStore.addNotification({ message, type: 'error' });
} finally {
creatingGroup.value = false;
@ -327,7 +327,7 @@ const handleJoinGroup = async () => {
} catch (error: any) {
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed'));
joinGroupFormError.value = message;
console.error('Error joining group:', error);
console.error(t('groupsPage.errors.joinFailedConsole'), error);
notificationStore.addNotification({ message, type: 'error' });
} finally {
joiningGroup.value = false;

View File

@ -1,7 +1,8 @@
import { api } from './api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate } from '../types/chore'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
import { groupService } from './groupService'
import type { Group } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/config/api'
export const choreService = {
async getAllChores(): Promise<Chore[]> {
@ -117,7 +118,7 @@ export const choreService = {
// Update assignment
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
const response = await api.put(`/api/v1/chores/assignments/${assignmentId}`, update)
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
return response.data
},
@ -180,4 +181,9 @@ export const choreService = {
// Renamed original for safety, to be removed
await api.delete(`/api/v1/chores/personal/${choreId}`)
},
async getChoreHistory(choreId: number): Promise<ChoreHistory[]> {
const response = await apiClient.get(API_ENDPOINTS.CHORES.HISTORY(choreId))
return response.data
},
}

View File

@ -1,4 +1,6 @@
import { api } from './api'
import { apiClient, API_ENDPOINTS } from '@/config/api';
import type { Group } from '@/types/group';
import type { ChoreHistory } from '@/types/chore';
// Define Group interface matching backend schema
export interface Group {
@ -17,13 +19,17 @@ export interface Group {
export const groupService = {
async getUserGroups(): Promise<Group[]> {
try {
const response = await api.get('/api/v1/groups')
return response.data
} catch (error) {
console.error('Failed to fetch user groups:', error)
throw error
}
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
return response.data;
},
async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise<void> {
await apiClient.post(API_ENDPOINTS.GROUPS.GENERATE_SCHEDULE(groupId), data);
},
async getGroupChoreHistory(groupId: string): Promise<ChoreHistory[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.CHORE_HISTORY(groupId));
return response.data;
},
// Add other group-related service methods here, e.g.:

View File

@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import router from '@/router'
interface AuthState {
export interface AuthState {
accessToken: string | null
refreshToken: string | null
user: {
@ -57,6 +57,7 @@ export const useAuthStore = defineStore('auth', () => {
const fetchCurrentUser = async () => {
if (!accessToken.value) {
// No token, so definitely clear any residual state and return.
clearTokens()
return null
}
@ -65,7 +66,28 @@ export const useAuthStore = defineStore('auth', () => {
setUser(response.data)
return response.data
} catch (error: any) {
// Check if the error is from an Axios request and has a response status
if (error.isAxiosError && error.response) {
const status = error.response.status
if (status === 401 || status === 403) {
// Authentication error from the server, clear tokens.
console.error('Authentication error fetching user, clearing tokens:', error)
clearTokens()
} else {
// Other HTTP error, log it but don't clear tokens.
// The user might be null, but the token remains for other cached calls.
console.error('HTTP error fetching user, token preserved:', error)
}
} else {
// Network error (offline) or other non-HTTP error.
// Log the error but preserve tokens.
// This allows the app to function with cached data if available.
console.error('Network or other error fetching user, token preserved:', error)
}
// In all error cases where tokens are not cleared, return null for the user object.
// The existing user object (if any) will remain until explicitly cleared or overwritten.
// If the intention is to clear the user object on any fetch error, uncomment the next line:
// setUser(null);
return null
}
}

View File

@ -1,7 +1,8 @@
import type { UserPublic } from './user'
import type { User } from './user'
export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
export type ChoreType = 'personal' | 'group'
export type ChoreHistoryEventType = 'created' | 'updated' | 'deleted' | 'completed' | 'reopened' | 'assigned' | 'unassigned' | 'reassigned' | 'schedule_generated' | 'due_date_changed' | 'details_changed'
export interface Chore {
id: number
@ -16,14 +17,9 @@ export interface Chore {
created_at: string
updated_at: string
type: ChoreType
creator?: {
id: number
name: string
email: string
}
assignments?: ChoreAssignment[]
is_completed: boolean
completed_at: string | null
creator?: User
assignments: ChoreAssignment[]
history?: ChoreHistory[]
}
export interface ChoreCreate extends Omit<Chore, 'id'> { }
@ -38,11 +34,12 @@ export interface ChoreAssignment {
assigned_by_id: number
due_date: string
is_complete: boolean
completed_at: string | null
completed_at?: string
created_at: string
updated_at: string
chore?: Chore
assigned_user?: UserPublic
assigned_user?: User
history?: ChoreAssignmentHistory[]
}
export interface ChoreAssignmentCreate {
@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate {
}
export interface ChoreAssignmentUpdate {
due_date?: string
is_complete?: boolean
due_date?: string
assigned_to_user_id?: number
}
export interface ChoreHistory {
id: number
event_type: ChoreHistoryEventType
event_data?: Record<string, any>
changed_by_user?: User
timestamp: string
}
export interface ChoreAssignmentHistory {
id: number
event_type: ChoreHistoryEventType
event_data?: Record<string, any>
changed_by_user?: User
timestamp: string
}

12
fe/src/types/group.ts Normal file
View File

@ -0,0 +1,12 @@
// fe/src/types/group.ts
import type { AuthState } from '@/stores/auth';
import type { ChoreHistory } from './chore';
export interface Group {
id: number;
name: string;
created_by_id: number;
created_at: string;
members: AuthState['user'][];
chore_history?: ChoreHistory[];
}