Compare commits

..

65 Commits
ph4 ... prod

Author SHA1 Message Date
mo
51474695ef Merge pull request 'Update API base URL to production environment in api-config.ts' (#61) from ph5 into prod
Reviewed-on: #61
2025-06-08 02:08:58 +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
mo
d13a231113 Merge pull request 'ph5' (#60) from ph5 into prod
Reviewed-on: #60
2025-06-08 02:04:07 +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
mo
3d2bc3846a Merge pull request 'Update API base URL to production environment in api-config.ts' (#59) from ph4 into prod
Reviewed-on: #59
2025-06-07 22:14:52 +02:00
mo
ef2caaee56 Merge pull request 'Update logging level to INFO, refine chore update logic, and enhance invite acceptance flow' (#58) from ph4 into prod
Reviewed-on: #58
2025-06-07 22:09:00 +02:00
mo
6004911912 Merge pull request 'ph4' (#57) from ph4 into prod
Reviewed-on: #57
2025-06-07 18:56:59 +02:00
mo
ef41ebb954 Merge pull request 'ph4' (#56) from ph4 into prod
Reviewed-on: #56
2025-06-07 18:08:41 +02:00
mo
24a5024e88 Merge pull request 'ph4' (#55) from ph4 into prod
Reviewed-on: #55
2025-06-05 01:05:04 +02:00
mo
acdf1af9b9 Merge pull request 'Update API base URL to production environment' (#54) from ph4 into prod
Reviewed-on: #54
2025-06-04 17:56:00 +02:00
mo
f3fdbc0592 Merge pull request 'ph4' (#53) from ph4 into prod
Reviewed-on: #53
2025-06-04 17:51:17 +02:00
mo
1f7abcbd85 Merge pull request 'ph4' (#52) from ph4 into prod
Reviewed-on: #52
2025-06-02 19:09:21 +02:00
mo
76446cf84e Merge pull request 'Update Dockerfile to use npm install and modify PWA theme and background colors in vite.config.ts' (#51) from ph4 into prod
Reviewed-on: #51
2025-06-02 00:29:25 +02:00
mo
df08bdaf9e Merge pull request 'Update vue-i18n dependency to version 9.9.1 in package.json' (#50) from ph4 into prod
Reviewed-on: #50
2025-06-02 00:25:41 +02:00
mo
6a61bb8df4 Merge pull request 'ph4' (#49) from ph4 into prod
Reviewed-on: #49
2025-06-02 00:20:48 +02:00
mo
e124f05e7b Merge pull request 'Update OAuth redirect URIs and API routing structure' (#48) from ph4 into prod
Reviewed-on: #48
2025-06-01 22:43:17 +02:00
mo
f60002d98e Merge pull request 'Refactor API routing and update login URLs' (#47) from ph4 into prod
Reviewed-on: #47
2025-06-01 22:38:08 +02:00
mo
708a6280d6 Merge pull request 'Update API base URL in api-config.ts to point to the new production environment' (#46) from ph4 into prod
Reviewed-on: #46
2025-06-01 22:16:58 +02:00
mo
20e1c2ac69 Merge pull request 'ph4' (#45) from ph4 into prod
Reviewed-on: #45
2025-06-01 22:03:25 +02:00
mo
e777268643 Merge pull request 'Update API base URL in api-config.ts to point to the production environment' (#44) from ph4 into prod
Reviewed-on: #44
2025-06-01 21:06:58 +02:00
mo
3be38002e7 Merge pull request 'Refactor: Update styling and functionality in various components' (#43) from ph4 into prod
Reviewed-on: #43
2025-06-01 20:41:23 +02:00
mo
d23219fd60 Merge pull request 'Refactor GroupsPage: Replace VButton and VIcon components with standard HTML button and SVG for improved compatibility and maintainability. Added console logs for better debugging during the create list dialog flow.' (#42) from ph4 into prod
Reviewed-on: #42
2025-06-01 20:00:16 +02:00
mo
088f371547 Merge pull request 'Enhance group selection flow by ensuring latest groups data is fetched before opening the create list dialog. Additionally, refresh the groups list after a new list is created to reflect updates. This improves data consistency and user experience on th…' (#40) from ph4 into prod
Reviewed-on: #40
2025-06-01 19:56:27 +02:00
mo
b5f16a3d0d Merge pull request 'Refactor: Replace button elements with VButton and VIcon components in GroupsPage' (#39) from ph4 into prod
Reviewed-on: #39
2025-06-01 19:51:23 +02:00
mo
0a6877852a Merge pull request 'ph4' (#38) from ph4 into prod
Reviewed-on: #38
2025-06-01 19:19:20 +02:00
mo
d3d5f88e09 Merge pull request 'refactor: Simplify upgrade function by directly creating enums and adding new tables for chores and chore assignments in the initial schema' (#37) from ph4 into prod
Reviewed-on: #37
2025-06-01 18:20:43 +02:00
mo
1ccd4456f6 Merge pull request 'refactor: Encapsulate enum creation logic within a dedicated function in the upgrade process for improved readability and maintainability' (#36) from ph4 into prod
Reviewed-on: #36
2025-06-01 18:15:41 +02:00
mo
acdb628777 Merge pull request 'refactor: Modify upgrade function to accept context parameter for enhanced migration flexibility' (#35) from ph4 into prod
Reviewed-on: #35
2025-06-01 18:13:11 +02:00
mo
463cfe070c Merge pull request 'refactor: Clarify access to revision strings in migration function by referencing Script object within RevisionStep' (#34) from ph4 into prod
Reviewed-on: #34
2025-06-01 18:09:45 +02:00
mo
8a98aee6c1 Merge pull request 'refactor: Update migration function to access revision strings from RevisionStep objects for improved clarity' (#33) from ph4 into prod
Reviewed-on: #33
2025-06-01 17:50:18 +02:00
mo
0a42d68853 Merge pull request 'refactor: Introduce migration function to streamline upgrade steps in Alembic migrations' (#32) from ph4 into prod
Reviewed-on: #32
2025-06-01 17:45:43 +02:00
mo
26315cd407 Merge pull request 'refactor: Improve Alembic migration functions by integrating configuration and script directory handling for enhanced migration context management' (#31) from ph4 into prod
Reviewed-on: #31
2025-06-01 17:42:33 +02:00
mo
8517cbee99 Merge pull request 'refactor: Update migration functions to accept connection parameter for improved flexibility and consistency' (#30) from ph4 into prod
Reviewed-on: #30
2025-06-01 17:39:22 +02:00
mo
f882b86f05 Merge pull request 'refactor: Separate async migration logic into dedicated module and streamline migration functions for improved clarity and maintainability' (#29) from ph4 into prod
Reviewed-on: #29
2025-06-01 17:33:14 +02:00
mo
5e79be16d3 Merge pull request 'refactor: Enhance Alembic migration functions to support direct execution and improve error handling for database URL configuration' (#28) from ph4 into prod
Reviewed-on: #28
2025-06-01 17:30:01 +02:00
mo
d1b8191c8d Merge pull request 'refactor: Update Alembic migration functions to support asynchronous execution and streamline migration handling in application startup' (#27) from ph4 into prod
Reviewed-on: #27
2025-06-01 17:20:42 +02:00
mo
8d3bf927b6 Merge pull request 'fix: Add Alembic directory and configuration file to production Dockerfile for migration support' (#26) from ph4 into prod
Reviewed-on: #26
2025-06-01 17:16:39 +02:00
mo
e62bceb955 Merge pull request 'fix: Update Alembic configuration to use absolute paths for ini file and script location in migration process' (#25) from ph4 into prod
Reviewed-on: #25
2025-06-01 17:13:21 +02:00
mo
99d06baa03 Merge pull request 'fix: Enhance Alembic configuration by setting script location and database URL validation in migration process' (#24) from ph4 into prod
Reviewed-on: #24
2025-06-01 17:10:06 +02:00
mo
530867bb16 Merge pull request 'refactor: Simplify Dockerfile by reorganizing Alembic file copying and enhance migration handling in application startup' (#23) from ph4 into prod
Reviewed-on: #23
2025-06-01 17:03:25 +02:00
mo
de5f54f970 Merge pull request 'fix: Update Alembic configuration in startup event to set script location and database URL' (#22) from ph4 into prod
Reviewed-on: #22
2025-06-01 16:57:22 +02:00
mo
792a7878f0 Merge pull request 'feat: Add Alembic configuration and migration command to application startup' (#21) from ph4 into prod
Reviewed-on: #21
2025-06-01 16:54:30 +02:00
mo
c62c0d0157 Merge pull request 'fix ig' (#20) from ph4 into prod
Reviewed-on: #20
2025-06-01 16:49:35 +02:00
mo
855dd852c5 Merge pull request 'refactor: Update production Dockerfile to use Node.js for serving built assets and enhance environment variable injection' (#19) from ph4 into prod
Reviewed-on: #19
2025-06-01 16:46:19 +02:00
mo
028c991d91 Merge pull request 'refactor: Transition production Dockerfile to use Nginx for serving built assets and streamline environment variable handling' (#18) from ph4 into prod
Reviewed-on: #18
2025-06-01 16:39:34 +02:00
mo
1f7f573f64 Merge pull request 'refactor: Update environment variable handling in Dockerfile for production' (#17) from ph4 into prod
Reviewed-on: #17
2025-06-01 16:33:08 +02:00
mo
350ccaf5d8 Merge pull request 'refactor: Optimize Dockerfiles and deployment workflow for improved performance and reliability' (#16) from ph4 into prod
Reviewed-on: #16
2025-06-01 16:27:10 +02:00
mo
ca73d6ca79 Merge pull request 'refactor: Revise .dockerignore and Dockerfile for enhanced build efficiency and organization' (#15) from ph4 into prod
Reviewed-on: #15
2025-06-01 16:15:12 +02:00
mo
d7bd69f68c Merge pull request 'refactor: Improve deployment workflow with retry logic for image pushes and optimized build process' (#14) from ph4 into prod
Reviewed-on: #14
2025-06-01 16:04:11 +02:00
mo
fd15ed5a35 Merge pull request 'refactor: Enhance deployment workflow for backend and frontend images' (#13) from ph4 into prod
Reviewed-on: #13
2025-06-01 16:01:25 +02:00
mo
0cdc47d0d2 Merge pull request 'refactor: Update .dockerignore for improved clarity and organization' (#12) from ph4 into prod
Reviewed-on: #12
2025-06-01 15:58:00 +02:00
mo
c90ee6b73f Merge pull request 'refactor: Standardize user creation in Dockerfile and improve multi-stage build syntax' (#11) from ph4 into prod
Reviewed-on: #11
2025-06-01 15:47:57 +02:00
mo
3c30eaeaee Merge pull request 'refactor: Update backend Dockerfile to use Alpine package names' (#10) from ph4 into prod
Reviewed-on: #10
2025-06-01 15:46:24 +02:00
mo
1907911779 Merge pull request 'refactor: Switch backend Dockerfile to use Alpine package manager' (#9) from ph4 into prod
Reviewed-on: #9
2025-06-01 15:44:29 +02:00
mo
cda51e34ba Merge pull request 'refactor: Update Docker configurations for improved environment variable handling' (#8) from ph4 into prod
Reviewed-on: #8
2025-06-01 15:41:57 +02:00
mo
c7f296597e Merge pull request 'refactor: Improve environment variable injection in Dockerfile for production' (#7) from ph4 into prod
Reviewed-on: #7
2025-06-01 15:35:15 +02:00
mo
b3fd3acad9 Merge pull request 'fix: Update API base URL for development environment' (#6) from ph4 into prod
Reviewed-on: #6
2025-06-01 15:16:16 +02:00
mo
258798846d Merge pull request 'refactor: Update frontend components and Dockerfile for production' (#5) from ph4 into prod
Reviewed-on: #5
2025-06-01 14:59:49 +02:00
mo
6f69ad8fcc Merge pull request 'Enhance deployment workflow with context variable debugging and fallback logic' (#4) from ph4 into prod
Reviewed-on: #4
2025-06-01 14:51:29 +02:00
mo
7a3e91a324 Merge pull request 'fix: Update Docker image tags' (#3) from ph4 into prod
Reviewed-on: #3
2025-06-01 14:47:59 +02:00
mo
e43b4fe50a Merge pull request 'fix: Update Docker login commands in deployment workflow' (#2) from ph4 into prod
Reviewed-on: #2
2025-06-01 14:41:35 +02:00
mo
b37cbebf8a Merge pull request 'ph4' (#1) from ph4 into prod
Reviewed-on: #1
2025-06-01 14:39:42 +02:00
28 changed files with 2364 additions and 315 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.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 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 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__)
@ -451,3 +456,65 @@ 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,13 +8,16 @@ 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 from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
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,
@ -265,3 +268,54 @@ 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,9 +332,17 @@ 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 is not found.""" """Raised when a chore or assignment is not found."""
def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None): def __init__(self, chore_id: int = None, assignment_id: int = None, 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,10 +6,11 @@ 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 from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum
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__)
@ -39,7 +40,9 @@ 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)
) )
@ -56,7 +59,9 @@ 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)
) )
@ -99,6 +104,16 @@ 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(
@ -107,7 +122,9 @@ async def create_chore(
.options( .options(
selectinload(Chore.creator), selectinload(Chore.creator),
selectinload(Chore.group), 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() 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( result = await db.execute(
select(Chore) select(Chore)
.where(Chore.id == chore_id) .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() return result.scalar_one_or_none()
@ -152,7 +175,9 @@ 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)
) )
@ -175,7 +200,9 @@ 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)
) )
@ -194,6 +221,9 @@ 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:
@ -245,6 +275,23 @@ 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(
@ -253,7 +300,9 @@ 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()
@ -273,6 +322,16 @@ 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:
@ -324,6 +383,15 @@ 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(
@ -331,7 +399,8 @@ 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()
@ -346,7 +415,8 @@ 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()
@ -364,7 +434,8 @@ 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)
@ -393,7 +464,8 @@ 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)
) )
@ -411,7 +483,6 @@ 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)
@ -427,19 +498,27 @@ 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 not can_manage: if 'due_date' in update_data and update_data['due_date'] != original_due_date:
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments") 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 # Handle completion logic
if 'is_complete' in update_data and update_data['is_complete']: if 'is_complete' in update_data:
if not db_assignment.is_complete: # Only if not already complete if update_data['is_complete'] and not db_assignment.is_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,
@ -447,24 +526,25 @@ 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
) )
elif 'is_complete' in update_data and not update_data['is_complete']: await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
# If marking as incomplete, clear completed_at elif not update_data['is_complete'] and db_assignment.is_complete:
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() # Flush changes within the transaction await db.flush()
# 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()
@ -483,6 +563,15 @@ 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,7 +79,8 @@ 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()
@ -95,7 +96,8 @@ 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()

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 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
@ -71,6 +72,20 @@ 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"
@ -109,6 +124,11 @@ 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):
@ -137,6 +157,10 @@ 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):
@ -383,6 +407,7 @@ 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 ---
@ -403,6 +428,7 @@ 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 ===
@ -430,3 +456,35 @@ 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,13 +1,37 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Optional, List from typing import Optional, List, Any
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 # For UserPublic relation from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # 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
@ -75,7 +99,8 @@ 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
# group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed assignments: List[ChoreAssignmentPublic] = []
history: List[ChoreHistoryPublic] = []
model_config = ConfigDict(from_attributes=True) 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 # 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
@ -102,10 +128,11 @@ 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() # If using Pydantic v2 and forward refs were used with strings ChorePublic.model_rebuild()
# ChoreAssignmentPublic.model_rebuild() ChoreAssignmentPublic.model_rebuild()

View File

@ -1,14 +1,21 @@
# 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 from datetime import datetime, date
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
@ -16,6 +23,7 @@ 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
@ -40,3 +48,6 @@ 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,11 +917,13 @@ select.form-input {
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 9999;
/* Increased z-index to ensure it's above other elements */
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: transition:
@ -941,16 +943,18 @@ select.form-input {
background-color: var(--light); background-color: var(--light);
border: var(--border); border: var(--border);
width: 90%; width: 90%;
max-width: 550px; max-width: 850px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
position: relative; position: relative;
overflow-y: scroll; overflow-y: auto;
/* Can cause tooltip clipping */ /* Changed from scroll to auto */
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

@ -2,7 +2,7 @@
export const API_VERSION = 'v1' export const API_VERSION = 'v1'
// API Base URL // API Base URL
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev' export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohmad.dev'
// API Endpoints // API Endpoints
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
@ -33,7 +33,6 @@ 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`,
@ -62,13 +61,15 @@ 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: '/invites/accept', ACCEPT: (id: string) => `/invites/accept/${id}`,
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',
@ -120,4 +121,12 @@ 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,5 +1,6 @@
import { api } from '@/services/api'; 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 // Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => { export const getApiUrl = (endpoint: string): string => {
@ -14,5 +15,3 @@ 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,5 +627,15 @@
"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,5 +555,15 @@
"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,5 +627,15 @@
"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,5 +627,15 @@
"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,5 +627,15 @@
"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,17 +2,40 @@
<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 -->
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54"> <div class="flex align-end">
<path d="M0 0h24v24H0z" fill="none" /> <div class="language-selector" v-if="authStore.isAuthenticated">
<path <button @click="toggleLanguageMenu" class="language-menu-button">
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 xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54">
</svg> <path d="M0 0h24v24H0z" fill="none" />
</button> <path
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown"> 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" />
<a href="#" @click.prevent="handleLogout">Logout</a> </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>
</div> </div>
</header> </header>
@ -53,13 +76,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineComponent, onMounted } from 'vue'; import { ref, defineComponent, onMounted, computed } 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'
@ -70,6 +94,7 @@ 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 () => {
@ -90,6 +115,12 @@ 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);
@ -103,6 +134,37 @@ 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 {
@ -163,23 +225,61 @@ const navigateToGroups = () => {
color: var(--primary); color: var(--primary);
} }
.user-menu { .language-selector {
position: relative; position: relative;
} }
.user-menu-button { .language-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: 50%; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 0.25rem;
&:hover { &: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 { .page-container {
flex-grow: 1; flex-grow: 1;

View File

@ -40,6 +40,7 @@ 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 } from 'date-fns' import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } 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 } from '../types/chore' import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory } from '../types/chore'
import { groupService } from '../services/groupService' import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
@ -16,6 +16,8 @@ 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 {
@ -35,8 +37,14 @@ 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)
@ -71,8 +79,10 @@ const loadChores = async () => {
return { return {
...c, ...c,
current_assignment_id: currentAssignment?.id ?? null, current_assignment_id: currentAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false, is_completed: currentAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null, 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, updating: false,
} }
}); });
@ -113,13 +123,24 @@ 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'); 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[] = []; 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) {
@ -306,6 +327,77 @@ 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>
@ -338,19 +430,35 @@ const toggleCompletion = async (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">
<span class="checkbox-text-span" <div class="chore-main-info">
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }"> <span class="checkbox-text-span"
{{ chore.name }} :class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
</span> {{ 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> <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>
@ -401,7 +509,7 @@ const toggleCompletion = async (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>
@ -422,7 +530,7 @@ const toggleCompletion = async (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>
@ -431,7 +539,7 @@ const toggleCompletion = async (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>
@ -456,7 +564,114 @@ const toggleCompletion = async (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>
@ -679,4 +894,212 @@ const toggleCompletion = async (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,7 +1,8 @@
import { api } from './api' 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 { 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[]> {
@ -117,7 +118,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 api.put(`/api/v1/chores/assignments/${assignmentId}`, update) const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
return response.data return response.data
}, },
@ -180,4 +181,9 @@ 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,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 // Define Group interface matching backend schema
export interface Group { export interface Group {
@ -17,13 +19,17 @@ export interface Group {
export const groupService = { export const groupService = {
async getUserGroups(): Promise<Group[]> { async getUserGroups(): Promise<Group[]> {
try { const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
const response = await api.get('/api/v1/groups') return response.data;
return response.data },
} catch (error) {
console.error('Failed to fetch user groups:', error) async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise<void> {
throw error 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.: // 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'
interface AuthState { export interface AuthState {
accessToken: string | null accessToken: string | null
refreshToken: string | null refreshToken: string | null
user: { 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 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
@ -16,14 +17,9 @@ export interface Chore {
created_at: string created_at: string
updated_at: string updated_at: string
type: ChoreType type: ChoreType
creator?: { creator?: User
id: number assignments: ChoreAssignment[]
name: string history?: ChoreHistory[]
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'> { }
@ -38,11 +34,12 @@ 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 | null completed_at?: string
created_at: string created_at: string
updated_at: string updated_at: string
chore?: Chore chore?: Chore
assigned_user?: UserPublic assigned_user?: User
history?: ChoreAssignmentHistory[]
} }
export interface ChoreAssignmentCreate { export interface ChoreAssignmentCreate {
@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate {
} }
export interface ChoreAssignmentUpdate { export interface ChoreAssignmentUpdate {
due_date?: string
is_complete?: boolean 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[];
}