From 81577ac7e8f80a50bcc321a031385a370379f6ca Mon Sep 17 00:00:00 2001 From: mohamad <mohamad> Date: Fri, 23 May 2025 21:01:37 +0200 Subject: [PATCH] feat: Add Recurrence Pattern and Update Expense Schema - Introduced a new `RecurrencePattern` model to manage recurrence details for expenses, allowing for daily, weekly, monthly, and yearly patterns. - Updated the `Expense` model to include fields for recurrence management, such as `is_recurring`, `recurrence_pattern_id`, and `next_occurrence`. - Modified the database schema to reflect these changes, including alterations to existing columns and the removal of obsolete fields. - Enhanced the expense creation logic to accommodate recurring expenses and updated related CRUD operations accordingly. - Implemented necessary migrations to ensure database integrity and support for the new features. --- .../295cb070f266_add_recurrence_pattern.py | 90 ++ be/app/api/v1/endpoints/groups.py | 13 +- be/app/core/scheduler.py | 8 +- be/app/crud/chore.py | 2 +- be/app/crud/expense.py | 2 +- be/app/crud/group.py | 29 +- be/app/db/__init__.py | 3 + be/app/db/session.py | 4 + be/app/jobs/recurring_expenses.py | 5 +- be/app/models.py | 39 + be/app/schemas/chore.py | 8 +- fe/package-lock.json | 8 + fe/package.json | 1 + fe/src/pages/ChoresPage.vue | 1077 +++++++++++++---- fe/src/pages/GroupDetailPage.vue | 160 +++ 15 files changed, 1200 insertions(+), 249 deletions(-) create mode 100644 be/alembic/versions/295cb070f266_add_recurrence_pattern.py create mode 100644 be/app/db/__init__.py create mode 100644 be/app/db/session.py diff --git a/be/alembic/versions/295cb070f266_add_recurrence_pattern.py b/be/alembic/versions/295cb070f266_add_recurrence_pattern.py new file mode 100644 index 0000000..31fb047 --- /dev/null +++ b/be/alembic/versions/295cb070f266_add_recurrence_pattern.py @@ -0,0 +1,90 @@ +"""add_recurrence_pattern + +Revision ID: 295cb070f266 +Revises: 7cc1484074eb +Create Date: 2025-05-22 19:55:24.650524 + +""" +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 = '295cb070f266' +down_revision: Union[str, None] = '7cc1484074eb' +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.alter_column('expenses', 'next_occurrence', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.drop_index('ix_expenses_recurring_next_occurrence', table_name='expenses', postgresql_where='(is_recurring = true)') + op.drop_constraint('fk_expenses_recurrence_pattern_id', 'expenses', type_='foreignkey') + op.drop_constraint('fk_expenses_parent_expense_id', 'expenses', type_='foreignkey') + op.drop_column('expenses', 'recurrence_pattern_id') + op.drop_column('expenses', 'last_occurrence') + op.drop_column('expenses', 'parent_expense_id') + op.alter_column('recurrence_patterns', 'days_of_week', + existing_type=postgresql.JSON(astext_type=sa.Text()), + type_=sa.String(), + existing_nullable=True) + op.alter_column('recurrence_patterns', 'end_date', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('recurrence_patterns', 'created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('recurrence_patterns', 'updated_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.create_index(op.f('ix_settlement_activities_created_by_user_id'), 'settlement_activities', ['created_by_user_id'], unique=False) + op.create_index(op.f('ix_settlement_activities_expense_split_id'), 'settlement_activities', ['expense_split_id'], unique=False) + op.create_index(op.f('ix_settlement_activities_id'), 'settlement_activities', ['id'], unique=False) + op.create_index(op.f('ix_settlement_activities_paid_by_user_id'), 'settlement_activities', ['paid_by_user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_settlement_activities_paid_by_user_id'), table_name='settlement_activities') + op.drop_index(op.f('ix_settlement_activities_id'), table_name='settlement_activities') + op.drop_index(op.f('ix_settlement_activities_expense_split_id'), table_name='settlement_activities') + op.drop_index(op.f('ix_settlement_activities_created_by_user_id'), table_name='settlement_activities') + op.alter_column('recurrence_patterns', 'updated_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('recurrence_patterns', 'created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('recurrence_patterns', 'end_date', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('recurrence_patterns', 'days_of_week', + existing_type=sa.String(), + type_=postgresql.JSON(astext_type=sa.Text()), + existing_nullable=True) + op.add_column('expenses', sa.Column('parent_expense_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('expenses', sa.Column('last_occurrence', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + op.add_column('expenses', sa.Column('recurrence_pattern_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_foreign_key('fk_expenses_parent_expense_id', 'expenses', 'expenses', ['parent_expense_id'], ['id'], ondelete='SET NULL') + op.create_foreign_key('fk_expenses_recurrence_pattern_id', 'expenses', 'recurrence_patterns', ['recurrence_pattern_id'], ['id'], ondelete='SET NULL') + op.create_index('ix_expenses_recurring_next_occurrence', 'expenses', ['is_recurring', 'next_occurrence'], unique=False, postgresql_where='(is_recurring = true)') + op.alter_column('expenses', 'next_occurrence', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/be/app/api/v1/endpoints/groups.py b/be/app/api/v1/endpoints/groups.py index cbef763..185292a 100644 --- a/be/app/api/v1/endpoints/groups.py +++ b/be/app/api/v1/endpoints/groups.py @@ -172,22 +172,23 @@ async def leave_group( db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): - """Removes the current user from the specified group.""" + """Removes the current user from the specified group. If the owner is the last member, the group will be deleted.""" logger.info(f"User {current_user.email} attempting to leave group {group_id}") user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) if user_role is None: raise GroupMembershipError(group_id, "leave (you are not a member)") - # --- MVP: Prevent owner leaving if they are the last member/owner --- + # Check if owner is the last member if user_role == UserRoleEnum.owner: member_count = await crud_group.get_group_member_count(db, group_id) - # More robust check: count owners. For now, just check member count. if member_count <= 1: - logger.warning(f"Owner {current_user.email} attempted to leave group {group_id} as last member.") - raise GroupValidationError("Owner cannot leave the group as the last member. Delete the group or transfer ownership.") + # Delete the group since owner is the last member + logger.info(f"Owner {current_user.email} is the last member. Deleting group {group_id}") + await crud_group.delete_group(db, group_id) + return Message(detail="Group deleted as you were the last member") - # Proceed with removal + # Proceed with removal for non-owner or if there are other members deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id) if not deleted: diff --git a/be/app/core/scheduler.py b/be/app/core/scheduler.py index 89eb9ae..9227cf2 100644 --- a/be/app/core/scheduler.py +++ b/be/app/core/scheduler.py @@ -3,16 +3,20 @@ from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.triggers.cron import CronTrigger from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from app.core.config import settings +from app.config import settings from app.jobs.recurring_expenses import generate_recurring_expenses from app.db.session import async_session import logging logger = logging.getLogger(__name__) +# Convert async database URL to sync URL for APScheduler +# Replace postgresql+asyncpg:// with postgresql:// +sync_db_url = settings.DATABASE_URL.replace('postgresql+asyncpg://', 'postgresql://') + # Configure the scheduler jobstores = { - 'default': SQLAlchemyJobStore(url=settings.SQLALCHEMY_DATABASE_URI) + 'default': SQLAlchemyJobStore(url=sync_db_url) } executors = { diff --git a/be/app/crud/chore.py b/be/app/crud/chore.py index f6a5ea6..a7e1540 100644 --- a/be/app/crud/chore.py +++ b/be/app/crud/chore.py @@ -34,7 +34,7 @@ async def create_chore( raise ValueError("group_id must be None for personal chores") db_chore = Chore( - **chore_in.model_dump(exclude_unset=True), + **chore_in.model_dump(exclude_unset=True, exclude={'group_id'}), group_id=group_id, created_by_id=user_id, ) diff --git a/be/app/crud/expense.py b/be/app/crud/expense.py index 10f7627..5af9656 100644 --- a/be/app/crud/expense.py +++ b/be/app/crud/expense.py @@ -19,7 +19,6 @@ from app.models import ( Item as ItemModel, ExpenseOverallStatusEnum, # Added ExpenseSplitStatusEnum, # Added - RecurrencePattern, ) from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate, ExpenseUpdate # Removed unused ExpenseUpdate from app.core.exceptions import ( @@ -34,6 +33,7 @@ from app.core.exceptions import ( DatabaseTransactionError,# Added ExpenseOperationError # Added specific exception ) +from app.models import RecurrencePattern # Placeholder for InvalidOperationError if not defined in app.core.exceptions # This should be a proper HTTPException subclass if used in API layer diff --git a/be/app/crud/group.py b/be/app/crud/group.py index 5df3e4a..aea3773 100644 --- a/be/app/crud/group.py +++ b/be/app/crud/group.py @@ -267,4 +267,31 @@ async def check_user_role_in_group( action=f"{action} (requires at least '{required_role.value}' role)" ) # If role is sufficient, return None - return None \ No newline at end of file + return None + +async def delete_group(db: AsyncSession, group_id: int) -> None: + """ + Deletes a group and all its associated data (members, invites, lists, etc.). + The cascade delete in the models will handle the deletion of related records. + + Raises: + GroupNotFoundError: If the group doesn't exist. + DatabaseError: If there's an error during deletion. + """ + try: + # Get the group first to ensure it exists + group = await get_group_by_id(db, group_id) + if not group: + raise GroupNotFoundError(group_id) + + # Delete the group - cascading delete will handle related records + await db.delete(group) + await db.flush() + + logger.info(f"Group {group_id} deleted successfully") + except OperationalError as e: + logger.error(f"Database connection error while deleting group {group_id}: {str(e)}", exc_info=True) + raise DatabaseConnectionError(f"Database connection error: {str(e)}") + except SQLAlchemyError as e: + logger.error(f"Unexpected SQLAlchemy error while deleting group {group_id}: {str(e)}", exc_info=True) + raise DatabaseTransactionError(f"Failed to delete group: {str(e)}") \ No newline at end of file diff --git a/be/app/db/__init__.py b/be/app/db/__init__.py new file mode 100644 index 0000000..f83198a --- /dev/null +++ b/be/app/db/__init__.py @@ -0,0 +1,3 @@ +from app.db.session import async_session + +__all__ = ["async_session"] \ No newline at end of file diff --git a/be/app/db/session.py b/be/app/db/session.py new file mode 100644 index 0000000..959b962 --- /dev/null +++ b/be/app/db/session.py @@ -0,0 +1,4 @@ +from app.database import AsyncSessionLocal + +# Export the async session factory +async_session = AsyncSessionLocal \ No newline at end of file diff --git a/be/app/jobs/recurring_expenses.py b/be/app/jobs/recurring_expenses.py index c5381d8..96f9026 100644 --- a/be/app/jobs/recurring_expenses.py +++ b/be/app/jobs/recurring_expenses.py @@ -4,7 +4,10 @@ from sqlalchemy import select, and_ from app.models import Expense, RecurrencePattern from app.crud.expense import create_expense from app.schemas.expense import ExpenseCreate -from app.core.logging import logger +import logging +from typing import Optional + +logger = logging.getLogger(__name__) async def generate_recurring_expenses(db: AsyncSession) -> None: """ diff --git a/be/app/models.py b/be/app/models.py index 182a4ad..74fbba1 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -50,6 +50,13 @@ class ExpenseOverallStatusEnum(enum.Enum): partially_paid = "partially_paid" paid = "paid" +class RecurrenceTypeEnum(enum.Enum): + DAILY = "DAILY" + WEEKLY = "WEEKLY" + MONTHLY = "MONTHLY" + YEARLY = "YEARLY" + # Add more types as needed + # Define ChoreFrequencyEnum class ChoreFrequencyEnum(enum.Enum): one_time = "one_time" @@ -245,6 +252,11 @@ class Expense(Base): item = relationship("Item", foreign_keys=[item_id], back_populates="expenses") splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan") overall_settlement_status = Column(SAEnum(ExpenseOverallStatusEnum, name="expenseoverallstatusenum", create_type=True), nullable=False, server_default=ExpenseOverallStatusEnum.unpaid.value, default=ExpenseOverallStatusEnum.unpaid) + # --- Recurrence fields --- + is_recurring = Column(Boolean, default=False, nullable=False) + recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True) + recurrence_pattern = relationship("RecurrencePattern", back_populates="expenses", uselist=False) # One-to-one + next_occurrence = Column(DateTime(timezone=True), nullable=True) # For recurring expenses __table_args__ = ( # Ensure at least one context is provided @@ -376,3 +388,30 @@ class ChoreAssignment(Base): # --- Relationships --- chore = relationship("Chore", back_populates="assignments") assigned_user = relationship("User", back_populates="assigned_chores") + + +# === NEW: RecurrencePattern Model === +class RecurrencePattern(Base): + __tablename__ = "recurrence_patterns" + + id = Column(Integer, primary_key=True, index=True) + type = Column(SAEnum(RecurrenceTypeEnum, name="recurrencetypeenum", create_type=True), nullable=False) + interval = Column(Integer, default=1, nullable=False) # e.g., every 1 day, every 2 weeks + days_of_week = Column(String, nullable=True) # For weekly recurrences, e.g., "MON,TUE,FRI" + # day_of_month = Column(Integer, nullable=True) # For monthly on a specific day + # week_of_month = Column(Integer, nullable=True) # For monthly on a specific week (e.g., 2nd week) + # month_of_year = Column(Integer, nullable=True) # For yearly recurrences + end_date = Column(DateTime(timezone=True), nullable=True) + max_occurrences = Column(Integer, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + # Relationship back to Expenses that use this pattern (could be one-to-many if patterns are shared) + # However, the current CRUD implies one RecurrencePattern per Expense if recurring. + # If a pattern can be shared, this would be a one-to-many (RecurrencePattern to many Expenses). + # For now, assuming one-to-one as implied by current Expense.recurrence_pattern relationship setup. + expenses = relationship("Expense", back_populates="recurrence_pattern") + + +# === END: RecurrencePattern Model === diff --git a/be/app/schemas/chore.py b/be/app/schemas/chore.py index da316fa..7ba70f1 100644 --- a/be/app/schemas/chore.py +++ b/be/app/schemas/chore.py @@ -42,9 +42,9 @@ class ChoreCreate(ChoreBase): @field_validator('group_id') @classmethod def validate_group_id(cls, v, values): - if values.get('type') == ChoreTypeEnum.group and v is None: + if values.data.get('type') == ChoreTypeEnum.group and v is None: raise ValueError("group_id is required for group chores") - if values.get('type') == ChoreTypeEnum.personal and v is not None: + if values.data.get('type') == ChoreTypeEnum.personal and v is not None: raise ValueError("group_id must be None for personal chores") return v @@ -61,9 +61,9 @@ class ChoreUpdate(BaseModel): @field_validator('group_id') @classmethod def validate_group_id(cls, v, values): - if values.get('type') == ChoreTypeEnum.group and v is None: + if values.data.get('type') == ChoreTypeEnum.group and v is None: raise ValueError("group_id is required for group chores") - if values.get('type') == ChoreTypeEnum.personal and v is not None: + if values.data.get('type') == ChoreTypeEnum.personal and v is not None: raise ValueError("group_id must be None for personal chores") return v diff --git a/fe/package-lock.json b/fe/package-lock.json index 7f9a258..13133d2 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -25,6 +25,7 @@ "@intlify/unplugin-vue-i18n": "^6.0.8", "@playwright/test": "^1.51.1", "@tsconfig/node22": "^22.0.1", + "@types/date-fns": "^2.5.3", "@types/jsdom": "^21.1.7", "@types/node": "^22.15.17", "@vitejs/plugin-vue": "^5.2.3", @@ -4124,6 +4125,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/date-fns": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz", + "integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", diff --git a/fe/package.json b/fe/package.json index 272c4ed..0d9febe 100644 --- a/fe/package.json +++ b/fe/package.json @@ -34,6 +34,7 @@ "@intlify/unplugin-vue-i18n": "^6.0.8", "@playwright/test": "^1.51.1", "@tsconfig/node22": "^22.0.1", + "@types/date-fns": "^2.5.3", "@types/jsdom": "^21.1.7", "@types/node": "^22.15.17", "@vitejs/plugin-vue": "^5.2.3", diff --git a/fe/src/pages/ChoresPage.vue b/fe/src/pages/ChoresPage.vue index fd26ee1..5b5ace0 100644 --- a/fe/src/pages/ChoresPage.vue +++ b/fe/src/pages/ChoresPage.vue @@ -1,118 +1,301 @@ <template> <main class="container page-padding"> - <div class="row q-mb-md items-center justify-between"> - <h1 class="mb-3">All Chores</h1> + <div class="page-header"> + <h1 class="mb-3">Chores Timeline</h1> <button class="btn btn-primary" @click="openCreateChoreModal(null)"> <span class="material-icons">add</span> New Chore </button> </div> - <!-- Chores List --> - <div v-if="groupedChores.personal.length > 0"> - <h2 class="chores-group-title">Personal Chores</h2> - <div class="neo-grid"> - <div v-for="chore in groupedChores.personal" :key="chore.id" class="neo-card"> - <div class="neo-card-header"> - <div class="row items-center justify-between"> - <h3>{{ chore.name }}</h3> - <span class="neo-chore-frequency" :class="chore.frequency"> - {{ formatFrequency(chore.frequency) }} - </span> + <!-- Timeline View --> + <div v-if="allChores.length > 0" class="chores-timeline"> + <!-- Overdue Section --> + <div v-if="choresByTimeline.overdue.length > 0" class="timeline-section overdue"> + <div class="timeline-header"> + <div class="timeline-dot overdue"></div> + <h2 class="timeline-title">Overdue</h2> + <span class="timeline-count">{{ choresByTimeline.overdue.length }}</span> + </div> + <div class="timeline-items"> + <div v-for="chore in choresByTimeline.overdue" :key="chore.id" class="timeline-chore-card overdue"> + <div class="chore-timeline-marker"></div> + <div class="chore-content"> + <div class="chore-header"> + <h3>{{ chore.name }}</h3> + <div class="chore-tags"> + <span class="chore-type-tag" :class="chore.type"> + {{ chore.type === 'personal' ? 'Personal' : getGroupName(chore.group_id) || 'Group' }} + </span> + <span class="chore-frequency-tag" :class="chore.frequency"> + {{ formatFrequency(chore.frequency) }} + </span> + </div> + </div> + <div class="chore-meta"> + <div class="chore-due-date overdue"> + <span class="material-icons">schedule</span> + Due {{ formatDate(chore.next_due_date) }} + </div> + <div v-if="chore.description" class="chore-description"> + {{ chore.description }} + </div> + </div> + <div class="chore-actions"> + <button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)"> + <span class="material-icons">edit</span> + Edit + </button> + <button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)"> + <span class="material-icons">delete</span> + Delete + </button> + </div> </div> </div> - <div class="neo-card-body"> - <div class="neo-chore-info"> - <div class="neo-chore-due"> - Due: {{ formatDate(chore.next_due_date) }} + </div> + </div> + + <!-- Today Section --> + <div v-if="choresByTimeline.today.length > 0" class="timeline-section today"> + <div class="timeline-header"> + <div class="timeline-dot today"></div> + <h2 class="timeline-title">Today</h2> + <span class="timeline-count">{{ choresByTimeline.today.length }}</span> + </div> + <div class="timeline-items"> + <div v-for="chore in choresByTimeline.today" :key="chore.id" class="timeline-chore-card today"> + <div class="chore-timeline-marker"></div> + <div class="chore-content"> + <div class="chore-header"> + <h3>{{ chore.name }}</h3> + <div class="chore-tags"> + <span class="chore-type-tag" :class="chore.type"> + {{ chore.type === 'personal' ? 'Personal' : getGroupName(chore.group_id) || 'Group' }} + </span> + <span class="chore-frequency-tag" :class="chore.frequency"> + {{ formatFrequency(chore.frequency) }} + </span> + </div> </div> - <div v-if="chore.description" class="neo-chore-description"> - {{ chore.description }} + <div class="chore-meta"> + <div class="chore-due-date today"> + <span class="material-icons">today</span> + Due Today + </div> + <div v-if="chore.description" class="chore-description"> + {{ chore.description }} + </div> + </div> + <div class="chore-actions"> + <button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)"> + <span class="material-icons">edit</span> + Edit + </button> + <button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)"> + <span class="material-icons">delete</span> + Delete + </button> </div> </div> - <div class="neo-card-actions"> - <button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)"> - <span class="material-icons">edit</span> - Edit - </button> - <button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)"> - <span class="material-icons">delete</span> - Delete - </button> + </div> + </div> + </div> + + <!-- Tomorrow Section --> + <div v-if="choresByTimeline.tomorrow.length > 0" class="timeline-section tomorrow"> + <div class="timeline-header"> + <div class="timeline-dot tomorrow"></div> + <h2 class="timeline-title">Tomorrow</h2> + <span class="timeline-count">{{ choresByTimeline.tomorrow.length }}</span> + </div> + <div class="timeline-items"> + <div v-for="chore in choresByTimeline.tomorrow" :key="chore.id" class="timeline-chore-card tomorrow"> + <div class="chore-timeline-marker"></div> + <div class="chore-content"> + <div class="chore-header"> + <h3>{{ chore.name }}</h3> + <div class="chore-tags"> + <span class="chore-type-tag" :class="chore.type"> + {{ chore.type === 'personal' ? 'Personal' : getGroupName(chore.group_id) || 'Group' }} + </span> + <span class="chore-frequency-tag" :class="chore.frequency"> + {{ formatFrequency(chore.frequency) }} + </span> + </div> + </div> + <div class="chore-meta"> + <div class="chore-due-date tomorrow"> + <span class="material-icons">event</span> + Due Tomorrow + </div> + <div v-if="chore.description" class="chore-description"> + {{ chore.description }} + </div> + </div> + <div class="chore-actions"> + <button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)"> + <span class="material-icons">edit</span> + Edit + </button> + <button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)"> + <span class="material-icons">delete</span> + Delete + </button> + </div> + </div> + </div> + </div> + </div> + + <!-- This Week Section --> + <div v-if="choresByTimeline.thisWeek.length > 0" class="timeline-section this-week"> + <div class="timeline-header"> + <div class="timeline-dot this-week"></div> + <h2 class="timeline-title">This Week</h2> + <span class="timeline-count">{{ choresByTimeline.thisWeek.length }}</span> + </div> + <div class="timeline-items"> + <div v-for="chore in choresByTimeline.thisWeek" :key="chore.id" class="timeline-chore-card this-week"> + <div class="chore-timeline-marker"></div> + <div class="chore-content"> + <div class="chore-header"> + <h3>{{ chore.name }}</h3> + <div class="chore-tags"> + <span class="chore-type-tag" :class="chore.type"> + {{ chore.type === 'personal' ? 'Personal' : getGroupName(chore.group_id) || 'Group' }} + </span> + <span class="chore-frequency-tag" :class="chore.frequency"> + {{ formatFrequency(chore.frequency) }} + </span> + </div> + </div> + <div class="chore-meta"> + <div class="chore-due-date this-week"> + <span class="material-icons">date_range</span> + Due {{ formatDate(chore.next_due_date) }} + </div> + <div v-if="chore.description" class="chore-description"> + {{ chore.description }} + </div> + </div> + <div class="chore-actions"> + <button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)"> + <span class="material-icons">edit</span> + Edit + </button> + <button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)"> + <span class="material-icons">delete</span> + Delete + </button> + </div> + </div> + </div> + </div> + </div> + + <!-- Later Section --> + <div v-if="choresByTimeline.later.length > 0" class="timeline-section later"> + <div class="timeline-header"> + <div class="timeline-dot later"></div> + <h2 class="timeline-title">Later</h2> + <span class="timeline-count">{{ choresByTimeline.later.length }}</span> + </div> + <div class="timeline-items"> + <div v-for="chore in choresByTimeline.later" :key="chore.id" class="timeline-chore-card later"> + <div class="chore-timeline-marker"></div> + <div class="chore-content"> + <div class="chore-header"> + <h3>{{ chore.name }}</h3> + <div class="chore-tags"> + <span class="chore-type-tag" :class="chore.type"> + {{ chore.type === 'personal' ? 'Personal' : getGroupName(chore.group_id) || 'Group' }} + </span> + <span class="chore-frequency-tag" :class="chore.frequency"> + {{ formatFrequency(chore.frequency) }} + </span> + </div> + </div> + <div class="chore-meta"> + <div class="chore-due-date later"> + <span class="material-icons">schedule</span> + Due {{ formatDate(chore.next_due_date) }} + </div> + <div v-if="chore.description" class="chore-description"> + {{ chore.description }} + </div> + </div> + <div class="chore-actions"> + <button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)"> + <span class="material-icons">edit</span> + Edit + </button> + <button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)"> + <span class="material-icons">delete</span> + Delete + </button> + </div> + </div> + </div> + </div> + </div> + + <!-- Add New Chore Section --> + <div class="timeline-section add-new"> + <div class="timeline-header"> + <div class="timeline-dot add-new"></div> + <h2 class="timeline-title">Add New</h2> + </div> + <div class="timeline-items"> + <div class="timeline-add-card" @click="openCreateChoreModal(null)"> + <div class="chore-timeline-marker"></div> + <div class="add-content"> + <span class="material-icons">add_circle</span> + <span>Create a new chore</span> </div> </div> </div> </div> </div> - <div v-for="group in groupedChores.groups" :key="group.id"> - <h2 class="chores-group-title">{{ group.name }}</h2> - <div class="neo-grid" v-if="group.chores.length > 0"> - <div v-for="chore in group.chores" :key="chore.id" class="neo-card"> - <div class="neo-card-header"> - <div class="row items-center justify-between"> - <h3>{{ chore.name }}</h3> - <span class="neo-chore-frequency" :class="chore.frequency"> - {{ formatFrequency(chore.frequency) }} - </span> - </div> - </div> - <div class="neo-card-body"> - <div class="neo-chore-info"> - <div class="neo-chore-due"> - Due: {{ formatDate(chore.next_due_date) }} - </div> - <div v-if="chore.description" class="neo-chore-description"> - {{ chore.description }} - </div> - </div> - <div class="neo-card-actions"> - <button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)"> - <span class="material-icons">edit</span> - Edit - </button> - <button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)"> - <span class="material-icons">delete</span> - Delete - </button> - </div> - </div> - </div> - </div> - <p v-else>No chores in this group.</p> - </div> - - <div v-if="groupedChores.personal.length === 0 && groupedChores.groups.length === 0"> - <p>No chores found. Get started by adding a new chore!</p> + <div v-else class="card empty-state-card"> + <svg class="icon icon-lg" aria-hidden="true"> + <use xlink:href="#icon-clipboard" /> + </svg> + <h3>No Chores Yet!</h3> + <p>Get started by adding a new chore to your timeline!</p> + <button class="btn btn-primary mt-2" @click="openCreateChoreModal(null)"> + <svg class="icon" aria-hidden="true"> + <use xlink:href="#icon-plus" /> + </svg> + Create New Chore + </button> </div> <!-- Create/Edit Chore Modal --> - <div v-if="showChoreModal" class="neo-modal"> - <div class="neo-modal-content"> - <div class="neo-modal-header"> + <div v-if="showChoreModal" class="modal-backdrop open" @click.self="showChoreModal = false"> + <div class="modal-container" role="dialog" aria-modal="true"> + <div class="modal-header"> <h3>{{ isEditing ? 'Edit Chore' : 'New Chore' }}</h3> - <button class="btn btn-neutral btn-icon-only" @click="showChoreModal = false"> - <span class="material-icons">close</span> + <button class="close-button" @click="showChoreModal = false" aria-label="Close"> + <svg class="icon" aria-hidden="true"> + <use xlink:href="#icon-close" /> + </svg> </button> </div> - <div class="neo-modal-body"> - <form @submit.prevent="onSubmit" class="neo-form"> - <div class="neo-form-group"> + <form @submit.prevent="onSubmit" class="neo-form"> + <div class="modal-body"> + <div class="form-group"> <label for="name">Name</label> - <input - id="name" - v-model="choreForm.name" - type="text" - class="neo-input" - required - /> + <input id="name" v-model="choreForm.name" type="text" class="form-input" required /> </div> - <div class="neo-form-group"> + <div class="form-group"> <label>Chore Type</label> <div class="radio-group"> <label> - <input type="radio" v-model="choreForm.type" value="personal" @change="choreForm.group_id = undefined"> + <input type="radio" v-model="choreForm.type" value="personal" + @change="choreForm.group_id = undefined"> Personal </label> <label> @@ -122,14 +305,13 @@ </div> </div> - <div v-if="choreForm.type === 'group'" class="neo-form-group"> + <div v-if="choreForm.type === 'group'" class="form-group"> <label for="group">Group</label> - <select id="group" v-model="choreForm.group_id" class="neo-input" required> + <select id="group" v-model="choreForm.group_id" class="form-input" required> <option :value="undefined" disabled>Select a group</option> <option v-for="group in groups" :key="group.id" :value="group.id"> {{ group.name }} </option> - <!-- Placeholder if no groups loaded yet --> <option v-if="groups.length === 0 && choreForm.group_id" :value="choreForm.group_id" disabled> Group ID: {{ choreForm.group_id }} (Loading groups...) </option> @@ -139,76 +321,56 @@ </p> </div> - <div class="neo-form-group"> + <div class="form-group"> <label for="description">Description</label> - <textarea - id="description" - v-model="choreForm.description" - class="neo-input" - rows="3" - ></textarea> + <textarea id="description" v-model="choreForm.description" class="form-input" rows="3"></textarea> </div> - <div class="neo-form-group"> + <div class="form-group"> <label for="frequency">Frequency</label> - <select - id="frequency" - v-model="choreForm.frequency" - class="neo-input" - required - > + <select id="frequency" v-model="choreForm.frequency" class="form-input" required> <option v-for="option in frequencyOptions" :key="option.value" :value="option.value"> {{ option.label }} </option> </select> </div> - <div v-if="choreForm.frequency === 'custom'" class="neo-form-group"> + <div v-if="choreForm.frequency === 'custom'" class="form-group"> <label for="interval">Interval (days)</label> - <input - id="interval" - v-model.number="choreForm.custom_interval_days" - type="number" - class="neo-input" - min="1" - required - /> + <input id="interval" v-model.number="choreForm.custom_interval_days" type="number" class="form-input" + min="1" required /> </div> - <div class="neo-form-group"> + <div class="form-group"> <label for="dueDate">Next Due Date</label> - <input - id="dueDate" - v-model="choreForm.next_due_date" - type="date" - class="neo-input" - required - /> + <input id="dueDate" v-model="choreForm.next_due_date" type="date" class="form-input" required /> </div> - </form> - </div> - <div class="neo-modal-footer"> - <button class="btn btn-neutral" @click="showChoreModal = false">Cancel</button> - <button class="btn btn-primary" @click="onSubmit">Save</button> - </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-neutral" @click="showChoreModal = false">Cancel</button> + <button type="submit" class="btn btn-primary ml-2">Save</button> + </div> + </form> </div> </div> <!-- Delete Confirmation Dialog --> - <div v-if="showDeleteDialog" class="neo-modal"> - <div class="neo-modal-content"> - <div class="neo-modal-header"> + <div v-if="showDeleteDialog" class="modal-backdrop open" @click.self="showDeleteDialog = false"> + <div class="modal-container" role="dialog" aria-modal="true"> + <div class="modal-header"> <h3>Delete Chore</h3> - <button class="btn btn-neutral btn-icon-only" @click="showDeleteDialog = false"> - <span class="material-icons">close</span> + <button class="close-button" @click="showDeleteDialog = false" aria-label="Close"> + <svg class="icon" aria-hidden="true"> + <use xlink:href="#icon-close" /> + </svg> </button> </div> - <div class="neo-modal-body"> + <div class="modal-body"> <p>Are you sure you want to delete this chore?</p> </div> - <div class="neo-modal-footer"> + <div class="modal-footer"> <button class="btn btn-neutral" @click="showDeleteDialog = false">Cancel</button> - <button class="btn btn-danger" @click="deleteChore">Delete</button> + <button class="btn btn-danger ml-2" @click="deleteChore">Delete</button> </div> </div> </div> @@ -222,18 +384,33 @@ import { choreService } from '../services/choreService' import { useNotificationStore } from '../stores/notifications' import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreType } from '../types/chore' import { useRoute } from 'vue-router' +import { groupService } from '../services/groupService' +import { useStorage } from '@vueuse/core' const notificationStore = useNotificationStore() const route = useRoute() // State const chores = ref<Chore[]>([]) -const groups = ref<{id: number, name: string}[]>([]) // To store group info +const groups = ref<{ id: number, name: string }[]>([]) // To store group info const showChoreModal = ref(false) const showDeleteDialog = ref(false) const isEditing = ref(false) const selectedChore = ref<Chore | null>(null) +// Cache chores in localStorage +const cachedChores = useStorage<Chore[]>('cached-chores', []) +const cachedTimestamp = useStorage<number>('cached-chores-timestamp', 0) +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds + +// Load cached data immediately if available and not expired +const loadCachedData = () => { + const now = Date.now(); + if (cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) { + chores.value = cachedChores.value; + } +}; + const choreForm = ref<ChoreCreate>({ name: '', description: '', @@ -273,6 +450,67 @@ const groupedChores = computed(() => { }; }); +// Add computed property for all chores in a single array +const allChores = computed(() => { + return chores.value.sort((a, b) => { + // Sort by due date, then by name + const dateA = new Date(a.next_due_date); + const dateB = new Date(b.next_due_date); + if (dateA.getTime() !== dateB.getTime()) { + return dateA.getTime() - dateB.getTime(); + } + return a.name.localeCompare(b.name); + }); +}); + +// Add helper function to get group name +const getGroupName = (groupId?: number | null): string | undefined => { + if (!groupId) return undefined; + return groups.value.find(g => g.id === groupId)?.name; +}; + +// Add computed property for timeline grouping +const choresByTimeline = computed(() => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const nextWeek = new Date(today); + nextWeek.setDate(nextWeek.getDate() + 7); + + const timeline = { + overdue: [] as Chore[], + today: [] as Chore[], + tomorrow: [] as Chore[], + thisWeek: [] as Chore[], + later: [] as Chore[] + }; + + chores.value.forEach(chore => { + const dueDate = new Date(chore.next_due_date); + const choreDate = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate()); + + if (choreDate < today) { + timeline.overdue.push(chore); + } else if (choreDate.getTime() === today.getTime()) { + timeline.today.push(chore); + } else if (choreDate.getTime() === tomorrow.getTime()) { + timeline.tomorrow.push(chore); + } else if (choreDate < nextWeek) { + timeline.thisWeek.push(chore); + } else { + timeline.later.push(chore); + } + }); + + // Sort each timeline section by name + Object.values(timeline).forEach(section => { + section.sort((a, b) => a.name.localeCompare(b.name)); + }); + + return timeline; +}); + const frequencyOptions = [ { label: 'One Time', value: 'one_time' as ChoreFrequency }, { label: 'Daily', value: 'daily' as ChoreFrequency }, @@ -286,12 +524,20 @@ const loadChores = async () => { try { // Use the new unified service method chores.value = await choreService.getAllChores() + + // Update cache + cachedChores.value = chores.value + cachedTimestamp.value = Date.now() } catch (error) { console.error('Failed to load all chores:', error) notificationStore.addNotification({ message: 'Failed to load chores', type: 'error' }) + // If we have cached data, keep showing it even if refresh failed + if (cachedChores.value.length === 0) { + chores.value = [] + } } } @@ -324,11 +570,11 @@ const openEditChoreModal = (chore: Chore) => { // Reformat next_due_date if it's not already yyyy-MM-dd if (chore.next_due_date) { try { - choreForm.value.next_due_date = format(new Date(chore.next_due_date), 'yyyy-MM-dd'); + choreForm.value.next_due_date = format(new Date(chore.next_due_date), 'yyyy-MM-dd'); } catch (e) { - console.warn("Could not parse next_due_date for editing:", chore.next_due_date); - // Keep original if parsing fails, or set to today as a fallback - choreForm.value.next_due_date = format(new Date(), 'yyyy-MM-dd'); + console.warn("Could not parse next_due_date for editing:", chore.next_due_date); + // Keep original if parsing fails, or set to today as a fallback + choreForm.value.next_due_date = format(new Date(), 'yyyy-MM-dd'); } } showChoreModal.value = true @@ -362,7 +608,7 @@ const onSubmit = async () => { }); showChoreModal.value = false; - loadChores(); // Reload all chores + loadChores(); // Reload all chores (this will update cache) } catch (error) { console.error('Failed to save chore:', error); notificationStore.addNotification({ @@ -388,7 +634,7 @@ const deleteChore = async () => { message: `Chore deleted successfully`, type: 'success' }) - loadChores() // Reload all chores + loadChores() // Reload all chores (this will update cache) } catch (error) { console.error('Failed to delete chore:', error); notificationStore.addNotification({ @@ -404,7 +650,7 @@ const formatDate = (date: string) => { } else if (date) { const parts = date.split('-'); if (parts.length === 3) { - return format(new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])), 'MMM d, yyyy'); + return format(new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])), 'MMM d, yyyy'); } } return 'Invalid Date'; @@ -416,19 +662,23 @@ const formatFrequency = (frequency: ChoreFrequency) => { } const loadGroups = async () => { - // Placeholder: In a real scenario, this would call groupService - // For now, we can mock it if we want to see the structure, or leave it empty. - // groups.value = await groupService.getUserGroups(); - // Mock example: - // groups.value = [ - // { id: 1, name: 'Family' }, - // { id: 2, name: 'Work Team' } - // ]; - console.log('loadGroups called - placeholder for fetching groups'); + try { + groups.value = await groupService.getUserGroups(); + } catch (error) { + console.error('Failed to load groups:', error); + notificationStore.addNotification({ + message: 'Failed to load groups', + type: 'error' + }); + } }; // Lifecycle onMounted(() => { + // Load cached data immediately + loadCachedData() + + // Then fetch fresh data in background loadChores() loadGroups() // Call loadGroups }) @@ -438,86 +688,371 @@ onMounted(() => { <style scoped> .page-padding { padding: 1rem; - max-width: 1200px; + max-width: 800px; margin: 0 auto; } +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + .mb-3 { margin-bottom: 1.5rem; } -/* Neo Grid Layout */ -.neo-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 2rem; - margin-bottom: 2rem; +.mt-2 { + margin-top: 1rem; } -/* Neo Card Styles */ -.neo-card { +.ml-2 { + margin-left: 0.5rem; +} + +/* Timeline Layout */ +.chores-timeline { + position: relative; + margin-left: 2rem; +} + +/* Timeline line */ +.chores-timeline::before { + content: ''; + position: absolute; + left: -1rem; + top: 0; + bottom: 0; + width: 4px; + background: #ddd; + border-radius: 2px; +} + +/* Timeline Sections */ +.timeline-section { + position: relative; + margin-bottom: 2rem; + background: var(--light); border-radius: 18px; box-shadow: 6px 6px 0 #111; - background: var(--light); border: 3px solid #111; overflow: hidden; } -.neo-card-header { - padding: 1.5rem; - border-bottom: 3px solid #111; - background: #fafafa; +.timeline-section.overdue { + border-color: #dc3545; + box-shadow: 6px 6px 0 #dc3545; } -.neo-card-header h3 { +.timeline-section.today { + border-color: #007bff; + box-shadow: 6px 6px 0 #007bff; +} + +.timeline-section.tomorrow { + border-color: #28a745; + box-shadow: 6px 6px 0 #28a745; +} + +.timeline-section.this-week { + border-color: #ffc107; + box-shadow: 6px 6px 0 #ffc107; +} + +.timeline-section.later { + border-color: #6c757d; + box-shadow: 6px 6px 0 #6c757d; +} + +.timeline-section.add-new { + border: 3px dashed #111; + box-shadow: 6px 6px 0 #111; +} + +/* Timeline Header */ +.timeline-header { + padding: 1.5rem; + border-bottom: 3px solid; + background: #fafafa; + display: flex; + align-items: center; + gap: 1rem; +} + +.timeline-section.overdue .timeline-header { + border-bottom-color: #dc3545; + background: linear-gradient(135deg, #f8d7da 0%, #fafafa 100%); +} + +.timeline-section.today .timeline-header { + border-bottom-color: #007bff; + background: linear-gradient(135deg, #cce7ff 0%, #fafafa 100%); +} + +.timeline-section.tomorrow .timeline-header { + border-bottom-color: #28a745; + background: linear-gradient(135deg, #d4edda 0%, #fafafa 100%); +} + +.timeline-section.this-week .timeline-header { + border-bottom-color: #ffc107; + background: linear-gradient(135deg, #fff3cd 0%, #fafafa 100%); +} + +.timeline-section.later .timeline-header { + border-bottom-color: #6c757d; + background: linear-gradient(135deg, #e2e3e5 0%, #fafafa 100%); +} + +.timeline-section.add-new .timeline-header { + border-bottom-color: #111; +} + +/* Timeline Dots */ +.timeline-dot { + position: absolute; + left: -2.25rem; + top: 1.75rem; + width: 24px; + height: 24px; + border-radius: 50%; + border: 4px solid #fff; + box-shadow: 0 0 0 3px #111; +} + +.timeline-dot.overdue { + background: #dc3545; +} + +.timeline-dot.today { + background: #007bff; +} + +.timeline-dot.tomorrow { + background: #28a745; +} + +.timeline-dot.this-week { + background: #ffc107; +} + +.timeline-dot.later { + background: #6c757d; +} + +.timeline-dot.add-new { + background: #111; +} + +.timeline-title { font-weight: 900; font-size: 1.25rem; margin: 0; letter-spacing: 0.5px; + flex: 1; } -.neo-card-body { +.timeline-count { + font-size: 0.875rem; + color: #666; + font-weight: 700; + background: rgba(255, 255, 255, 0.8); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + border: 2px solid #111; +} + +/* Timeline Items */ +.timeline-items { padding: 1.5rem; } -.neo-card-actions { +.timeline-chore-card { display: flex; gap: 1rem; - margin-top: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #fff; + border-radius: 12px; + border: 2px solid #eee; + transition: all 0.2s ease; } -/* Chore Info Styles */ -.neo-chore-info { +.timeline-chore-card:hover { + border-color: #111; + transform: translateX(5px); +} + +.timeline-chore-card:last-child { + margin-bottom: 0; +} + +.chore-timeline-marker { + width: 12px; + height: 12px; + border-radius: 50%; + background: #111; + margin-top: 0.75rem; + flex-shrink: 0; +} + +.chore-content { + flex: 1; +} + +.chore-header { + margin-bottom: 0.75rem; +} + +.chore-header h3 { + font-weight: 900; + font-size: 1.1rem; + margin: 0 0 0.5rem 0; + letter-spacing: 0.5px; + color: #111; +} + +.chore-tags { display: flex; - flex-direction: column; gap: 0.5rem; + flex-wrap: wrap; } -.neo-chore-due { +/* Tag Styles */ +.chore-type-tag, +.chore-frequency-tag { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.chore-type-tag.personal { + background: #d4edda; + color: #155724; + border: 2px solid #155724; +} + +.chore-type-tag.group { + background: #cce7ff; + color: #0056b3; + border: 2px solid #0056b3; +} + +.chore-frequency-tag.one_time { + background: #e0e0e0; + color: #333; + border: 2px solid #333; +} + +.chore-frequency-tag.daily { + background: #bbdefb; + color: #1565c0; + border: 2px solid #1565c0; +} + +.chore-frequency-tag.weekly { + background: #c8e6c9; + color: #2e7d32; + border: 2px solid #2e7d32; +} + +.chore-frequency-tag.monthly { + background: #e1bee7; + color: #7b1fa2; + border: 2px solid #7b1fa2; +} + +.chore-frequency-tag.custom { + background: #ffe0b2; + color: #ef6c00; + border: 2px solid #ef6c00; +} + +.chore-meta { + margin-bottom: 1rem; +} + +.chore-due-date { + display: flex; + align-items: center; + gap: 0.5rem; font-size: 0.875rem; - color: #666; + font-weight: 700; + margin-bottom: 0.5rem; } -.neo-chore-description { - margin-top: 0.5rem; +.chore-due-date.overdue { + color: #dc3545; +} + +.chore-due-date.today { + color: #007bff; +} + +.chore-due-date.tomorrow { + color: #28a745; +} + +.chore-due-date.this-week { + color: #ffc107; +} + +.chore-due-date.later { + color: #6c757d; +} + +.chore-due-date .material-icons { + font-size: 1rem; +} + +.chore-description { color: #444; + line-height: 1.4; + font-size: 0.9rem; } -.neo-chore-frequency { - font-size: 0.875rem; - padding: 0.25rem 0.75rem; - border-radius: 1rem; - font-weight: 600; +.chore-actions { + display: flex; + gap: 0.75rem; } -.neo-chore-frequency.one_time { background: #e0e0e0; } -.neo-chore-frequency.daily { background: #bbdefb; color: #1565c0; } -.neo-chore-frequency.weekly { background: #c8e6c9; color: #2e7d32; } -.neo-chore-frequency.monthly { background: #e1bee7; color: #7b1fa2; } -.neo-chore-frequency.custom { background: #ffe0b2; color: #ef6c00; } +/* Add New Chore Card */ +.timeline-add-card { + display: flex; + gap: 1rem; + padding: 2rem; + background: #fff; + border: 3px dashed #111; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; + align-items: center; + justify-content: center; +} + +.timeline-add-card:hover { + background: #f8f9fa; + transform: translateX(5px); +} + +.add-content { + display: flex; + align-items: center; + gap: 1rem; + font-weight: 700; + color: #111; +} + +.add-content .material-icons { + font-size: 2rem; +} /* Modal Styles */ -.neo-modal { +.modal-backdrop { position: fixed; top: 0; left: 0; @@ -530,7 +1065,7 @@ onMounted(() => { z-index: 1000; } -.neo-modal-content { +.modal-container { background: white; border-radius: 18px; box-shadow: 6px 6px 0 #111; @@ -541,7 +1076,7 @@ onMounted(() => { overflow-y: auto; } -.neo-modal-header { +.modal-header { padding: 1.5rem; border-bottom: 3px solid #111; background: #fafafa; @@ -550,11 +1085,11 @@ onMounted(() => { align-items: center; } -.neo-modal-body { +.modal-body { padding: 1.5rem; } -.neo-modal-footer { +.modal-footer { padding: 1.5rem; border-top: 3px solid #111; background: #fafafa; @@ -564,23 +1099,18 @@ onMounted(() => { } /* Form Styles */ -.neo-form { - display: flex; - flex-direction: column; - gap: 1.5rem; +.form-group { + margin-bottom: 1.5rem; } -.neo-form-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.neo-form-group label { +.form-group label { + display: block; + margin-bottom: 0.5rem; font-weight: 600; } -.neo-input { +.form-input { + width: 100%; padding: 0.75rem; border: 2px solid #111; border-radius: 8px; @@ -588,11 +1118,29 @@ onMounted(() => { background: white; } -.neo-input:focus { +.form-input:focus { outline: none; border-color: var(--primary-color); } +.radio-group { + display: flex; + gap: 1rem; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: normal; +} + +.form-text-muted { + font-size: 0.875rem; + color: #6c757d; + margin-top: 0.25rem; +} + /* Button Styles */ .btn { display: inline-flex; @@ -604,6 +1152,7 @@ onMounted(() => { font-weight: 600; cursor: pointer; transition: transform 0.1s ease-in-out; + text-decoration: none; } .btn:hover { @@ -630,47 +1179,109 @@ onMounted(() => { font-size: 0.875rem; } -.btn-icon-only { - padding: 0.5rem; +/* Empty State */ +.empty-state-card { + text-align: center; + padding: 3rem 2rem; + background: var(--light); + border-radius: 18px; + box-shadow: 6px 6px 0 #111; + border: 3px solid #111; + margin: 2rem auto; + max-width: 500px; +} + +.empty-state-card .icon-lg { + width: 64px; + height: 64px; + margin-bottom: 1rem; + color: #666; +} + +.empty-state-card h3 { + font-size: 1.5rem; + margin-bottom: 0.5rem; + color: #333; +} + +.empty-state-card p { + color: #666; + margin-bottom: 1.5rem; } /* Responsive Adjustments */ @media (max-width: 768px) { - .neo-grid { - grid-template-columns: 1fr; + .page-padding { + padding: 0.5rem; + max-width: 100%; } - .neo-modal-content { - width: 95%; + .page-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; } -} -.chores-group-title { - font-size: 1.8rem; - font-weight: 600; - margin-top: 2rem; - margin-bottom: 1rem; - color: var(--primary-color); - border-bottom: 2px solid var(--primary-color-light); - padding-bottom: 0.5rem; -} + .chores-timeline { + margin-left: 1rem; + } -.radio-group { - display: flex; - gap: 1rem; - margin-bottom: 0.5rem; /* Optional: for spacing */ -} + .chores-timeline::before { + left: -0.5rem; + width: 2px; + } -.radio-group label { - display: flex; - align-items: center; - gap: 0.5rem; - font-weight: normal; /* Override potential heavier label weight from neo-form-group */ -} + .timeline-dot { + left: -1.25rem; + width: 16px; + height: 16px; + border-width: 2px; + box-shadow: 0 0 0 2px #111; + } -.form-text-muted { - font-size: 0.875rem; - color: #6c757d; /* Bootstrap muted color, adjust as needed */ - margin-top: 0.25rem; + .timeline-section { + margin-bottom: 1.5rem; + } + + .timeline-header { + padding: 1rem; + } + + .timeline-title { + font-size: 1.1rem; + } + + .timeline-items { + padding: 1rem; + } + + .timeline-chore-card { + padding: 0.75rem; + margin-bottom: 1rem; + } + + .chore-tags { + flex-direction: column; + gap: 0.25rem; + } + + .chore-actions { + flex-direction: column; + gap: 0.5rem; + } + + .btn-sm { + width: 100%; + justify-content: center; + } + + .timeline-add-card { + padding: 1.5rem; + } + + .add-content { + flex-direction: column; + text-align: center; + } } </style> diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue index 8bf38b4..934bdb2 100644 --- a/fe/src/pages/GroupDetailPage.vue +++ b/fe/src/pages/GroupDetailPage.vue @@ -116,6 +116,42 @@ </div> </div> + <!-- Expenses Section --> + <div class="mt-4"> + <div class="neo-card"> + <div class="neo-card-header"> + <h3>Group Expenses</h3> + <router-link :to="`/groups/${groupId}/expenses`" class="btn btn-primary"> + <span class="material-icons">payments</span> + Manage Expenses + </router-link> + </div> + <div class="neo-card-body"> + <div v-if="recentExpenses.length > 0" class="neo-expenses-list"> + <div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item"> + <div class="neo-expense-info"> + <span class="neo-expense-name">{{ expense.description }}</span> + <span class="neo-expense-date">{{ formatDate(expense.expense_date) }}</span> + </div> + <div class="neo-expense-details"> + <span class="neo-expense-amount">{{ expense.currency }} {{ formatAmount(expense.total_amount) + }}</span> + <span class="neo-chip" :class="getSplitTypeColor(expense.split_type)"> + {{ formatSplitType(expense.split_type) }} + </span> + </div> + </div> + </div> + <div v-else class="neo-empty-state"> + <svg class="icon icon-lg" aria-hidden="true"> + <use xlink:href="#icon-payments" /> + </svg> + <p>No expenses recorded. Click "Manage Expenses" to add some!</p> + </div> + </div> + </div> + </div> + </div> <div v-else class="alert alert-info" role="status"> @@ -134,6 +170,7 @@ import { useNotificationStore } from '@/stores/notifications'; import { choreService } from '../services/choreService' import type { Chore, ChoreFrequency } from '../types/chore' import { format } from 'date-fns' +import type { Expense } from '@/types/expense' interface Group { id: string | number; @@ -174,6 +211,9 @@ const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({ // Chores state const upcomingChores = ref<Chore[]>([]) +// Add new state for expenses +const recentExpenses = ref<Expense[]>([]) + const fetchActiveInviteCode = async () => { if (!groupId.value) return; // Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading @@ -326,9 +366,42 @@ const getFrequencyColor = (frequency: ChoreFrequency) => { return colors[frequency] } +// Add new methods for expenses +const loadRecentExpenses = async () => { + if (!groupId.value) return + try { + const response = await apiClient.get(`/api/groups/${groupId.value}/expenses`) + recentExpenses.value = response.data.slice(0, 5) // Get only the 5 most recent expenses + } catch (error) { + console.error('Error loading recent expenses:', error) + } +} + +const formatAmount = (amount: string) => { + return parseFloat(amount).toFixed(2) +} + +const formatSplitType = (type: string) => { + return type.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ).join(' ') +} + +const getSplitTypeColor = (type: string) => { + const colors: Record<string, string> = { + equal: 'blue', + exact_amounts: 'green', + percentage: 'purple', + shares: 'orange', + item_based: 'teal' + } + return colors[type] || 'grey' +} + onMounted(() => { fetchGroupDetails(); loadUpcomingChores(); + loadRecentExpenses(); }); </script> @@ -564,4 +637,91 @@ onMounted(() => { font-size: 0.875rem; color: #666; } + +/* Expenses List Styles */ +.neo-expenses-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.neo-expense-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-radius: 12px; + background: #fafafa; + border: 2px solid #111; + transition: transform 0.1s ease-in-out; +} + +.neo-expense-item:hover { + transform: translateY(-2px); +} + +.neo-expense-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.neo-expense-name { + font-weight: 600; + font-size: 1.1rem; +} + +.neo-expense-date { + font-size: 0.875rem; + color: #666; +} + +.neo-expense-details { + display: flex; + align-items: center; + gap: 1rem; +} + +.neo-expense-amount { + font-weight: 600; + font-size: 1.1rem; +} + +.neo-chip { + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + font-weight: 600; + background: #e0e0e0; +} + +.neo-chip.blue { + background: #e3f2fd; + color: #1976d2; +} + +.neo-chip.green { + background: #e8f5e9; + color: #2e7d32; +} + +.neo-chip.purple { + background: #f3e5f5; + color: #7b1fa2; +} + +.neo-chip.orange { + background: #fff3e0; + color: #f57c00; +} + +.neo-chip.teal { + background: #e0f2f1; + color: #00796b; +} + +.neo-chip.grey { + background: #f5f5f5; + color: #616161; +} </style>