Compare commits

..

No commits in common. "prod" and "ph4" have entirely different histories.
prod ... ph4

28 changed files with 308 additions and 2357 deletions

View File

@ -1,75 +0,0 @@
"""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,13 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session from app.database import get_transactional_session, get_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
from app.schemas.chore import ( from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic
ChoreCreate, ChoreUpdate, ChorePublic,
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
)
from app.crud import chore as crud_chore 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 from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -456,65 +451,3 @@ async def complete_chore_assignment(
except DatabaseIntegrityError as e: except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True) 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,16 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session from app.database import get_transactional_session, get_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum # Import model and enum from app.models import User as UserModel, UserRoleEnum # Import model and enum
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest from app.schemas.group import GroupCreate, GroupPublic
from app.schemas.invite import InviteCodePublic from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic, ListDetail 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 group as crud_group
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.crud import list as crud_list 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 ( from app.core.exceptions import (
GroupNotFoundError, GroupNotFoundError,
GroupPermissionError, GroupPermissionError,
@ -268,54 +265,3 @@ async def read_group_lists(
group_lists = [list for list in lists if list.group_id == group_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,17 +332,9 @@ class UserOperationError(HTTPException):
detail=detail 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): class ChoreNotFoundError(HTTPException):
"""Raised when a chore or assignment is not found.""" """Raised when a chore is not found."""
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None): def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None):
if detail: if detail:
error_detail = detail error_detail = detail
elif group_id is not None: elif group_id is not None:

View File

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

View File

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

View File

@ -1,83 +0,0 @@
# 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()

View File

@ -1,120 +0,0 @@
# 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,7 +24,6 @@ from sqlalchemy import (
Date # Added Date for Chore model Date # Added Date for Chore model
) )
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship, backref
from sqlalchemy.dialects.postgresql import JSONB
from .database import Base from .database import Base
@ -72,20 +71,6 @@ class ChoreTypeEnum(enum.Enum):
personal = "personal" personal = "personal"
group = "group" 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 --- # --- User Model ---
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -124,11 +109,6 @@ class User(Base):
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan") assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
# --- End Relationships for Chores --- # --- 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 --- # --- Group Model ---
class Group(Base): class Group(Base):
@ -157,10 +137,6 @@ class Group(Base):
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan") chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
# --- End Relationship for Chores --- # --- End Relationship for Chores ---
# --- History Relationships ---
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- UserGroup Association Model --- # --- UserGroup Association Model ---
class UserGroup(Base): class UserGroup(Base):
@ -407,7 +383,6 @@ class Chore(Base):
group = relationship("Group", back_populates="chores") group = relationship("Group", back_populates="chores")
creator = relationship("User", back_populates="created_chores") creator = relationship("User", back_populates="created_chores")
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan") assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
# --- ChoreAssignment Model --- # --- ChoreAssignment Model ---
@ -428,7 +403,6 @@ class ChoreAssignment(Base):
# --- Relationships --- # --- Relationships ---
chore = relationship("Chore", back_populates="assignments") chore = relationship("Chore", back_populates="assignments")
assigned_user = relationship("User", back_populates="assigned_chores") assigned_user = relationship("User", back_populates="assigned_chores")
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
# === NEW: RecurrencePattern Model === # === NEW: RecurrencePattern Model ===
@ -456,35 +430,3 @@ class RecurrencePattern(Base):
# === END: RecurrencePattern Model === # === 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,37 +1,13 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Optional, List, Any from typing import Optional, List
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
# Assuming ChoreFrequencyEnum is imported from models # Assuming ChoreFrequencyEnum is imported from models
# Adjust the import path if necessary based on your project structure. # Adjust the import path if necessary based on your project structure.
# e.g., from app.models import ChoreFrequencyEnum # e.g., from app.models import ChoreFrequencyEnum
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # For UserPublic relation from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel # For UserPublic relation
from .user import UserPublic # For embedding user information 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 # Chore Schemas
class ChoreBase(BaseModel): class ChoreBase(BaseModel):
name: str name: str
@ -99,8 +75,7 @@ class ChorePublic(ChoreBase):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
creator: Optional[UserPublic] = None # Embed creator UserPublic schema creator: Optional[UserPublic] = None # Embed creator UserPublic schema
assignments: List[ChoreAssignmentPublic] = [] # group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed
history: List[ChoreHistoryPublic] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -117,7 +92,6 @@ class ChoreAssignmentUpdate(BaseModel):
# Only completion status and perhaps due_date can be updated for an assignment # Only completion status and perhaps due_date can be updated for an assignment
is_complete: Optional[bool] = None is_complete: Optional[bool] = None
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed 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): class ChoreAssignmentPublic(ChoreAssignmentBase):
id: int id: int
@ -128,11 +102,10 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
# Embed ChorePublic and UserPublic for richer responses # Embed ChorePublic and UserPublic for richer responses
chore: Optional[ChorePublic] = None chore: Optional[ChorePublic] = None
assigned_user: Optional[UserPublic] = None assigned_user: Optional[UserPublic] = None
history: List[ChoreAssignmentHistoryPublic] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic # To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
# We can update forward refs after all models are defined. # We can update forward refs after all models are defined.
ChorePublic.model_rebuild() # ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings
ChoreAssignmentPublic.model_rebuild() # ChoreAssignmentPublic.model_rebuild()

View File

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

View File

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

View File

@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
BASE: '/lists', BASE: '/lists',
BY_ID: (id: string) => `/lists/${id}`, BY_ID: (id: string) => `/lists/${id}`,
STATUS: (id: string) => `/lists/${id}/status`, STATUS: (id: string) => `/lists/${id}/status`,
STATUSES: '/lists/statuses',
ITEMS: (listId: string) => `/lists/${listId}/items`, ITEMS: (listId: string) => `/lists/${listId}/items`,
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
EXPENSES: (listId: string) => `/lists/${listId}/expenses`, EXPENSES: (listId: string) => `/lists/${listId}/expenses`,
@ -61,15 +62,13 @@ export const API_ENDPOINTS = {
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`, SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
ROLES: (groupId: string) => `/groups/${groupId}/roles`, ROLES: (groupId: string) => `/groups/${groupId}/roles`,
ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`, 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
INVITES: { INVITES: {
BASE: '/invites', BASE: '/invites',
BY_ID: (id: string) => `/invites/${id}`, BY_ID: (id: string) => `/invites/${id}`,
ACCEPT: (id: string) => `/invites/accept/${id}`, ACCEPT: '/invites/accept',
DECLINE: (id: string) => `/invites/decline/${id}`, DECLINE: (id: string) => `/invites/decline/${id}`,
REVOKE: (id: string) => `/invites/revoke/${id}`, REVOKE: (id: string) => `/invites/revoke/${id}`,
LIST: '/invites', LIST: '/invites',
@ -121,12 +120,4 @@ export const API_ENDPOINTS = {
METRICS: '/health/metrics', METRICS: '/health/metrics',
LOGS: '/health/logs', 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,6 +1,5 @@
import { api } from '@/services/api'; import { api } from '@/services/api';
import { API_BASE_URL, API_VERSION } from './api-config'; import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
export { API_ENDPOINTS } from './api-config';
// Helper function to get full API URL // Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => { export const getApiUrl = (endpoint: string): string => {
@ -15,3 +14,5 @@ export const apiClient = {
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config), patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config), delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
}; };
export { API_ENDPOINTS };

View File

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

View File

@ -555,15 +555,5 @@
"sampleTodosHeader": "Sample Todos (from IndexPage data)", "sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:", "totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display." "noTodos": "No todos to display."
},
"languageSelector": {
"title": "Language",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
} }
} }

View File

@ -627,15 +627,5 @@
"sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)", "sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)",
"totalCountLabel": "Recuento total de meta:", "totalCountLabel": "Recuento total de meta:",
"noTodos": "No hay tareas para mostrar." "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,15 +627,5 @@
"sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)", "sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)",
"totalCountLabel": "Nombre total depuis meta :", "totalCountLabel": "Nombre total depuis meta :",
"noTodos": "Aucune tâche à afficher." "noTodos": "Aucune tâche à afficher."
},
"languageSelector": {
"title": "Langue",
"languages": {
"en": "English",
"de": "Deutsch",
"nl": "Nederlands",
"fr": "Français",
"es": "Español"
}
} }
} }

View File

@ -627,15 +627,5 @@
"sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)", "sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)",
"totalCountLabel": "Totaal aantal uit meta:", "totalCountLabel": "Totaal aantal uit meta:",
"noTodos": "Geen taken om weer te geven." "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,40 +2,17 @@
<div class="main-layout"> <div class="main-layout">
<header class="app-header"> <header class="app-header">
<div class="toolbar-title">mitlist</div> <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 -->
<div class="flex align-end"> <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
<div class="language-selector" v-if="authStore.isAuthenticated"> <path d="M0 0h24v24H0z" fill="none" />
<button @click="toggleLanguageMenu" class="language-menu-button"> <path
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54"> 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" />
<path d="M0 0h24v24H0z" fill="none" /> </svg>
<path </button>
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" /> <div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
</svg> <a href="#" @click.prevent="handleLogout">Logout</a>
<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>
</div> </div>
</header> </header>
@ -76,14 +53,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineComponent, onMounted, computed } from 'vue'; import { ref, defineComponent, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import OfflineIndicator from '@/components/OfflineIndicator.vue'; import OfflineIndicator from '@/components/OfflineIndicator.vue';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { useGroupStore } from '@/stores/groupStore'; import { useGroupStore } from '@/stores/groupStore';
import { useI18n } from 'vue-i18n';
defineComponent({ defineComponent({
name: 'MainLayout' name: 'MainLayout'
@ -94,7 +70,6 @@ const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const groupStore = useGroupStore(); const groupStore = useGroupStore();
const { t, locale } = useI18n();
// Add initialization logic // Add initialization logic
const initializeApp = async () => { const initializeApp = async () => {
@ -115,12 +90,6 @@ onMounted(() => {
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
groupStore.fetchGroups(); 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); const userMenuOpen = ref(false);
@ -134,37 +103,6 @@ onClickOutside(userMenuDropdown, () => {
userMenuOpen.value = false; userMenuOpen.value = false;
}, { ignore: ['.user-menu-button'] }); }, { 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 () => { const handleLogout = async () => {
try { try {
@ -225,61 +163,23 @@ const navigateToGroups = () => {
color: var(--primary); color: var(--primary);
} }
.language-selector { .user-menu {
position: relative; position: relative;
} }
.language-menu-button { .user-menu-button {
background: none; background: none;
border: none; border: none;
color: var(--primary); color: var(--primary);
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
border-radius: 8px; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; justify-content: center;
&:hover { &:hover {
background-color: rgba(255, 123, 84, 0.1); background-color: rgba(255, 255, 255, 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;
}
} }
} }
@ -307,25 +207,6 @@ 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 { .page-container {
flex-grow: 1; flex-grow: 1;

View File

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

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns' import { format, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
import { choreService } from '../services/choreService' import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory } from '../types/chore' import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate } from '../types/chore'
import { groupService } from '../services/groupService' import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
@ -16,8 +16,6 @@ interface ChoreWithCompletion extends Chore {
is_completed: boolean; is_completed: boolean;
completed_at: string | null; completed_at: string | null;
updating: boolean; updating: boolean;
assigned_user_name?: string;
completed_by_name?: string;
} }
interface ChoreFormData { interface ChoreFormData {
@ -37,14 +35,8 @@ const chores = ref<ChoreWithCompletion[]>([])
const groups = ref<{ id: number, name: string }[]>([]) const groups = ref<{ id: number, name: string }[]>([])
const showChoreModal = ref(false) const showChoreModal = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const showChoreDetailModal = ref(false)
const showHistoryModal = ref(false)
const isEditing = ref(false) const isEditing = ref(false)
const selectedChore = ref<ChoreWithCompletion | null>(null) 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 cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0) const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
@ -79,10 +71,8 @@ const loadChores = async () => {
return { return {
...c, ...c,
current_assignment_id: currentAssignment?.id ?? null, current_assignment_id: currentAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? false, is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false,
completed_at: currentAssignment?.completed_at ?? null, completed_at: currentAssignment?.completed_at ?? c.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, updating: false,
} }
}); });
@ -123,24 +113,13 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
if (chore.is_completed && chore.completed_at) { if (chore.is_completed && chore.completed_at) {
const completedDate = new Date(chore.completed_at); const completedDate = new Date(chore.completed_at);
if (isTodayDate(completedDate)) { if (isTodayDate(completedDate)) {
return t('choresPage.completedToday') + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : ''); return t('choresPage.completedToday');
} }
const timeAgo = formatDistanceToNow(completedDate, { addSuffix: true }); return t('choresPage.completedOn', { date: format(completedDate, 'd MMM') });
return `Completed ${timeAgo}` + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
} }
const parts: string[] = []; 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') { if (chore.frequency && chore.frequency !== 'one_time') {
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency); const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
if (freqOption) { if (freqOption) {
@ -327,77 +306,6 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
chore.updating = false; 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> </script>
<template> <template>
@ -430,35 +338,19 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2> <h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
<div class="neo-item-list-container"> <div class="neo-item-list-container">
<ul class="neo-item-list"> <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"> <div class="neo-item-content">
<label class="neo-checkbox-label"> <label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)"> <input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
<div class="checkbox-content"> <div class="checkbox-content">
<div class="chore-main-info"> <span class="checkbox-text-span"
<span class="checkbox-text-span" :class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }"> {{ chore.name }}
{{ chore.name }} </span>
</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> <span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
</div> </div>
</label> </label>
<div class="neo-item-actions"> <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)"> <button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
{{ t('choresPage.edit', 'Edit') }} {{ t('choresPage.edit', 'Edit') }}
</button> </button>
@ -509,7 +401,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div> </div>
<div v-if="choreForm.frequency === 'custom'" class="form-group"> <div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)') <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" <input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1"> class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div> </div>
@ -530,7 +422,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div> </div>
<div v-if="choreForm.type === 'group'" class="form-group"> <div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to 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"> <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> <option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select> </select>
@ -539,7 +431,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{ <button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel') t('choresPage.form.cancel', 'Cancel')
}}</button> }}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') : <button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button> t('choresPage.form.create', 'Create') }}</button>
</div> </div>
@ -564,114 +456,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button> t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{ <button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete') 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> </div>
</div> </div>
@ -894,212 +679,4 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
transform: scaleX(1); transform: scaleX(1);
transform-origin: left; 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> </style>

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,7 @@
import { api } from './api' import { api } from './api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore' import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate } from '../types/chore'
import { groupService } from './groupService' import { groupService } from './groupService'
import type { Group } from './groupService' import type { Group } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/config/api'
export const choreService = { export const choreService = {
async getAllChores(): Promise<Chore[]> { async getAllChores(): Promise<Chore[]> {
@ -118,7 +117,7 @@ export const choreService = {
// Update assignment // Update assignment
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> { async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update) const response = await api.put(`/api/v1/chores/assignments/${assignmentId}`, update)
return response.data return response.data
}, },
@ -181,9 +180,4 @@ export const choreService = {
// Renamed original for safety, to be removed // Renamed original for safety, to be removed
await api.delete(`/api/v1/chores/personal/${choreId}`) 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,6 +1,4 @@
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { api } from './api'
import type { Group } from '@/types/group';
import type { ChoreHistory } from '@/types/chore';
// Define Group interface matching backend schema // Define Group interface matching backend schema
export interface Group { export interface Group {
@ -19,17 +17,13 @@ export interface Group {
export const groupService = { export const groupService = {
async getUserGroups(): Promise<Group[]> { async getUserGroups(): Promise<Group[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); try {
return response.data; const response = await api.get('/api/v1/groups')
}, return response.data
} catch (error) {
async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise<void> { console.error('Failed to fetch user groups:', error)
await apiClient.post(API_ENDPOINTS.GROUPS.GENERATE_SCHEDULE(groupId), data); throw error
}, }
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.: // 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 { ref, computed } from 'vue'
import router from '@/router' import router from '@/router'
export interface AuthState { interface AuthState {
accessToken: string | null accessToken: string | null
refreshToken: string | null refreshToken: string | null
user: { user: {

View File

@ -1,8 +1,7 @@
import type { User } from './user' import type { UserPublic } from './user'
export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom' export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
export type ChoreType = 'personal' | 'group' 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 { export interface Chore {
id: number id: number
@ -17,9 +16,14 @@ export interface Chore {
created_at: string created_at: string
updated_at: string updated_at: string
type: ChoreType type: ChoreType
creator?: User creator?: {
assignments: ChoreAssignment[] id: number
history?: ChoreHistory[] name: string
email: string
}
assignments?: ChoreAssignment[]
is_completed: boolean
completed_at: string | null
} }
export interface ChoreCreate extends Omit<Chore, 'id'> { } export interface ChoreCreate extends Omit<Chore, 'id'> { }
@ -34,12 +38,11 @@ export interface ChoreAssignment {
assigned_by_id: number assigned_by_id: number
due_date: string due_date: string
is_complete: boolean is_complete: boolean
completed_at?: string completed_at: string | null
created_at: string created_at: string
updated_at: string updated_at: string
chore?: Chore chore?: Chore
assigned_user?: User assigned_user?: UserPublic
history?: ChoreAssignmentHistory[]
} }
export interface ChoreAssignmentCreate { export interface ChoreAssignmentCreate {
@ -49,23 +52,6 @@ export interface ChoreAssignmentCreate {
} }
export interface ChoreAssignmentUpdate { export interface ChoreAssignmentUpdate {
is_complete?: boolean
due_date?: string due_date?: string
assigned_to_user_id?: number is_complete?: boolean
}
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
} }

View File

@ -1,12 +0,0 @@
// 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[];
}