Compare commits

...

8 Commits
ph4 ... ph5

Author SHA1 Message Date
mohamad
3ec2ff1f89 feat: Add group members endpoint and enhance UI translations
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s
This commit introduces a new API endpoint to retrieve group members, ensuring that only authorized users can access member information. Additionally, it updates the UI with improved translations for chore management, group lists, and activity logs in both English and Dutch. Styling adjustments in the ListDetailPage enhance user interaction, while minor changes in the SCSS file improve the overall visual presentation.
2025-06-08 12:29:09 +02:00
mohamad
8afeda1df7 Enhance ChoresPage and GroupDetailPage with improved styling and UI updates
This commit introduces the following changes:

- Updated styling for overdue and due-today chore statuses in ChoresPage, replacing border styles with box shadows for better visibility.
- Adjusted opacity for completed chores to enhance UI clarity.
- Minor formatting fixes in GroupDetailPage for improved button and text alignment.

These updates aim to enhance the user experience by providing clearer visual cues and a more polished interface.
2025-06-08 11:03:32 +02:00
mohamad
26f589751d Fix API base URL in api-config.ts to correct domain
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m22s
2025-06-08 10:22:52 +02:00
mohamad
81f551a21d Update API base URL to production environment in api-config.ts
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m29s
2025-06-08 02:08:36 +02:00
mohamad
88c9516308 feat: Enhance GroupDetailPage with chore assignments and history
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s
This update introduces significant improvements to the GroupDetailPage, including:

- Added detailed modals for chore assignments and history.
- Implemented loading states for assignments and chore history.
- Enhanced chore display with status indicators for overdue and due-today.
- Improved UI with new styles for chore items and assignment details.

These changes enhance user experience by providing more context and information about group chores and their assignments.
2025-06-08 02:03:38 +02:00
mohamad
402489c928 feat: Enhance ChoresPage with detail and history modals
This update introduces new functionality to the ChoresPage, including:

- Added modals for viewing chore details and history.
- Implemented loading states for assignments and history.
- Enhanced chore display with assignment and completion details.
- Introduced new types for chore assignments and history.
- Improved UI with badges for overdue and due-today statuses.

These changes improve user experience by providing more context and information about chores and their assignments.
2025-06-08 01:32:53 +02:00
mohamad
f20f3c960d feat: Add language selector and Dutch translations 2025-06-08 01:32:40 +02:00
mohamad
fb951acb72 feat: Add chore history and scheduling functionality
This commit introduces new models and endpoints for managing chore history and scheduling within the application. Key changes include:

- Added `ChoreHistory` and `ChoreAssignmentHistory` models to track changes and events related to chores and assignments.
- Implemented CRUD operations for chore history in the `history.py` module.
- Created endpoints to retrieve chore and assignment history in the `chores.py` and `groups.py` files.
- Introduced a scheduling feature for group chores, allowing for round-robin assignment generation.
- Updated existing chore and assignment CRUD operations to log history entries for create, update, and delete actions.

This enhancement improves the tracking of chore-related events and facilitates better management of group chore assignments.
2025-06-08 01:17:53 +02:00
30 changed files with 2451 additions and 341 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__)
@ -450,4 +455,66 @@ async def complete_chore_assignment(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
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)
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,17 @@ 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.schemas.user import UserPublic
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,
@ -89,6 +93,33 @@ async def read_group(
return group
@router.get(
"/{group_id}/members",
response_model=List[UserPublic],
summary="Get Group Members",
tags=["Groups"]
)
async def read_group_members(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all members of a specific group, if the user is part of it."""
logger.info(f"User {current_user.email} requesting members for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise GroupMembershipError(group_id, "view group members")
group = await crud_group.get_group_by_id(db=db, group_id=group_id)
if not group:
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise GroupNotFoundError(group_id)
# Extract and return just the user information from member associations
return [member_assoc.user for member_assoc in group.member_associations]
@router.post(
"/{group_id}/invites",
@ -264,4 +295,55 @@ async def read_group_lists(
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
group_lists = [list for list in lists if list.group_id == group_id]
return group_lists
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,11 +483,10 @@ 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)
# Check permissions - only assignee can complete, but chore managers can reschedule
can_manage = False
if chore.type == ChoreTypeEnum.personal:
@ -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:
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
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
update_data['completed_at'] = None
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
@ -100,12 +126,13 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
created_at: datetime
updated_at: datetime
# Embed ChorePublic and UserPublic for richer responses
chore: Optional[ChorePublic] = None
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
@ -39,4 +47,7 @@ class UserGroupPublic(BaseModel):
# Properties stored in DB (if needed, often GroupPublic is sufficient)
# class GroupInDB(GroupPublic):
# pass
# pass
# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic
GroupPublic.model_rebuild()

View File

@ -81,7 +81,8 @@
body {
font-family: 'Patrick Hand', cursive;
background-color: var(--light);
background-image: var(--paper-texture);
// background-image: var(--paper-texture);
// background-image: url('@/assets/11.webp');
// padding: 2rem 1rem;s
color: var(--dark);
font-size: 1.1rem;
@ -917,11 +918,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 +944,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

@ -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 => {
@ -13,6 +14,4 @@ export const apiClient = {
put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config),
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
};
export { API_ENDPOINTS };
};

View File

@ -627,5 +627,15 @@
"sampleTodosHeader": "Beispiel-Todos (aus IndexPage-Daten)",
"totalCountLabel": "Gesamtzahl aus Meta:",
"noTodos": "Keine Todos zum Anzeigen."
},
"languageSelector": {
"title": "Sprache",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -97,6 +97,8 @@
"addChore": "+",
"edit": "Edit",
"delete": "Delete",
"editChore": "Edit Chore",
"createChore": "Create Chore",
"empty": {
"title": "No Chores Yet",
"message": "Get started by adding your first chore!",
@ -170,6 +172,23 @@
"loadingLabel": "Loading group details...",
"retryButton": "Retry",
"groupNotFound": "Group not found or an error occurred.",
"lists": {
"title": "Group Lists"
},
"generateScheduleModal": {
"title": "Generate Schedule"
},
"activityLog": {
"title": "Activity Log",
"emptyState": "No activity to show yet."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!",
"generateScheduleButton": "Generate Schedule"
},
"members": {
"title": "Group Members",
"defaultRole": "Member",
@ -201,12 +220,6 @@
"console": {
"noActiveInvite": "No active invite code found for this group."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": {
"title": "Group Expenses",
"manageButton": "Manage Expenses",
@ -445,6 +458,8 @@
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"emptyStateTitle": "No Expenses",
"emptyStateMessage": "Add your first expense to get started.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
@ -555,5 +570,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"
}
}
}

View File

@ -627,5 +627,15 @@
"sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)",
"totalCountLabel": "Recuento total de meta:",
"noTodos": "No hay tareas para mostrar."
},
"languageSelector": {
"title": "Idioma",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -627,5 +627,15 @@
"sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)",
"totalCountLabel": "Nombre total depuis meta :",
"noTodos": "Aucune tâche à afficher."
},
"languageSelector": {
"title": "Langue",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
}
}

View File

@ -94,6 +94,11 @@
},
"choresPage": {
"title": "Taken",
"addChore": "+",
"edit": "Bewerken",
"delete": "Verwijderen",
"editChore": "Taak bewerken",
"createChore": "Nieuwe taak",
"tabs": {
"overdue": "Achterstallig",
"today": "Vandaag",
@ -339,6 +344,16 @@
"partiallyPaid": "Gedeeltelijk betaald",
"unpaid": "Onbetaald",
"unknown": "Onbekende status"
},
"lists": {
"title": "Groepslijsten"
},
"generateScheduleModal": {
"title": "Schema genereren"
},
"activityLog": {
"title": "Activiteitenlogboek",
"emptyState": "Nog geen activiteiten om weer te geven."
}
},
"accountPage": {
@ -627,5 +642,15 @@
"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,17 +2,40 @@
<div class="main-layout">
<header class="app-header">
<div class="toolbar-title">mitlist</div>
<div class="user-menu" v-if="authStore.isAuthenticated">
<button @click="toggleUserMenu" class="user-menu-button">
<!-- Placeholder for user icon -->
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
<path d="M0 0h24v24H0z" fill="none" />
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
</button>
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
<a href="#" @click.prevent="handleLogout">Logout</a>
<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 -->
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
<path d="M0 0h24v24H0z" fill="none" />
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
</button>
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
<a href="#" @click.prevent="handleLogout">Logout</a>
</div>
</div>
</div>
</header>
@ -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

@ -40,6 +40,7 @@ const i18n = createI18n({
de: deMessages,
fr: frMessages,
es: esMessages,
nl: nlMessages,
},
})

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,
}
});
@ -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) {
@ -306,6 +327,77 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
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">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<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>
@ -401,7 +509,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
</div>
<div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label>
}}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div>
@ -422,7 +530,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
</div>
<div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label>
}}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
@ -431,7 +539,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel')
}}</button>
}}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button>
</div>
@ -456,7 +564,114 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete')
}}</button>
}}</button>
</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>
@ -503,6 +718,19 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
overflow: hidden;
}
/* Status-based styling */
.schedule-group:has(.status-overdue) .neo-item-list-container {
box-shadow: 6px 6px 0 #c72d2d;
}
.schedule-group:has(.status-due-today) .neo-item-list-container {
box-shadow: 6px 6px 0 #b37814;
}
.status-completed {
opacity: 0.7;
}
/* Neo-style list items from ListDetailPage */
.neo-item-list {
list-style: none;
@ -679,4 +907,199 @@ 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;
}
/* 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

@ -316,22 +316,23 @@
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{
<p><strong>{{ $t('listDetailPage.modals.costSummary.totalCostLabel') }}</strong> {{
formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{
<p><strong>{{ $t('listDetailPage.modals.costSummary.equalShareLabel') }}</strong> {{
formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{
<p><strong>{{ $t('listDetailPage.modals.costSummary.participantsLabel') }}</strong> {{
listCostSummary.num_participating_users }}</p>
</div>
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
<h4>{{ $t('listDetailPage.modals.costSummary.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th>
<th>{{ $t('listDetailPage.modals.costSummary.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}
</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance') }}</th>
</tr>
</thead>
<tbody>
@ -348,7 +349,7 @@
</table>
</div>
</div>
<p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
<p v-else>{{ $t('listDetailPage.modals.costSummary.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}
@ -357,7 +358,7 @@
</VModal>
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" :title="$t('listDetailPage.settleShareModal.title')"
<VModal v-model="showSettleModal" :title="$t('listDetailPage.modals.settleShare.title')"
@update:modelValue="!$event && closeSettleShareModal()" size="md">
<template #default>
<div v-if="isSettlementLoading" class="text-center">
@ -365,11 +366,11 @@
</div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else>
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', {
<p>{{ $t('listDetailPage.modals.settleShare.settleAmountFor', {
userName: selectedSplitForSettlement?.user?.name
|| selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}`
}) }}</p>
<VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')"
<VFormField :label="$t('listDetailPage.modals.settleShare.amountLabel')"
:error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField>
@ -377,9 +378,10 @@
</template>
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.settleShareModal.cancelButton')
$t('listDetailPage.modals.settleShare.cancelButton')
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
<VButton variant="primary" @click="handleConfirmSettle">{{
$t('listDetailPage.modals.settleShare.confirmButton')
}}</VButton>
</template>
</VModal>
@ -1215,12 +1217,12 @@ const closeSettleShareModal = () => {
const validateSettleAmount = (): boolean => {
settleAmountError.value = null;
if (!settleAmount.value.trim()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount');
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.enterAmount');
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.positiveAmount');
return false;
}
if (selectedSplitForSettlement.value) {
@ -1228,11 +1230,11 @@ const validateSettleAmount = (): boolean => {
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false;
}
} else {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.noSplitSelected');
return false;
}
return true;

View File

@ -29,8 +29,8 @@
<div class="neo-lists-grid">
<div v-for="list in lists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
:data-list-id="list.id">
@touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd"
@touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list">

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: {

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[];
}