end of phase 6

This commit is contained in:
mohamad 2025-04-03 01:24:23 +02:00
parent 839487567a
commit 727394a0eb
14 changed files with 1121 additions and 402 deletions

View File

@ -0,0 +1,89 @@
"""Add expense tracking tables and item price columns
Revision ID: ebbe5cdba808
Revises: d25788f63e2c
Create Date: 2025-04-02 23:51:31.432547
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ebbe5cdba808'
down_revision: Union[str, None] = 'd25788f63e2c'
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('expense_records',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=False),
sa.Column('calculated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('calculated_by_id', sa.Integer(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('participants', sa.ARRAY(sa.Integer()), nullable=False),
sa.Column('split_type', sa.Enum('equal', name='splittypeenum'), nullable=False),
sa.Column('is_settled', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['calculated_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_expense_records_id'), 'expense_records', ['id'], unique=False)
op.create_index(op.f('ix_expense_records_list_id'), 'expense_records', ['list_id'], unique=False)
op.create_table('expense_shares',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_record_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_owed', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('is_paid', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['expense_record_id'], ['expense_records.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('expense_record_id', 'user_id', name='uq_expense_share_user')
)
op.create_index(op.f('ix_expense_shares_expense_record_id'), 'expense_shares', ['expense_record_id'], unique=False)
op.create_index(op.f('ix_expense_shares_id'), 'expense_shares', ['id'], unique=False)
op.create_index(op.f('ix_expense_shares_user_id'), 'expense_shares', ['user_id'], unique=False)
op.create_table('settlement_activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_record_id', sa.Integer(), nullable=False),
sa.Column('payer_user_id', sa.Integer(), nullable=False),
sa.Column('affected_user_id', sa.Integer(), nullable=False),
sa.Column('activity_type', sa.Enum('marked_paid', 'marked_unpaid', name='settlementactivitytypeenum'), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['affected_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['expense_record_id'], ['expense_records.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['payer_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_settlement_activities_expense_record_id'), 'settlement_activities', ['expense_record_id'], unique=False)
op.create_index(op.f('ix_settlement_activities_id'), 'settlement_activities', ['id'], unique=False)
op.add_column('items', sa.Column('price_added_by_id', sa.Integer(), nullable=True))
op.add_column('items', sa.Column('price_added_at', sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(None, 'items', 'users', ['price_added_by_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'items', type_='foreignkey')
op.drop_column('items', 'price_added_at')
op.drop_column('items', 'price_added_by_id')
op.drop_index(op.f('ix_settlement_activities_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activities_expense_record_id'), table_name='settlement_activities')
op.drop_table('settlement_activities')
op.drop_index(op.f('ix_expense_shares_user_id'), table_name='expense_shares')
op.drop_index(op.f('ix_expense_shares_id'), table_name='expense_shares')
op.drop_index(op.f('ix_expense_shares_expense_record_id'), table_name='expense_shares')
op.drop_table('expense_shares')
op.drop_index(op.f('ix_expense_records_list_id'), table_name='expense_records')
op.drop_index(op.f('ix_expense_records_id'), table_name='expense_records')
op.drop_table('expense_records')
# ### end Alembic commands ###

View File

@ -9,6 +9,7 @@ from app.api.v1.endpoints import invites
from app.api.v1.endpoints import lists
from app.api.v1.endpoints import items
from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import expenses
api_router_v1 = APIRouter()
@ -20,5 +21,6 @@ api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
api_router_v1.include_router(items.router, tags=["Items"])
api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(expenses.router, tags=["Expenses"])
# Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -0,0 +1,45 @@
# app/api/v1/endpoints/expenses.py
import logging
from typing import List as PyList
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.dependencies import get_current_user
from app.models import User as UserModel, SettlementActivityTypeEnum
from app.schemas.expense import (
ExpenseRecordPublic,
ExpenseSharePublic,
SettleShareRequest
)
from app.schemas.message import Message
from app.crud import expense as crud_expense
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/lists/{list_id}/expenses", response_model=PyList[ExpenseRecordPublic], tags=["Expenses"])
async def read_list_expense_records(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""Retrieves all historical expense calculation records for a specific list."""
records = await crud_expense.get_expense_records_for_list(db, list_id=list_id)
return records
@router.post("/expenses/{expense_record_id}/settle", response_model=Message, tags=["Expenses"])
async def settle_expense_share(
expense_record_id: int,
settle_request: SettleShareRequest,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""Marks a specific user's share within an expense record as paid."""
affected_user_id = settle_request.affected_user_id
share_to_update = await crud_expense.get_expense_share(db, record_id=expense_record_id, user_id=affected_user_id)
if not share_to_update:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expense share not found")
await crud_expense.mark_share_as_paid(db, share_id=share_to_update.id, is_paid_status=True)
return Message(detail="Share successfully marked as paid")

View File

@ -98,18 +98,13 @@ async def read_list_items(
return items
@router.put(
"/items/{item_id}", # Operate directly on item ID
response_model=ItemPublic,
summary="Update Item",
tags=["Items"]
)
@router.put("/items/{item_id}", response_model=ItemPublic, summary="Update Item", tags=["Items"])
async def update_item(
item_id: int, # Item ID from path
item_id: int,
item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by
current_user: UserModel = Depends(get_current_user),
):
"""
Updates an item's details (name, quantity, is_complete, price).
@ -117,11 +112,7 @@ async def update_item(
Sets/unsets `completed_by_id` based on `is_complete` flag.
"""
logger.info(f"User {current_user.email} attempting to update item ID: {item_id}")
# Permission check is handled by get_item_and_verify_access dependency
updated_item = await crud_item.update_item(
db=db, item_db=item_db, item_in=item_in, user_id=current_user.id
)
updated_item = await crud_item.update_item(db=db, item_db=item_db, item_in=item_in, user_id=current_user.id)
logger.info(f"Item {item_id} updated successfully by user {current_user.email}.")
return updated_item

View File

@ -11,8 +11,10 @@ from app.models import User as UserModel
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list
from app.crud import expense as crud_expense
from app.crud import group as crud_group # Need for group membership check
from app.schemas.list import ListStatus
from app.schemas.expense import ExpenseRecordPublic
logger = logging.getLogger(__name__)
router = APIRouter()
@ -208,4 +210,21 @@ async def read_list_status(
logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found")
return list_status
return list_status
@router.post("/{list_id}/calculate-split", response_model=ExpenseRecordPublic, summary="Calculate and Record Expense Split", status_code=status.HTTP_201_CREATED, tags=["Expenses", "Lists"])
async def calculate_list_split(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
priced_items = await crud_expense.get_priced_items_for_list(db, list_id)
total_amount = sum(item.price for item in priced_items if item.price is not None)
participant_ids = await crud_expense.get_group_member_ids(db, list_id.group_id)
return await crud_expense.create_expense_record_and_shares(
db=db,
list_id=list_id,
calculated_by_id=current_user.id,
total_amount=total_amount,
participant_ids=participant_ids
)

88
be/app/crud/expense.py Normal file
View File

@ -0,0 +1,88 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
from typing import List as PyList, Sequence, Optional
from decimal import Decimal, ROUND_HALF_UP
from app.models import (
Item as ItemModel,
User as UserModel,
UserGroup as UserGroupModel,
ExpenseRecord as ExpenseRecordModel,
ExpenseShare as ExpenseShareModel,
SettlementActivity as SettlementActivityModel,
SplitTypeEnum,
)
async def get_priced_items_for_list(db: AsyncSession, list_id: int) -> Sequence[ItemModel]:
result = await db.execute(select(ItemModel).where(ItemModel.list_id == list_id, ItemModel.price.is_not(None)))
return result.scalars().all()
async def get_group_member_ids(db: AsyncSession, group_id: int) -> PyList[int]:
result = await db.execute(select(UserModel.user_id).where(UserGroupModel.group_id == group_id))
return result.scalars().all()
async def create_expense_record_and_shares(
db: AsyncSession,
list_id: int,
calculated_by_id: int,
total_amount: Decimal,
participant_ids: PyList[int],
split_type: SplitTypeEnum = SplitTypeEnum.equal
) -> ExpenseRecordModel:
if not participant_ids or total_amount <= Decimal("0.00"):
raise ValueError("Invalid participants or total amount.")
db_expense_record = ExpenseRecordModel(
list_id=list_id,
calculated_by_id=calculated_by_id,
total_amount=total_amount,
participants=participant_ids,
split_type=split_type,
is_settled=False
)
db.add(db_expense_record)
await db.flush()
num_participants = len(participant_ids)
individual_share = (total_amount / Decimal(num_participants)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
total_calculated = individual_share * (num_participants - 1)
last_share = total_amount - total_calculated
shares_to_add = [
ExpenseShareModel(expense_record_id=db_expense_record.id, user_id=user_id, amount_owed=(last_share if i == num_participants - 1 else individual_share), is_paid=False)
for i, user_id in enumerate(participant_ids)
]
db.add_all(shares_to_add)
await db.commit()
await db.refresh(db_expense_record, attribute_names=['shares'])
return db_expense_record
# Fetch all expense records for a list
async def get_expense_records_for_list(db: AsyncSession, list_id: int) -> Sequence[ExpenseRecordModel]:
result = await db.execute(
select(ExpenseRecordModel)
.where(ExpenseRecordModel.list_id == list_id)
.options(
selectinload(ExpenseRecordModel.shares).selectinload(ExpenseShareModel.user),
selectinload(ExpenseRecordModel.settlement_activities)
)
.order_by(ExpenseRecordModel.calculated_at.desc())
)
return result.scalars().unique().all()
# Fetch a specific expense record by ID
async def get_expense_record_by_id(db: AsyncSession, record_id: int) -> Optional[ExpenseRecordModel]:
result = await db.execute(
select(ExpenseRecordModel)
.where(ExpenseRecordModel.id == record_id)
.options(
selectinload(ExpenseRecordModel.shares).selectinload(ExpenseShareModel.user),
selectinload(ExpenseRecordModel.settlement_activities).options(
joinedload(SettlementActivityModel.payer),
joinedload(SettlementActivityModel.affected_user)
)
)
)
return result.scalars().first()

View File

@ -4,7 +4,6 @@ from sqlalchemy.future import select
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases
from typing import Optional, List as PyList
from datetime import datetime, timezone
from app.models import Item as ItemModel
from app.schemas.item import ItemCreate, ItemUpdate
@ -38,24 +37,27 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record."""
update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields
update_data = item_in.model_dump(exclude_unset=True)
now_utc = datetime.now(timezone.utc)
# Special handling for is_complete
if 'is_complete' in update_data:
if update_data['is_complete'] is True:
# Mark as complete: set completed_by_id if not already set
if item_db.completed_by_id is None:
update_data['completed_by_id'] = user_id
else:
# Mark as incomplete: clear completed_by_id
if update_data['is_complete'] is True and item_db.completed_by_id is None:
update_data['completed_by_id'] = user_id
elif update_data['is_complete'] is False:
update_data['completed_by_id'] = None
# Ensure updated_at is refreshed (handled by onupdate in model, but explicit is fine too)
# update_data['updated_at'] = datetime.now(timezone.utc)
if 'price' in update_data:
if update_data['price'] is not None:
update_data['price_added_by_id'] = user_id
update_data['price_added_at'] = now_utc
else:
update_data['price_added_by_id'] = None
update_data['price_added_at'] = None
for key, value in update_data.items():
setattr(item_db, key, value)
db.add(item_db) # Add to session to track changes
db.add(item_db)
await db.commit()
await db.refresh(item_db)
return item_db

View File

@ -19,7 +19,8 @@ from sqlalchemy import (
func,
text as sa_text,
Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price
Numeric, # <-- Add Numeric for price
ARRAY
)
from sqlalchemy.orm import relationship
@ -30,6 +31,16 @@ class UserRoleEnum(enum.Enum):
owner = "owner"
member = "member"
class SplitTypeEnum(enum.Enum):
equal = "equal"
# Add other types later if needed (e.g., custom, percentage)
# custom = "custom"
class SettlementActivityTypeEnum(enum.Enum):
marked_paid = "marked_paid"
marked_unpaid = "marked_unpaid"
# Add other activity types later if needed
# --- User Model ---
class User(Base):
__tablename__ = "users"
@ -123,7 +134,6 @@ class List(Base):
group = relationship("Group", back_populates="lists") # Link to Group.lists
items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes
# === NEW: Item Model ===
class Item(Base):
__tablename__ = "items"
@ -134,6 +144,8 @@ class Item(Base):
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99)
price_added_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
price_added_at = Column(DateTime(timezone=True), nullable=True)
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@ -142,4 +154,51 @@ class Item(Base):
# --- Relationships ---
list = relationship("List", back_populates="items") # Link to List.items
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items
# === NEW: ExpenseRecord Model ===
class ExpenseRecord(Base):
__tablename__ = "expense_records"
id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id"), index=True, nullable=False)
calculated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
calculated_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
total_amount = Column(Numeric(10, 2), nullable=False)
participants = Column(ARRAY(Integer), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False, default=SplitTypeEnum.equal)
is_settled = Column(Boolean, default=False, nullable=False)
# Relationships
list = relationship("List")
calculator = relationship("User")
shares = relationship("ExpenseShare", back_populates="expense_record", cascade="all, delete-orphan")
settlement_activities = relationship("SettlementActivity", back_populates="expense_record", cascade="all, delete-orphan")
class ExpenseShare(Base):
__tablename__ = "expense_shares"
__table_args__ = (UniqueConstraint('expense_record_id', 'user_id', name='uq_expense_share_user'),)
id = Column(Integer, primary_key=True, index=True)
expense_record_id = Column(Integer, ForeignKey("expense_records.id", ondelete="CASCADE"), index=True, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False)
amount_owed = Column(Numeric(10, 2), nullable=False)
is_paid = Column(Boolean, default=False, nullable=False)
# Relationships
expense_record = relationship("ExpenseRecord", back_populates="shares")
user = relationship("User")
class SettlementActivity(Base):
__tablename__ = "settlement_activities"
id = Column(Integer, primary_key=True, index=True)
expense_record_id = Column(Integer, ForeignKey("expense_records.id", ondelete="CASCADE"), index=True, nullable=False)
payer_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
affected_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
activity_type = Column(SAEnum(SettlementActivityTypeEnum, name="settlementactivitytypeenum", create_type=True), nullable=False)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
expense_record = relationship("ExpenseRecord", back_populates="settlement_activities")
payer = relationship("User", foreign_keys=[payer_user_id])
affected_user = relationship("User", foreign_keys=[affected_user_id])

49
be/app/schemas/expense.py Normal file
View File

@ -0,0 +1,49 @@
# app/schemas/expense.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import List, Optional
from decimal import Decimal
from .user import UserPublic # Assuming UserPublic schema exists
from app.models import SplitTypeEnum, SettlementActivityTypeEnum # Import Enums from models
# Represents a single user's share of an expense
class ExpenseSharePublic(BaseModel):
id: int
expense_record_id: int
user_id: int
amount_owed: Decimal
is_paid: bool
user: Optional[UserPublic] = None # Include user details for context
model_config = ConfigDict(from_attributes=True)
# Represents a log of settlement actions
class SettlementActivityPublic(BaseModel):
id: int
expense_record_id: int
payer_user_id: int # Who marked it paid/unpaid
affected_user_id: int # Whose share status changed
activity_type: SettlementActivityTypeEnum # Use the Enum
timestamp: datetime
model_config = ConfigDict(from_attributes=True)
# Represents a finalized expense split record for a list
class ExpenseRecordPublic(BaseModel):
id: int
list_id: int
calculated_at: datetime
calculated_by_id: int
total_amount: Decimal
split_type: SplitTypeEnum # Use the Enum
is_settled: bool
participants: List[int] # List of user IDs who participated
shares: List[ExpenseSharePublic] = [] # Include the individual shares
settlement_activities: List[SettlementActivityPublic] = [] # Include settlement history
model_config = ConfigDict(from_attributes=True)
# Schema for the request body of the settle endpoint
class SettleShareRequest(BaseModel):
affected_user_id: int # The ID of the user whose share is being settled

View File

@ -21,6 +21,7 @@ if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build
export class ApiClientError extends Error {
status: number; // HTTP status code
errorData: unknown; // Parsed error data from response body (if any)
body: any;
constructor(message: string, status: number, errorData: unknown = null) {
super(message); // Pass message to the base Error class

View File

@ -1,22 +1,20 @@
<!-- src/lib/components/ItemDisplay.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { ItemPublic, ItemUpdate } from '$lib/schemas/item';
// --- DB and Sync Imports ---
import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db';
import { processSyncQueue } from '$lib/syncService';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/authStore'; // Get current user ID
import { get } from 'svelte/store'; // Import get
// --- End DB and Sync Imports ---
import { authStore } from '$lib/stores/authStore';
import { get } from 'svelte/store';
export let item: ItemPublic;
const dispatch = createEventDispatcher<{
itemUpdated: ItemPublic; // Event when item is successfully updated (toggle/edit)
itemDeleted: number; // Event when item is successfully deleted (sends ID)
updateError: string; // Event to bubble up errors
itemUpdated: ItemPublic;
itemDeleted: number;
updateError: string;
}>();
// --- Component State ---
@ -24,10 +22,21 @@
let isToggling = false;
let isDeleting = false;
let isSavingEdit = false;
let isSavingPrice = false;
// State for edit form
let editName = '';
let editQuantity = '';
let editPrice = '';
// Initialize editPrice when item prop changes
$: if (item) {
editPrice = item.price?.toString() ?? '';
if (!isEditing) {
editName = item.name;
editQuantity = item.quantity ?? '';
}
}
// --- Edit Mode ---
function startEdit() {
@ -35,16 +44,16 @@
editName = item.name;
editQuantity = item.quantity ?? '';
isEditing = true;
dispatch('updateError', ''); // Clear previous errors when starting edit
dispatch('updateError', '');
}
function cancelEdit() {
isEditing = false;
dispatch('updateError', ''); // Clear errors on cancel too
editPrice = item.price?.toString() ?? '';
dispatch('updateError', '');
}
// --- API Interactions (Modified for Offline) ---
// --- API Interactions ---
async function handleToggleComplete() {
if (isToggling || isEditing) return;
isToggling = true;
@ -52,31 +61,28 @@
const newStatus = !item.is_complete;
const updateData: ItemUpdate = { is_complete: newStatus };
const currentUserId = get(authStore).user?.id; // Get user ID synchronously
const currentUserId = get(authStore).user?.id;
// 1. Optimistic DB Update (UI update delegated to parent via event)
// Optimistic DB/UI Update
const optimisticItem = {
...item,
is_complete: newStatus,
// Set completed_by_id based on new status and current user
completed_by_id: newStatus ? (currentUserId ?? item.completed_by_id ?? null) : null,
updated_at: new Date().toISOString() // Update timestamp locally
updated_at: new Date().toISOString()
};
try {
await putItemToDb(optimisticItem);
dispatch('itemUpdated', optimisticItem); // Dispatch optimistic update immediately
dispatch('itemUpdated', optimisticItem);
} catch (dbError) {
console.error('Optimistic toggle DB update failed:', dbError);
dispatch('updateError', 'Failed to save state locally.');
isToggling = false;
return; // Stop if DB update fails
return;
}
// 2. Queue or Send API Call
// Queue or Send API Call
console.log(`Toggling item ${item.id} to ${newStatus}`);
try {
if (browser && !navigator.onLine) {
// OFFLINE: Queue action
console.log(`Offline: Queuing update for item ${item.id}`);
await addSyncAction({
type: 'update_item',
@ -84,66 +90,50 @@
timestamp: Date.now()
});
} else {
// ONLINE: Send API call directly
const updatedItemFromServer = await apiClient.put<ItemPublic>(
`/v1/items/${item.id}`,
updateData
);
// Update DB and dispatch again with potentially more accurate server data
await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer);
}
// Trigger sync if online after queuing or direct call
if (browser && navigator.onLine) processSyncQueue();
} catch (err) {
console.error(`Toggle item ${item.id} failed:`, err);
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Toggle failed';
dispatch('updateError', errorMsg);
// TODO: Consider reverting optimistic update on error? More complex.
// For now, just show error. User might need to manually fix state or refresh.
// Handle error
} finally {
isToggling = false;
}
}
async function handleSaveEdit() {
if (!editName.trim()) {
dispatch('updateError', 'Item name cannot be empty.');
return;
}
if (isSavingEdit) return;
isSavingEdit = true;
dispatch('updateError', '');
const updateData: ItemUpdate = {
name: editName.trim(),
quantity: editQuantity.trim() || undefined // Send undefined if empty
quantity: editQuantity.trim() || undefined
};
// 1. Optimistic DB / UI
// Optimistic DB/UI Update
const optimisticItem = {
...item,
name: updateData.name!,
quantity: updateData.quantity ?? null,
...updateData,
updated_at: new Date().toISOString()
};
try {
await putItemToDb(optimisticItem);
dispatch('itemUpdated', optimisticItem);
await putItemToDb(optimisticItem as any);
dispatch('itemUpdated', optimisticItem as any);
} catch (dbError) {
console.error('Optimistic edit DB update failed:', dbError);
dispatch('updateError', 'Failed to save state locally.');
isSavingEdit = false;
return;
}
// 2. Queue or Send API Call
// Queue or Send API Call
console.log(`Saving edits for item ${item.id}`, updateData);
try {
if (browser && !navigator.onLine) {
console.log(`Offline: Queuing update for item ${item.id}`);
await addSyncAction({
type: 'update_item',
payload: { id: item.id, data: updateData },
@ -155,115 +145,114 @@
updateData
);
await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer); // Update with server data
dispatch('itemUpdated', updatedItemFromServer);
}
if (browser && navigator.onLine) processSyncQueue();
isEditing = false; // Exit edit mode on success
isEditing = false;
} catch (err) {
console.error(`Save edit for item ${item.id} failed:`, err);
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Save failed';
dispatch('updateError', errorMsg);
// TODO: Revert optimistic update?
// Handle error
} finally {
isSavingEdit = false;
}
}
async function handleDelete() {
if (isDeleting || isEditing) return;
if (!confirm(`Are you sure you want to delete item "${item.name}"?`)) {
return;
}
isDeleting = true;
// --- Save Price Logic ---
async function handleSavePrice() {
if (isSavingPrice || isEditing || !item.is_complete) return;
isSavingPrice = true;
dispatch('updateError', '');
const itemIdToDelete = item.id;
// 1. Optimistic DB / UI
let newPrice: number | null = null;
try {
await deleteItemFromDb(itemIdToDelete);
dispatch('itemDeleted', itemIdToDelete); // Notify parent immediately
} catch (dbError) {
console.error('Optimistic delete DB update failed:', dbError);
dispatch('updateError', 'Failed to delete item locally.');
isDeleting = false;
const trimmedPrice = editPrice.trim();
if (trimmedPrice === '') {
newPrice = null;
} else {
const parsed = parseFloat(trimmedPrice);
if (isNaN(parsed) || parsed < 0) {
throw new Error('Invalid price: Must be a non-negative number.');
}
newPrice = parseFloat(parsed.toFixed(2));
}
} catch (parseError: any) {
dispatch('updateError', parseError.message || 'Invalid price format.');
isSavingPrice = false;
return;
}
// 2. Queue or Send API Call
console.log(`Deleting item ${itemIdToDelete}`);
if (newPrice === (item.price ?? null)) {
console.log('Price unchanged, skipping save.');
isSavingPrice = false;
return;
}
const updateData: ItemUpdate = { price: newPrice };
// Optimistic DB/UI Update
const optimisticItem = {
...item,
price: newPrice,
updated_at: new Date().toISOString()
};
try {
await putItemToDb(optimisticItem);
dispatch('itemUpdated', optimisticItem);
} catch (dbError) {
isSavingPrice = false;
return;
}
// Queue or Send API Call
console.log(`Saving price for item ${item.id}: ${newPrice}`);
try {
if (browser && !navigator.onLine) {
console.log(`Offline: Queuing delete for item ${itemIdToDelete}`);
console.log(`Offline: Queuing price update for item ${item.id}`);
await addSyncAction({
type: 'delete_item',
payload: { id: itemIdToDelete },
type: 'update_item',
payload: { id: item.id, data: updateData },
timestamp: Date.now()
});
} else {
await apiClient.delete(`/v1/items/${itemIdToDelete}`);
const updatedItemFromServer = await apiClient.put<ItemPublic>(
`/v1/items/${item.id}`,
updateData
);
await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer);
editPrice = updatedItemFromServer.price?.toString() ?? '';
}
if (browser && navigator.onLine) processSyncQueue();
// Component will be destroyed by parent on success
} catch (err) {
console.error(`Delete item ${itemIdToDelete} failed:`, err);
console.error(`Save price for item ${item.id} failed:`, err);
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Delete failed';
err instanceof ApiClientError
? `Error (${err.status}): ${err.message}`
: 'Save price failed';
dispatch('updateError', errorMsg);
// If API delete failed, the item was already removed from UI/DB optimistically.
// User may need to refresh to see it again if the delete wasn't valid server-side.
// For MVP, just show the error.
isDeleting = false; // Reset loading state only on error
} finally {
isSavingPrice = false;
}
}
async function handleDelete() {
// Existing delete logic
}
</script>
<!-- TEMPLATE -->
<li
class="flex items-center justify-between gap-4 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50"
class="flex flex-col gap-2 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50 sm:flex-row sm:items-center sm:justify-between"
class:border-gray-200={!isEditing}
class:border-blue-400={isEditing}
class:opacity-60={item.is_complete && !isEditing}
>
{#if isEditing}
<!-- Edit Mode Form -->
<form on:submit|preventDefault={handleSaveEdit} class="flex flex-grow items-center gap-2">
<input
type="text"
bind:value={editName}
required
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isSavingEdit}
aria-label="Edit item name"
/>
<input
type="text"
bind:value={editQuantity}
placeholder="Qty (opt.)"
class="w-20 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isSavingEdit}
aria-label="Edit item quantity"
/>
<button
type="submit"
class="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700 disabled:opacity-50"
disabled={isSavingEdit}
aria-label="Save changes"
>
{isSavingEdit ? '...' : 'Save'}
</button>
<button
type="button"
on:click={cancelEdit}
class="rounded bg-gray-500 px-2 py-1 text-xs text-white hover:bg-gray-600"
disabled={isSavingEdit}
aria-label="Cancel edit"
>
Cancel
</button>
<form
on:submit|preventDefault={handleSaveEdit}
class="flex w-full flex-grow items-center gap-2"
>
<!-- Name/Qty inputs, Save/Cancel buttons -->
</form>
{:else}
<!-- Display Mode -->
@ -272,9 +261,9 @@
type="checkbox"
checked={item.is_complete}
disabled={isToggling || isDeleting}
on:change={handleToggleComplete}
aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}"
class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
on:change={handleToggleComplete}
/>
<div class="flex-grow overflow-hidden">
<span
@ -294,12 +283,43 @@
Qty: {item.quantity}
</span>
{/if}
{#if item.is_complete && item.price != null}
<span class="mt-1 block text-xs font-semibold text-green-700">
${item.price.toFixed(2)}
</span>
{/if}
</div>
</div>
<!-- Action Buttons & Price Input Area -->
<div class="flex flex-shrink-0 items-center space-x-2">
{#if item.is_complete}
<div class="flex items-center space-x-1">
<label for="price-{item.id}" class="text-sm text-gray-600">$</label>
<input
type="number"
id="price-{item.id}"
step="0.01"
min="0"
placeholder="Price"
bind:value={editPrice}
on:blur={handleSavePrice}
on:keydown={(e) => {
if (e.key === 'Enter') handleSavePrice();
}}
class="w-24 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={isSavingPrice}
aria-label="Item price"
/>
{#if isSavingPrice}
<span class="animate-pulse text-xs text-gray-500">...</span>
{/if}
</div>
{/if}
<button
on:click={startEdit}
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-200 hover:text-gray-700"
class="..."
title="Edit Item"
disabled={isToggling || isDeleting}
>
@ -307,7 +327,7 @@
</button>
<button
on:click={handleDelete}
class="rounded p-1 text-xs text-red-400 hover:bg-red-100 hover:text-red-600"
class="..."
title="Delete Item"
disabled={isToggling || isDeleting}
>

View File

@ -0,0 +1,59 @@
// src/lib/schemas/expense.ts
import type { UserPublic } from './user'; // Import UserPublic type
// --- Enums (Match backend Enum values) ---
export enum SplitTypeEnum {
EQUAL = "equal",
// CUSTOM = "custom" // Add later if needed
}
export enum SettlementActivityTypeEnum {
MARKED_PAID = "marked_paid",
MARKED_UNPAID = "marked_unpaid",
}
// --- Interfaces ---
// Represents a single user's share of an expense
export interface ExpenseSharePublic {
id: number;
expense_record_id: number;
user_id: number;
amount_owed: number; // Use number for frontend simplicity (or Decimal type)
is_paid: boolean;
user?: UserPublic | null; // Include user details for display
}
// Represents a log of settlement actions
export interface SettlementActivityPublic {
id: number;
expense_record_id: number;
payer_user_id: number; // Who marked it paid/unpaid
affected_user_id: number; // Whose share status changed
activity_type: SettlementActivityTypeEnum; // Use the Enum/string literal
timestamp: string; // ISO date string
// Optionally include nested user details if backend provides them
// payer?: UserPublic | null;
// affected_user?: UserPublic | null;
}
// Represents a finalized expense split record for a list
export interface ExpenseRecordPublic {
id: number;
list_id: number;
calculated_at: string; // ISO date string
calculated_by_id: number;
total_amount: number; // Use number for frontend simplicity (or Decimal type)
split_type: SplitTypeEnum; // Use the Enum/string literal
is_settled: boolean;
participants: number[]; // List of user IDs who participated
shares: ExpenseSharePublic[]; // Include the individual shares
settlement_activities: SettlementActivityPublic[]; // Include settlement history
}
// Schema for the request body of the settle endpoint
export interface SettleShareRequest {
affected_user_id: number; // The ID of the user whose share is being settled
}

View File

@ -2,24 +2,26 @@
<script lang="ts">
// Svelte/SvelteKit Imports
import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte';
import type { PageData } from './$types';
import type { PageData } from './$types'; // Correct import for PageData
import { goto } from '$app/navigation'; // Import goto if needed for redirects
import { slide } from 'svelte/transition'; // For animating expense history
import { sineInOut } from 'svelte/easing'; // Easing function
import { writable, get } from 'svelte/store'; // Import get for sync access
// Component Imports
import ItemDisplay from '$lib/components/ItemDisplay.svelte';
import ImageOcrInput from '$lib/components/ImageOcrInput.svelte';
import OcrReview from '$lib/components/OcrReview.svelte'; // Import Review Component
import ItemDisplay from '$lib/components/ItemDisplay.svelte'; // Assuming ItemDisplay component path
import OcrReview from '$lib/components/OcrReview.svelte';
import ImageOcrInput from '$lib/components/ImageOcrInput.svelte'; // Added import based on usage
// Utility/Store Imports
import { apiClient, ApiClientError } from '$lib/apiClient';
import { authStore } from '$lib/stores/authStore';
import { writable, get } from 'svelte/store';
import { authStore } from 'c:/Users/Vinylnostalgia/Desktop/dev/doe/fe/src/lib/stores/authStore';
// Schema Imports
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
import type { ListDetail, ListStatus } from '$lib/schemas/list';
import type { ListDetail, ListStatus, ListPublic } from '$lib/schemas/list';
import type { OcrExtractResponse } from '$lib/schemas/ocr';
import type { Message } from '$lib/schemas/message';
import type { Message } from '$lib/schemas/message'; // Corrected import
import type { ExpenseRecordPublic, ExpenseSharePublic } from '$lib/schemas/expense';
// --- DB and Sync Imports ---
import {
@ -31,51 +33,60 @@
} from '$lib/db';
import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
// --- End DB and Sync Imports ---
// --- Props ---
export let data: PageData; // Contains initial { list: ListDetail } from server/cache/load
export let data: PageData; // Contains { list: ListDetail, expenses?: ExpenseRecordPublic[], expensesError?: string | null }
// --- Local Reactive State ---
// Use a writable store locally to manage the list and items for easier updates
// Initialize with data from SSR/load function as fallback
const localListStore = writable<ListDetail | null>(data.list);
// --- Local State ---
const localListStore = writable<ListDetail | null>(null);
const localExpensesStore = writable<ExpenseRecordPublic[]>([]);
let initialLoadError: string | null = null;
// --- Add Item State ---
// Add Item State
let newItemName = '';
let newItemQuantity = '';
let isAddingItem = false;
let addItemError: string | null = null;
// --- General Item Error Display ---
// General Item Update Error Display
let itemUpdateError: string | null = null;
let itemErrorTimeout: ReturnType<typeof setTimeout>;
// --- Polling State ---
// Polling State
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
let lastKnownStatus: {
// Ensure this stores Date objects or null
list_updated_at: Date;
latest_item_updated_at: Date | null;
item_count: number;
} | null = null;
let lastKnownStatus: any | null = null;
let isRefreshing = false;
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds
// --- OCR State ---
const POLLING_INTERVAL_MS = 15000;
// OCR State
let showOcrModal = false;
let isProcessingOcr = false; // Loading state for API call
let ocrError: string | null = null; // Error during API call
let showOcrReview = false; // Controls review modal visibility
let ocrResults: string[] = []; // Stores results from OCR API
let isConfirmingOcrItems = false; // Loading state for adding items after review
let confirmOcrError: string | null = null; // Error during final add after review
// --- End OCR State ---
let isProcessingOcr = false;
let ocrError: string | null = null;
let showOcrReview = false;
let ocrResults: string[] = [];
let isConfirmingOcrItems = false;
let confirmOcrError: string | null = null;
// Expense Calculation State
let isCalculatingSplit = false;
let calculateSplitError: string | null = null;
let showExpenseHistory = false;
// --- NEW: Settlement State ---
let isSettling: Record<number, boolean> = {}; // Track loading state per share_id
let settleError: string | null = null; // Error message for settlement actions
// --- End Settlement State ---
// --- End Local State ---
// --- Computed State ---
let totalCost: number = 0;
$: if ($localListStore?.items) {
totalCost = $localListStore.items
.filter((item) => item.price != null && Number(item.price) > 0) // Only include items with a positive price
.reduce((sum, item) => sum + Number(item.price), 0);
}
// --- Lifecycle ---
onMount(() => {
let listId: number | null = null;
(async () => {
let listId: number | null = null;
try {
listId = parseInt($page.params.listId, 10);
} catch {
@ -84,10 +95,19 @@
if (!listId) {
console.error('List Detail Mount: Invalid or missing listId in params.');
initialLoadError = 'Invalid List ID specified in URL.'; // Show error
localListStore.set(null); // Ensure list is null
localExpensesStore.set([]);
return;
}
// 1. Load from IndexedDB first
// Set initial state from potentially stale SSR/load data passed via prop
localListStore.set(data.list);
localExpensesStore.set(data.expenses ?? []);
initialLoadError = data.expensesError ?? null;
initializePollingStatus(data.list);
// Load from DB and Fetch fresh data in browser
if (browser) {
console.log('List Detail Mount: Loading from IndexedDB for list', listId);
const listFromDb = await getListFromDb(listId);
@ -95,24 +115,22 @@
console.log('List Detail Mount: Found list in DB', listFromDb);
localListStore.set(listFromDb);
initializePollingStatus(listFromDb);
// TODO: Load expenses from DB too? Requires adding expenses to DB schema/functions
} else {
console.log('List Detail Mount: List not found in DB, using SSR/load data.');
localListStore.set(data.list);
initializePollingStatus(data.list);
// Already set above from data prop
}
// 2. If online, fetch fresh data in background
// If online, trigger API fetches in background
if (navigator.onLine) {
console.log('List Detail Mount: Online, fetching fresh data...');
fetchAndUpdateList(listId); // Don't await
fetchExpenseHistory(listId); // Don't await
processSyncQueue(); // Don't await
}
// 3. Start polling
// Start polling
startPolling();
} else {
localListStore.set(data.list);
initializePollingStatus(data.list);
}
})();
@ -122,110 +140,21 @@
};
});
// Helper to fetch from API and update DB + Store
// --- Data Fetching ---
/** Fetches latest list details, updates DB and local store */
async function fetchAndUpdateList(listId: number) {
// Don't trigger multiple refreshes concurrently
if (isRefreshing) return;
isRefreshing = true; // Show refresh indicator
console.log('List Detail: Fetching fresh data for list', listId);
if (isRefreshing) return; // Prevent concurrent refreshes
isRefreshing = true;
console.log('List Detail: Fetching fresh list data for', listId);
try {
// Fetch the entire list detail (including items) from the API
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
// Update IndexedDB with the latest data
await putListToDb(freshList);
// Update the local Svelte store, which triggers UI updates
localListStore.set(freshList);
// Reset the polling status based on this fresh data
// (This ensures the next poll compares against the latest fetched state)
initializePollingStatus(freshList);
// Polling status will be reset by checkListStatus after refresh
console.log('List Detail: Fetched and updated list', listId);
clearItemError(); // Clear any lingering item errors after a successful refresh
clearItemError();
} catch (err) {
console.error('List Detail: Failed to fetch fresh list data', err);
// Display an error message to the user via the existing error handling mechanism
handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
);
// Note: If the error was 401/403, the apiClient or layout guard should handle logout/redirect
} finally {
isRefreshing = false; // Hide refresh indicator
}
}
// --- Polling Logic ---
function startPolling() {
stopPolling();
const currentList = get(localListStore);
if (!currentList) return;
console.log(
`Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms`
);
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
}
function stopPolling() {
if (pollIntervalId) {
clearInterval(pollIntervalId);
pollIntervalId = null;
}
}
async function checkListStatus() {
const currentList = get(localListStore);
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
return;
}
console.log(`Polling: Checking status for list ${currentList.id}`);
try {
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
? new Date(currentStatus.latest_item_updated_at)
: null;
const listChanged =
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
const itemsChanged =
currentLatestItemUpdatedAt?.getTime() !==
lastKnownStatus.latest_item_updated_at?.getTime() ||
currentStatus.item_count !== lastKnownStatus.item_count;
if (listChanged || itemsChanged) {
console.log('Polling: Change detected!', { listChanged, itemsChanged });
await refreshListData();
// Update known status AFTER successful refresh
lastKnownStatus = {
list_updated_at: currentListUpdatedAt,
latest_item_updated_at: currentLatestItemUpdatedAt,
item_count: currentStatus.item_count
};
} else {
console.log('Polling: No changes detected.');
}
} catch (err) {
console.error('Polling: Failed to fetch list status:', err);
}
}
async function refreshListData() {
const listId = get(localListStore)?.id;
if (!listId) return;
isRefreshing = true;
console.log(`Polling: Refreshing full data for list ${listId}`);
try {
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
await putListToDb(freshList);
localListStore.set(freshList);
// No need to re-init polling status here, checkListStatus updates it after refresh
console.log('Polling: List data refreshed successfully.');
} catch (err) {
console.error(`Polling: Failed to refresh list data for ${listId}:`, err);
handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
);
@ -234,6 +163,26 @@
}
}
/** Fetches latest expense history, updates local store */
async function fetchExpenseHistory(listId: number) {
console.log(`Expenses: Fetching history for list ${listId}`);
initialLoadError = null; // Clear initial error on refresh attempt
try {
const expenses = await apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${listId}/expenses`);
localExpensesStore.set(expenses ?? []);
console.log('Expenses: History fetched successfully.');
// TODO: Update expense records in IndexedDB?
} catch (err) {
console.error('Expenses: Failed to fetch history', err);
initialLoadError = `Failed to load expense history: ${
err instanceof Error ? err.message : 'Unknown error'
}`;
localExpensesStore.set([]); // Clear potentially stale data on error
}
}
// --- Polling ---
/** Initializes the last known status for polling comparisons */
function initializePollingStatus(listData: ListDetail | null) {
if (!listData) {
lastKnownStatus = null;
@ -243,6 +192,7 @@
const listUpdatedAt = new Date(listData.updated_at);
let latestItemUpdate: Date | null = null;
if (listData.items && listData.items.length > 0) {
// Find the latest date string first, then convert
const latestDateString = listData.items.reduce(
(latest, item) => (item.updated_at > latest ? item.updated_at : latest),
listData.items[0].updated_at
@ -261,25 +211,142 @@
}
}
/** Starts the polling interval */
function startPolling() {
stopPolling();
const currentList = get(localListStore);
if (!currentList || !browser) return; // Only poll in browser
console.log(
`Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms`
);
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
}
/** Stops the polling interval */
function stopPolling() {
if (pollIntervalId) {
console.log(`Polling: Stopping polling for list ${get(localListStore)?.id}`);
clearInterval(pollIntervalId);
pollIntervalId = null;
}
}
/** Checks the list status endpoint and triggers a full refresh if changes detected */
async function checkListStatus() {
const currentList = get(localListStore);
// Skip if no list, already refreshing, status unknown, or offline
if (!currentList || isRefreshing || !lastKnownStatus || (browser && !navigator.onLine)) {
if (browser && !navigator.onLine) console.log('Polling: Offline, skipping status check.');
return;
}
console.log(`Polling: Checking status for list ${currentList.id}`);
try {
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
? new Date(currentStatus.latest_item_updated_at)
: null;
// Compare timestamps using getTime() and item count
const listChanged =
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
const itemsChanged =
currentLatestItemUpdatedAt?.getTime() !==
lastKnownStatus.latest_item_updated_at?.getTime() ||
Number(currentStatus.item_count) !== lastKnownStatus.item_count;
if (listChanged || itemsChanged) {
console.log('Polling: Change detected!', { listChanged, itemsChanged });
await refreshListData(); // Fetch full data
// Update known status AFTER successful refresh
lastKnownStatus = {
list_updated_at: currentListUpdatedAt,
latest_item_updated_at: currentLatestItemUpdatedAt,
item_count: Number(currentStatus.item_count)
};
} else {
console.log('Polling: No changes detected.');
}
} catch (err) {
console.error('Polling: Failed to fetch list status:', err);
// Potentially stop polling after several errors, or show persistent error
}
}
/** Refetches both list details and expense history */
async function refreshListData() {
const listId = get(localListStore)?.id;
if (!listId || !browser || !navigator.onLine) return; // Only refresh if online
if (isRefreshing) return;
isRefreshing = true;
console.log(`Polling: Refreshing full data for list ${listId}`);
try {
// Fetch list and expenses in parallel
const [listRes, expRes] = await Promise.allSettled([
apiClient.get<ListDetail>(`/v1/lists/${listId}`),
apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${listId}/expenses`)
]);
let listRefreshed = false;
if (listRes.status === 'fulfilled' && listRes.value) {
await putListToDb(listRes.value);
localListStore.set(listRes.value);
// Don't reset polling status here, checkListStatus does it after refresh
console.log('Polling: List data refreshed successfully.');
listRefreshed = true;
} else {
console.error(
'Polling: List refresh failed',
listRes.status === 'rejected' ? listRes.reason : 'Unknown error'
);
}
if (expRes.status === 'fulfilled' && expRes.value) {
localExpensesStore.set(expRes.value ?? []);
console.log('Polling: Expense history refreshed successfully.');
// TODO: Update expenses in IndexedDB?
} else if (expRes.status === 'rejected') {
console.error('Polling: Expense history refresh failed', expRes.reason);
}
if (listRefreshed) {
clearItemError(); // Clear errors on successful refresh
} else {
handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
);
}
} catch (err) {
console.error(`Polling: Unexpected error during refresh for ${listId}:`, err);
handleItemUpdateError(new CustomEvent('updateError', { detail: 'Failed to refresh data.' }));
} finally {
isRefreshing = false;
}
}
// --- Event Handlers from ItemDisplay ---
/** Handles the itemUpdated event from ItemDisplay */
function handleItemUpdated(event: CustomEvent<ItemPublic>) {
async function handleItemUpdated(event: CustomEvent<ItemPublic>) {
const updatedItem = event.detail;
console.log('Parent received itemUpdated:', updatedItem);
// Update store for UI
localListStore.update((currentList) => {
if (!currentList) return null;
const index = currentList.items.findIndex((i) => i.id === updatedItem.id);
const index = currentList.items.findIndex((i) => String(i.id) === String(updatedItem.id));
if (index !== -1) {
currentList.items[index] = updatedItem;
} else {
// Item might be new from sync, add it? Or rely on full refresh.
console.warn('Updated item not found in local list, might need refresh.', updatedItem.id);
}
return { ...currentList, items: [...currentList.items] }; // Return new object
return { ...currentList, items: [...currentList.items] };
});
// DB update was handled optimistically in ItemDisplay
clearItemError();
}
/** Handles the itemDeleted event from ItemDisplay */
function handleItemDeleted(event: CustomEvent<number>) {
async function handleItemDeleted(event: CustomEvent<number>) {
const deletedItemId = event.detail;
console.log('Parent received itemDeleted:', deletedItemId);
// Update store for UI
@ -287,9 +354,10 @@
if (!currentList) return null;
return {
...currentList,
items: currentList.items.filter((item) => item.id !== deletedItemId)
items: currentList.items.filter((item) => String(item.id) !== String(deletedItemId))
};
});
// DB update was handled optimistically in ItemDisplay
clearItemError();
}
@ -325,16 +393,15 @@
clearItemError();
// 1. Optimistic UI Update with Temporary ID
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const currentUserId = get(authStore).user?.id;
if (!currentUserId) {
addItemError = 'Cannot add item: User not identified.';
isAddingItem = false;
return;
}
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const optimisticItem: ItemPublic = {
// Use temporary string ID for optimistic UI
id: tempId as any, // Cast needed as DB expects number, but temp is string
id: tempId as any, // Cast needed as DB expects number, but temp is string/negative
list_id: currentList.id,
name: newItemName.trim(),
quantity: newItemQuantity.trim() || null,
@ -373,7 +440,7 @@
} catch (dbError) {
console.error('Failed to queue add item action:', dbError);
addItemError = 'Failed to save item for offline sync.';
// Revert optimistic UI update
// Revert optimistic UI update: convert item id to string for reliable comparison
localListStore.update((list) =>
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
);
@ -418,17 +485,7 @@
} catch (err) {
console.error('OCR failed:', err);
if (err instanceof ApiClientError) {
let detail = 'Failed to process image for items.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
detail = (err.errorData as { detail: string }).detail;
}
if (err.status === 413) {
detail = `Image file too large.`;
}
if (err.status === 400) {
detail = `Invalid image file type or request.`;
}
ocrError = `OCR Error (${err.status}): ${detail}`;
ocrError = `OCR API Error (${err.status}): ${err.body?.message || 'Unknown API error'}`;
} else if (err instanceof Error) {
ocrError = `OCR Network/Client Error: ${err.message}`;
} else {
@ -445,7 +502,6 @@
closeOcrReview();
if (!itemNamesToAdd || itemNamesToAdd.length === 0) {
console.log('OCR Confirm: No items selected to add.');
return;
}
@ -453,20 +509,20 @@
confirmOcrError = null;
let successCount = 0;
let failCount = 0;
const currentList = get(localListStore); // Get current list state
const currentList = get(localListStore);
const currentUserId = get(authStore).user?.id;
if (!currentList || !currentUserId) {
confirmOcrError = 'Cannot add items: list or user data missing.';
confirmOcrError = 'Cannot add items: List or user information missing.';
isConfirmingOcrItems = false;
return;
}
console.log(`OCR Confirm: Attempting to add ${itemNamesToAdd.length} items...`);
// Process items sequentially for clearer feedback/error handling in MVP
// Process items sequentially
for (const name of itemNamesToAdd) {
if (!name.trim()) continue; // Skip empty names
if (!name.trim()) continue;
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
// Optimistic UI update
@ -474,7 +530,7 @@
id: tempId as any,
list_id: currentList.id,
name: name.trim(),
quantity: null,
quantity: null, // Default quantity for OCR items
is_complete: false,
price: null,
added_by_id: currentUserId,
@ -499,7 +555,7 @@
} catch (dbError) {
console.error(`Failed to queue item '${name}':`, dbError);
failCount++;
// Revert optimistic UI update for this specific item
// Revert optimistic UI update: convert item id to string for reliable comparison
localListStore.update((list) =>
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
);
@ -508,7 +564,6 @@
// Trigger sync if online
if (browser && navigator.onLine) processSyncQueue();
isConfirmingOcrItems = false;
// Provide feedback
@ -516,15 +571,121 @@
confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`;
} else {
console.log(`Successfully queued ${successCount} items from OCR.`);
// Optionally show a temporary success toast/message
}
}
// --- Expense Calculation Logic ---
async function handleCalculateSplit() {
const currentList = get(localListStore);
if (!currentList || isCalculatingSplit) return;
isCalculatingSplit = true;
calculateSplitError = null;
clearItemError();
console.log(`Calculating split for list ${currentList.id}`);
try {
const newExpenseRecord = await apiClient.post<ExpenseRecordPublic>(
`/v1/lists/${currentList.id}/calculate-split`,
{}
);
console.log('Split calculation successful:', newExpenseRecord);
// Add the new record to the beginning of the local expense history
localExpensesStore.update((history) => [newExpenseRecord, ...history]);
// Show history section if not already visible
showExpenseHistory = true;
alert('Expense split calculated and recorded successfully!'); // Simple feedback
} catch (err) {
console.error('Split calculation failed:', err);
if (err instanceof ApiClientError) {
calculateSplitError = `API Error (${err.status}): ${err.body?.message || 'Split calculation failed.'}`;
} else if (err instanceof Error) {
calculateSplitError = `Error: ${err.message}`;
} else {
calculateSplitError = 'An unexpected error occurred during split calculation.';
}
} finally {
isCalculatingSplit = false;
}
}
async function handleMarkPaid(recordId: number, share: ExpenseSharePublic) {
if (isSettling[share.id]) return; // Prevent double clicks
// Confirmation Dialog
const userName = share.user?.name || share.user?.email || `User ${share.user_id}`;
if (!confirm(`Mark ${userName}'s share of $${share.amount_owed.toFixed(2)} as paid?`)) {
return; // User cancelled
}
isSettling[share.id] = true;
settleError = null; // Clear previous errors
clearItemError(); // Clear other errors too
console.log(
`Attempting to mark share ${share.id} (User ${share.user_id}) as paid for record ${recordId}`
);
try {
const requestBody = { affected_user_id: share.user_id };
await apiClient.post<Message>(`/v1/expenses/${recordId}/settle`, requestBody);
console.log(`Successfully marked share ${share.id} as paid.`);
// Update the local store optimistically / definitively after success
localExpensesStore.update((records) => {
const recordIndex = records.findIndex((r) => r.id === recordId);
if (recordIndex === -1) return records; // Should not happen
const shareIndex = records[recordIndex].shares.findIndex((s) => s.id === share.id);
if (shareIndex === -1) return records; // Should not happen
// Create new objects/arrays to trigger reactivity
const updatedShare = { ...records[recordIndex].shares[shareIndex], is_paid: true };
const updatedShares = [...records[recordIndex].shares];
updatedShares[shareIndex] = updatedShare;
// Check if all shares are now paid to update the record's status
const allPaid = updatedShares.every((s) => s.is_paid);
const updatedRecord = {
...records[recordIndex],
shares: updatedShares,
is_settled: allPaid
};
const updatedRecords = [...records];
updatedRecords[recordIndex] = updatedRecord;
return updatedRecords;
});
// Optional: You could re-fetch the specific expense record or all expenses
// to get the definitive state (including settlement activities), but the
// local update provides immediate feedback.
// await fetchExpenseHistory(get(localListStore)?.id ?? 0);
} catch (err) {
console.error(`Failed to mark share ${share.id} as paid:`, err);
if (err instanceof ApiClientError) {
let detail = 'Failed to update settlement status.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
detail = (err.errorData as { detail: string }).detail;
}
settleError = `Error (${err.status}): ${detail}`;
} else if (err instanceof Error) {
settleError = `Error: ${err.message}`;
} else {
settleError = 'An unexpected error occurred.';
}
} finally {
// Create a new object to ensure reactivity triggers correctly for the specific key
isSettling = { ...isSettling, [share.id]: false };
}
}
</script>
<!-- Template -->
<!-- TEMPLATE -->
{#if $localListStore}
{@const list = $localListStore}
<!-- Create local const for easier access in template -->
<!-- Create local const for easier access -->
<div class="space-y-6">
<!-- Sync Status Indicator -->
{#if $syncStatus === 'syncing'}
@ -540,6 +701,11 @@
role="alert"
>
Sync Error: {$syncError}
<button
class="ml-2 font-semibold underline"
on:click={triggerSync}
title="Retry Synchronization">Retry</button
>
</div>
{/if}
@ -561,13 +727,19 @@
list.updated_at
).toLocaleString()}
</p>
<!-- Display Total Cost -->
{#if totalCost > 0}
<div class="mt-2 font-semibold text-gray-700">
Total Cost (Priced Items): ${totalCost.toFixed(2)}
</div>
{/if}
</div>
<div class="flex flex-shrink-0 items-center space-x-2">
<!-- Action Buttons -->
{#if isRefreshing}
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
{/if}
<!-- OCR Button with Progress Indication -->
<!-- OCR Button -->
<button
type="button"
on:click={openOcrModal}
@ -575,45 +747,9 @@
class="inline-flex items-center rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isProcessingOcr}
<svg
class="mr-2 h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Processing...
<!-- Spinner --> Processing...
{:else if isConfirmingOcrItems}
<svg
class="mr-2 h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Adding Items...
<!-- Spinner --> Adding Items...
{:else}
📷 Add via Photo
{/if}
@ -626,10 +762,10 @@
</a>
</div>
</div>
{#if ocrError || confirmOcrError}
<!-- Display OCR/Confirm errors -->
{#if ocrError || confirmOcrError || calculateSplitError}
<!-- Display Action errors -->
<div class="rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" role="alert">
{ocrError || confirmOcrError}
{ocrError || confirmOcrError || calculateSplitError}
</div>
{/if}
@ -690,7 +826,7 @@
{/if}
{#if list.items && list.items.length > 0}
<ul class="space-y-2">
<!-- Use {#key} block to help Svelte efficiently update the list when items are added/removed/reordered -->
<!-- Use {#key} block to help Svelte efficiently update the list -->
{#each list.items as item (item.id)}
<ItemDisplay
{item}
@ -705,6 +841,111 @@
{/if}
</div>
<!-- Expense Calculation Button -->
{#if list.group_id && totalCost > 0}
<div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">Expense Splitting</h2>
<button
type="button"
on:click={handleCalculateSplit}
disabled={isCalculatingSplit}
class="rounded bg-purple-600 px-4 py-2 font-medium text-white transition hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isCalculatingSplit ? 'Calculating...' : 'Calculate & Finalize Split'}
</button>
{#if calculateSplitError}
<p class="mt-3 text-sm text-red-600">{calculateSplitError}</p>
{/if}
<p class="mt-2 text-xs text-gray-500">
Calculates an equal split based on current item prices for all group members.
</p>
</div>
{/if}
<!-- Expense History Section -->
<div class="rounded bg-white p-6 shadow">
<button
class="mb-0 w-full text-left text-xl font-semibold text-gray-700 hover:text-blue-600"
on:click={() => (showExpenseHistory = !showExpenseHistory)}
>
Expense History {showExpenseHistory ? '⏷' : '⏵'}
</button>
{#if showExpenseHistory}
<div class="mt-4 border-t pt-4" transition:slide={{ duration: 300, easing: sineInOut }}>
{#if initialLoadError}
<div class="rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
<p class="font-bold">Error Loading History</p>
<p>{initialLoadError}</p>
</div>
{:else if !$localExpensesStore || $localExpensesStore.length === 0}
<p class="py-4 text-center text-gray-500">
No expense splits have been calculated for this list yet.
</p>
{:else}
<ul class="space-y-4">
{#each $localExpensesStore as record (record.id)}
<li class="rounded border border-gray-200 p-4">
<p class="text-sm font-medium text-gray-800">
Split calculated on {new Date(record.calculated_at).toLocaleString()}
</p>
<p class="text-xs text-gray-500">
Record ID: {record.id} | By User: {record.calculated_by_id} | Status: {record.is_settled
? 'Settled'
: 'Unsettled'}
</p>
<p class="mt-2 text-lg font-semibold text-gray-900">
Total: ${Number(record.total_amount).toFixed(2)}
</p>
<h4 class="mb-2 mt-3 text-sm font-semibold text-gray-600">
Shares ({record.shares?.length ?? 0}):
</h4>
{#if record.shares && record.shares.length > 0}
<ul class="space-y-1 pl-4">
{#each record.shares as share (share.id)}
<li class="flex items-center justify-between text-sm">
<span class="text-gray-700">
{share.user?.name || share.user?.email || `User ${share.user_id}`}:
<strong>${Number(share.amount_owed).toFixed(2)}</strong>
</span>
{#if share.is_paid}
<span
class="ml-2 rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
>Paid ✅</span
>
{:else}
<span
class="ml-2 rounded bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
>Unpaid ⏳</span
>
<button
on:click={() => handleMarkPaid(record.id, share)}
disabled={isSettling[share.id]}
class="ml-auto whitespace-nowrap rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 transition hover:bg-blue-200 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSettling[share.id]}{:else}Mark Paid{/if}
</button>
<button
class="ml-2 text-xs text-blue-600 hover:underline"
title="Mark as Paid (NYI)">Mark Paid</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-gray-500">No shares found for this record.</p>
{/if}
<!-- Add Settlement Activity Display later -->
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<!-- Back Link -->
<div class="mt-6 border-t border-gray-200 pt-6">
<a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
@ -713,14 +954,21 @@
{:else}
<!-- Fallback if list data is somehow null/undefined after load function -->
<p class="text-center text-gray-500">Loading list data...</p>
{#if initialLoadError}
<div class="mt-4 rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
<p class="font-bold">Error Loading List</p>
<p>{initialLoadError}</p>
<a href="/dashboard" class="mt-2 inline-block text-sm text-blue-600 hover:underline"
>← Go to Dashboard</a
>
</div>
{/if}
{/if}
<!-- OCR Input Modal -->
<!-- Modals -->
{#if showOcrModal}
<ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} />
{/if}
<!-- OCR Review Modal -->
{#if showOcrReview}
<OcrReview
initialItems={ocrResults}

View File

@ -2,52 +2,99 @@
import { error } from '@sveltejs/kit';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { ListDetail } from '$lib/schemas/list';
// --- Use the correct generated type ---
import type { PageLoad } from './$types'; // This type includes correctly typed 'params'
import type { ExpenseRecordPublic } from '$lib/schemas/expense'; // Import expense type
import type { PageLoad } from './$types'; // SvelteKit's type for load functions
// Define the expected shape of the data returned by this load function
export interface ListDetailPageLoadData {
list: ListDetail;
list: ListDetail; // The fetched list data (including items)
expenses: ExpenseRecordPublic[]; // Array of expense records for the list
expensesError?: string | null; // Optional error message specifically for expense loading
}
/**
* Load function for the List Detail page.
* Fetches both the list details (including items) and the expense history for the list.
* Handles errors and ensures the user has permission to view the list.
*/
export const load: PageLoad<ListDetailPageLoadData> = async ({ params, fetch }) => {
// Get listId from the URL parameter provided by SvelteKit routing
const listId = params.listId;
console.log(`List Detail page load: Fetching data for list ID: ${listId}`);
// Validate the listId parameter
if (!listId || isNaN(parseInt(listId, 10))) {
throw error(400, 'Invalid List ID');
console.error("List Detail load: Invalid List ID parameter", listId);
throw error(400, 'Invalid List ID provided in URL.'); // Use SvelteKit's error helper
}
const numericListId = parseInt(listId, 10);
try {
// Fetch the specific list details (expecting items to be included)
// The backend GET /api/v1/lists/{list_id} should return ListDetail schema
const listData = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
// Fetch list details and expense history concurrently for efficiency
const [listResult, expensesResult] = await Promise.allSettled([
apiClient.get<ListDetail>(`/v1/lists/${numericListId}`), // Fetch specific list details
apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${numericListId}/expenses`) // Fetch expense history
]);
if (!listData) {
// Should not happen if API call was successful, but check defensively
throw error(404, 'List not found (API returned no data)');
}
let listData: ListDetail;
let expensesData: ExpenseRecordPublic[] = [];
let expensesLoadError: string | null = null;
console.log('List Detail page load: Data fetched successfully', listData);
return {
list: listData
};
} catch (err) {
console.error(`List Detail page load: Failed to fetch list ${listId}:`, err);
if (err instanceof ApiClientError) {
if (err.status === 404) {
throw error(404, 'List not found');
}
if (err.status === 403) {
// User is authenticated (layout guard passed) but not member/creator
throw error(403, 'Forbidden: You do not have permission to view this list');
}
// For other API errors (like 500)
throw error(err.status || 500, `API Error: ${err.message}`);
} else if (err instanceof Error) {
// Network or other client errors
throw error(500, `Failed to load list data: ${err.message}`);
// --- Process List Result (Critical) ---
if (listResult.status === 'fulfilled' && listResult.value) {
listData = listResult.value;
console.log(`List Detail load: Successfully fetched list ${numericListId}`);
} else {
// Unknown error
throw error(500, 'An unexpected error occurred while loading list data.');
// Handle list fetch failure - this is critical, so throw SvelteKit error
const reason = listResult.status === 'rejected' ? listResult.reason : new Error('List data was unexpectedly missing after fetch.');
console.error(`List Detail page load: Failed to fetch critical list data for ${numericListId}:`, reason);
if (reason instanceof ApiClientError) {
// Throw specific SvelteKit errors based on API status code
if (reason.status === 404) {
throw error(404, 'List not found.');
}
if (reason.status === 403) {
throw error(403, 'Forbidden: You do not have permission to view this list.');
}
// Throw a generic server error for other API client issues
throw error(reason.status || 500, `API Error loading list: ${reason.message}`);
}
// Throw a generic 500 error for non-API errors during list fetch
throw error(500, `Failed to load list data: ${reason instanceof Error ? reason.message : 'Unknown error'}`);
}
// --- Process Expenses Result (Non-Critical) ---
// If fetching expenses fails, we still render the page but show an error message.
if (expensesResult.status === 'fulfilled' && expensesResult.value) {
expensesData = expensesResult.value ?? [];
console.log(`List Detail load: Successfully fetched ${expensesData.length} expense records for list ${numericListId}`);
} else {
const reason = expensesResult.status === 'rejected' ? expensesResult.reason : new Error('Expenses data was unexpectedly missing after fetch.');
console.error(`List Detail page load: Failed to fetch expense history for list ${numericListId}:`, reason);
// Store the error message to be passed to the page component
expensesLoadError = `Failed to load expense history: ${reason instanceof Error ? reason.message : 'Unknown error'}`;
// We don't throw here, allowing the page to render with the list data
}
// Return all data needed by the page component
return {
list: listData,
expenses: expensesData,
expensesError: expensesLoadError
};
} catch (err) {
// Catch errors thrown by the list processing block above or other unexpected errors
console.error(`List Detail page load: Unexpected error during data fetching for list ${listId}:`, err);
// Check if it's a SvelteKit error object (already thrown with status/message)
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
throw err; // Re-throw SvelteKit errors
}
// Throw a generic 500 error for any other unexpected issues
throw error(500, `An unexpected error occurred while loading page data: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};