Compare commits

..

2 Commits

Author SHA1 Message Date
mohamad
727394a0eb end of phase 6 2025-04-03 01:24:23 +02:00
mohamad
839487567a end of phase 5 2025-04-02 23:54:43 +02:00
23 changed files with 2071 additions and 405 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

@ -8,6 +8,8 @@ from app.api.v1.endpoints import groups
from app.api.v1.endpoints import invites from app.api.v1.endpoints import invites
from app.api.v1.endpoints import lists from app.api.v1.endpoints import lists
from app.api.v1.endpoints import items 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() api_router_v1 = APIRouter()
@ -18,5 +20,7 @@ api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"])
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]) 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(lists.router, prefix="/lists", tags=["Lists"])
api_router_v1.include_router(items.router, tags=["Items"]) 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 # Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) # 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 return items
@router.put( @router.put("/items/{item_id}", response_model=ItemPublic, summary="Update Item", tags=["Items"])
"/items/{item_id}", # Operate directly on item ID
response_model=ItemPublic,
summary="Update Item",
tags=["Items"]
)
async def update_item( async def update_item(
item_id: int, # Item ID from path item_id: int,
item_in: ItemUpdate, 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), 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). 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. Sets/unsets `completed_by_id` based on `is_complete` flag.
""" """
logger.info(f"User {current_user.email} attempting to update item ID: {item_id}") 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}.") logger.info(f"Item {item_id} updated successfully by user {current_user.email}.")
return updated_item 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.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list 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.crud import group as crud_group # Need for group membership check
from app.schemas.list import ListStatus from app.schemas.list import ListStatus
from app.schemas.expense import ExpenseRecordPublic
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -209,3 +211,20 @@ async def read_list_status(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found") 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
)

View File

@ -0,0 +1,108 @@
# app/api/v1/endpoints/ocr.py
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from google.api_core import exceptions as google_exceptions # Import Google API exceptions
from app.api.dependencies import get_current_user
from app.models import User as UserModel
from app.schemas.ocr import OcrExtractResponse
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error # Import helper
logger = logging.getLogger(__name__)
router = APIRouter()
# Allowed image MIME types
ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]
MAX_FILE_SIZE_MB = 10 # Set a reasonable max file size
@router.post(
"/extract-items",
response_model=OcrExtractResponse,
summary="Extract List Items via OCR (Gemini)",
tags=["OCR"]
)
async def ocr_extract_items(
current_user: UserModel = Depends(get_current_user),
# Use File(...) for better metadata handling than UploadFile directly as type hint
image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."),
):
"""
Accepts an image upload, sends it to Gemini Flash with a prompt
to extract shopping list items, and returns the parsed items.
"""
# Check if Gemini client initialized correctly
if gemini_initialization_error:
logger.error("OCR endpoint called but Gemini client failed to initialize.")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service unavailable: {gemini_initialization_error}"
)
logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.")
# --- File Validation ---
if image_file.content_type not in ALLOWED_IMAGE_TYPES:
logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}",
)
# Simple size check (FastAPI/Starlette might handle larger limits via config)
# Read content first to get size accurately
contents = await image_file.read()
if len(contents) > MAX_FILE_SIZE_MB * 1024 * 1024:
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB} MB.",
)
# --- End File Validation ---
try:
# Call the Gemini helper function
extracted_items = await extract_items_from_image_gemini(image_bytes=contents)
logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.")
return OcrExtractResponse(extracted_items=extracted_items)
except ValueError as e:
# Handle errors from Gemini processing (blocked, empty response, etc.)
logger.warning(f"Gemini processing error for user {current_user.email}: {e}")
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, # Or 400 Bad Request?
detail=f"Could not extract items from image: {e}",
)
except google_exceptions.ResourceExhausted as e:
# Specific handling for quota errors
logger.error(f"Gemini Quota Exceeded for user {current_user.email}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="OCR service quota exceeded. Please try again later.",
)
except google_exceptions.GoogleAPIError as e:
# Handle other Google API errors (e.g., invalid key, permissions)
logger.error(f"Gemini API Error for user {current_user.email}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service error: {e}",
)
except RuntimeError as e:
# Catch initialization errors from get_gemini_client()
logger.error(f"Gemini client runtime error during OCR request: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service configuration error: {e}"
)
except Exception as e:
# Catch any other unexpected errors
logger.exception(f"Unexpected error during OCR extraction for user {current_user.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred during item extraction.",
)
finally:
# Ensure file handle is closed (UploadFile uses SpooledTemporaryFile)
await image_file.close()

View File

@ -2,11 +2,14 @@
import os import os
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from dotenv import load_dotenv from dotenv import load_dotenv
import logging
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str | None = None DATABASE_URL: str | None = None
GEMINI_API_KEY: str | None = None
# --- JWT Settings --- # --- JWT Settings ---
# Generate a strong secret key using: openssl rand -hex 32 # Generate a strong secret key using: openssl rand -hex 32
@ -35,3 +38,11 @@ if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me":
# Consider raising an error in a production environment check # Consider raising an error in a production environment check
# if os.getenv("ENVIRONMENT") == "production": # if os.getenv("ENVIRONMENT") == "production":
# raise ValueError("Default SECRET_KEY is not allowed in production!") # raise ValueError("Default SECRET_KEY is not allowed in production!")
if settings.GEMINI_API_KEY is None:
print.error("CRITICAL: GEMINI_API_KEY environment variable not set. Gemini features will be unavailable.")
# You might raise an error here if Gemini is essential for startup
# raise ValueError("GEMINI_API_KEY must be set.")
else:
# Optional: Log partial key for confirmation (avoid logging full key)
logger.info(f"GEMINI_API_KEY loaded (starts with: {settings.GEMINI_API_KEY[:4]}...).")

154
be/app/core/gemini.py Normal file
View File

@ -0,0 +1,154 @@
# app/core/gemini.py
import logging
from typing import List
import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold # For safety settings
from google.api_core import exceptions as google_exceptions
from app.config import settings
logger = logging.getLogger(__name__)
# --- Global variable to hold the initialized model client ---
gemini_flash_client = None
gemini_initialization_error = None # Store potential init error
# --- Configure and Initialize ---
try:
if settings.GEMINI_API_KEY:
genai.configure(api_key=settings.GEMINI_API_KEY)
# Initialize the specific model we want to use
gemini_flash_client = genai.GenerativeModel(
model_name="gemini-2.0-flash",
# Optional: Add default safety settings
# Adjust these based on your expected content and risk tolerance
safety_settings={
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
},
# Optional: Add default generation config (can be overridden per request)
# generation_config=genai.types.GenerationConfig(
# # candidate_count=1, # Usually default is 1
# # stop_sequences=["\n"],
# # max_output_tokens=2048,
# # temperature=0.9, # Controls randomness (0=deterministic, >1=more random)
# # top_p=1,
# # top_k=1
# )
)
logger.info("Gemini AI client initialized successfully for model 'gemini-1.5-flash-latest'.")
else:
# Store error if API key is missing
gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized."
logger.error(gemini_initialization_error)
except Exception as e:
# Catch any other unexpected errors during initialization
gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}"
logger.exception(gemini_initialization_error) # Log full traceback
gemini_flash_client = None # Ensure client is None on error
# --- Function to get the client (optional, allows checking error) ---
def get_gemini_client():
"""
Returns the initialized Gemini client instance.
Raises an exception if initialization failed.
"""
if gemini_initialization_error:
raise RuntimeError(f"Gemini client could not be initialized: {gemini_initialization_error}")
if gemini_flash_client is None:
# This case should ideally be covered by the check above, but as a safeguard:
raise RuntimeError("Gemini client is not available (unknown initialization issue).")
return gemini_flash_client
# Define the prompt as a constant
OCR_ITEM_EXTRACTION_PROMPT = """
Extract the shopping list items from this image.
List each distinct item on a new line.
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
Focus only on the names of the products or items to be purchased.
If the image does not appear to be a shopping list or receipt, state that clearly.
Example output for a grocery list:
Milk
Eggs
Bread
Apples
Organic Bananas
"""
async def extract_items_from_image_gemini(image_bytes: bytes) -> List[str]:
"""
Uses Gemini Flash to extract shopping list items from image bytes.
Args:
image_bytes: The image content as bytes.
Returns:
A list of extracted item strings.
Raises:
RuntimeError: If the Gemini client is not initialized.
google_exceptions.GoogleAPIError: For API call errors (quota, invalid key etc.).
ValueError: If the response is blocked or contains no usable text.
"""
client = get_gemini_client() # Raises RuntimeError if not initialized
# Prepare image part for multimodal input
image_part = {
"mime_type": "image/jpeg", # Or image/png, image/webp etc. Adjust if needed or detect mime type
"data": image_bytes
}
# Prepare the full prompt content
prompt_parts = [
OCR_ITEM_EXTRACTION_PROMPT, # Text prompt first
image_part # Then the image
]
logger.info("Sending image to Gemini for item extraction...")
try:
# Make the API call
# Use generate_content_async for async FastAPI
response = await client.generate_content_async(prompt_parts)
# --- Process the response ---
# Check for safety blocks or lack of content
if not response.candidates or not response.candidates[0].content.parts:
logger.warning("Gemini response blocked or empty.", extra={"response": response})
# Check finish_reason if available
finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN'
safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A'
if finish_reason == 'SAFETY':
raise ValueError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}")
else:
raise ValueError(f"Gemini response was empty or incomplete. Finish Reason: {finish_reason}")
# Extract text - assumes the first part of the first candidate is the text response
raw_text = response.text # response.text is a shortcut for response.candidates[0].content.parts[0].text
logger.info("Received raw text from Gemini.")
# logger.debug(f"Gemini Raw Text:\n{raw_text}") # Optional: Log full response text
# Parse the text response
items = []
for line in raw_text.splitlines(): # Split by newline
cleaned_line = line.strip() # Remove leading/trailing whitespace
# Basic filtering: ignore empty lines and potential non-item lines
if cleaned_line and len(cleaned_line) > 1: # Ignore very short lines too?
# Add more sophisticated filtering if needed (e.g., regex, keyword check)
items.append(cleaned_line)
logger.info(f"Extracted {len(items)} potential items.")
return items
except google_exceptions.GoogleAPIError as e:
logger.error(f"Gemini API Error: {e}", exc_info=True)
# Re-raise specific Google API errors for endpoint to handle (e.g., quota)
raise e
except Exception as e:
# Catch other unexpected errors during generation or processing
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)
# Wrap in a generic ValueError or re-raise
raise ValueError(f"Failed to process image with Gemini: {e}") from e

View File

@ -0,0 +1,83 @@
# be/tests/core/test_gemini.py
import pytest
import os
from unittest.mock import patch, MagicMock
# Store original key if exists, then clear it for testing missing key scenario
original_api_key = os.environ.get("GEMINI_API_KEY")
if "GEMINI_API_KEY" in os.environ:
del os.environ["GEMINI_API_KEY"]
# --- Test Module Import ---
# This forces the module-level initialization code in gemini.py to run
# We need to reload modules because settings might have been cached
from importlib import reload
from app.config import settings as app_settings
from app.core import gemini as gemini_core
# Reload settings first to ensure GEMINI_API_KEY is None initially
reload(app_settings)
# Reload gemini core to trigger initialization logic with potentially missing key
reload(gemini_core)
def test_gemini_initialization_without_key():
"""Verify behavior when GEMINI_API_KEY is not set."""
# Reload modules again to ensure clean state for this specific test
if "GEMINI_API_KEY" in os.environ:
del os.environ["GEMINI_API_KEY"]
reload(app_settings)
reload(gemini_core)
assert gemini_core.gemini_flash_client is None
assert gemini_core.gemini_initialization_error is not None
assert "GEMINI_API_KEY not configured" in gemini_core.gemini_initialization_error
with pytest.raises(RuntimeError, match="GEMINI_API_KEY not configured"):
gemini_core.get_gemini_client()
@patch('google.generativeai.configure')
@patch('google.generativeai.GenerativeModel')
def test_gemini_initialization_with_key(mock_generative_model: MagicMock, mock_configure: MagicMock):
"""Verify initialization logic is called when key is present (using mocks)."""
# Set a dummy key in the environment for this test
test_key = "TEST_API_KEY_123"
os.environ["GEMINI_API_KEY"] = test_key
# Reload settings and gemini module to pick up the new key
reload(app_settings)
reload(gemini_core)
# Assertions
mock_configure.assert_called_once_with(api_key=test_key)
mock_generative_model.assert_called_once_with(
model_name="gemini-1.5-flash-latest",
safety_settings=pytest.ANY, # Check safety settings were passed (ANY allows flexibility)
# generation_config=pytest.ANY # Check if you added default generation config
)
assert gemini_core.gemini_flash_client is not None
assert gemini_core.gemini_initialization_error is None
# Test get_gemini_client() success path
client = gemini_core.get_gemini_client()
assert client is not None # Should return the mocked client instance
# Clean up environment variable after test
if original_api_key:
os.environ["GEMINI_API_KEY"] = original_api_key
else:
if "GEMINI_API_KEY" in os.environ:
del os.environ["GEMINI_API_KEY"]
# Reload modules one last time to restore state for other tests
reload(app_settings)
reload(gemini_core)
# Restore original key after all tests in the module run (if needed)
def teardown_module(module):
if original_api_key:
os.environ["GEMINI_API_KEY"] = original_api_key
else:
if "GEMINI_API_KEY" in os.environ:
del os.environ["GEMINI_API_KEY"]
reload(app_settings)
reload(gemini_core)

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 sqlalchemy import delete as sql_delete, update as sql_update # Use aliases
from typing import Optional, List as PyList from typing import Optional, List as PyList
from datetime import datetime, timezone from datetime import datetime, timezone
from app.models import Item as ItemModel from app.models import Item as ItemModel
from app.schemas.item import ItemCreate, ItemUpdate 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: async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record.""" """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 'is_complete' in update_data:
if update_data['is_complete'] is True: if update_data['is_complete'] is True and item_db.completed_by_id is None:
# Mark as complete: set completed_by_id if not already set update_data['completed_by_id'] = user_id
if item_db.completed_by_id is None: elif update_data['is_complete'] is False:
update_data['completed_by_id'] = user_id
else:
# Mark as incomplete: clear completed_by_id
update_data['completed_by_id'] = None 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(): for key, value in update_data.items():
setattr(item_db, key, value) setattr(item_db, key, value)
db.add(item_db) # Add to session to track changes db.add(item_db)
await db.commit() await db.commit()
await db.refresh(item_db) await db.refresh(item_db)
return item_db return item_db

View File

@ -19,7 +19,8 @@ from sqlalchemy import (
func, func,
text as sa_text, text as sa_text,
Text, # <-- Add Text for description Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price Numeric, # <-- Add Numeric for price
ARRAY
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -30,6 +31,16 @@ class UserRoleEnum(enum.Enum):
owner = "owner" owner = "owner"
member = "member" 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 --- # --- User Model ---
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -123,7 +134,6 @@ class List(Base):
group = relationship("Group", back_populates="lists") # Link to Group.lists 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 items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes
# === NEW: Item Model === # === NEW: Item Model ===
class Item(Base): class Item(Base):
__tablename__ = "items" __tablename__ = "items"
@ -134,6 +144,8 @@ class Item(Base):
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch") quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
is_complete = Column(Boolean, default=False, nullable=False) is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99) 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 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 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) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@ -143,3 +155,50 @@ class Item(Base):
list = relationship("List", back_populates="items") # Link to List.items 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 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

6
be/app/schemas/ocr.py Normal file
View File

@ -0,0 +1,6 @@
# app/schemas/ocr.py
from pydantic import BaseModel
from typing import List
class OcrExtractResponse(BaseModel):
extracted_items: List[str] # A list of potential item names

View File

@ -9,3 +9,4 @@ python-dotenv>=1.0.0 # To load .env file for scripts/alembic
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
pydantic[email] pydantic[email]
google-generativeai>=0.5.0

View File

@ -21,6 +21,7 @@ if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build
export class ApiClientError extends Error { export class ApiClientError extends Error {
status: number; // HTTP status code status: number; // HTTP status code
errorData: unknown; // Parsed error data from response body (if any) errorData: unknown; // Parsed error data from response body (if any)
body: any;
constructor(message: string, status: number, errorData: unknown = null) { constructor(message: string, status: number, errorData: unknown = null) {
super(message); // Pass message to the base Error class super(message); // Pass message to the base Error class
@ -39,177 +40,96 @@ export class ApiClientError extends Error {
// --- Request Options Interface --- // --- Request Options Interface ---
// Extends standard RequestInit but omits 'body' as we handle it separately // Extends standard RequestInit but omits 'body' as we handle it separately
interface RequestOptions extends Omit<RequestInit, 'body'> { interface RequestOptions extends Omit<RequestInit, 'body' | 'headers'> {
// Can add custom options here later, e.g.: headers?: HeadersInit;
// skipAuth?: boolean; // To bypass adding the Authorization header
} }
// --- Core Request Function --- // --- Core Request Function ---
// Uses generics <T> to allow specifying the expected successful response data type // Uses generics <T> to allow specifying the expected successful response data type
async function request<T = unknown>( async function request<T = unknown>(
method: string, method: string,
path: string, // Relative path to the API endpoint (e.g., /v1/users/me) path: string,
bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.) bodyData?: unknown,
options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.) options: RequestOptions = {}
): Promise<T> { ): Promise<T> {
// Runtime check for BASE_URL, in case it wasn't set or available during initial load
if (!BASE_URL) { if (!BASE_URL) {
// Depending on context (load function vs. component event), choose how to handle throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.');
// error(500, 'API Base URL is not configured.'); // Use in load functions
throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); // Throw for component events
} }
// Construct the full URL safely const cleanBase = BASE_URL.replace(/\/$/, '');
const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base const cleanPath = path.replace(/^\//, '');
const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path
const url = `${cleanBase}/${cleanPath}`; const url = `${cleanBase}/${cleanPath}`;
// Initialize headers, setting Accept to JSON by default // --- Refined Header Handling ---
const headers = new Headers({ const headers = new Headers({ Accept: 'application/json' });
Accept: 'application/json',
...options.headers // Spread custom headers provided in options early if (options.headers) {
}); new Headers(options.headers).forEach((value, key) => {
headers.set(key, value);
});
}
// --- Prepare Request Body and Set Content-Type --- // --- Prepare Request Body and Set Content-Type ---
let processedBody: BodyInit | null = null; let processedBody: BodyInit | null = null;
if (bodyData !== undefined && bodyData !== null) { if (bodyData !== undefined && bodyData !== null) {
if (bodyData instanceof URLSearchParams) { if (bodyData instanceof URLSearchParams) {
// Handle URL-encoded form data
headers.set('Content-Type', 'application/x-www-form-urlencoded'); headers.set('Content-Type', 'application/x-www-form-urlencoded');
processedBody = bodyData; processedBody = bodyData;
} else if (bodyData instanceof FormData) { } else if (bodyData instanceof FormData) {
// Handle FormData (multipart/form-data)
// Let the browser set the Content-Type with the correct boundary
// Important: DO NOT set 'Content-Type' manually for FormData
// headers.delete('Content-Type'); // Ensure no manual Content-Type is set
processedBody = bodyData; processedBody = bodyData;
} else if (typeof bodyData === 'object') { } else if (typeof bodyData === 'object') {
// Handle plain JavaScript objects as JSON
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
try { try { processedBody = JSON.stringify(bodyData); }
processedBody = JSON.stringify(bodyData); catch (e) { throw new Error("Invalid JSON body data provided."); }
} catch (e) {
console.error("Failed to stringify JSON body data:", bodyData, e);
throw new Error("Invalid JSON body data provided.");
}
} else { } else {
// Handle other primitives (string, number, boolean) - default to sending as JSON stringified
// Adjust this logic if you need to send plain text or other formats
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
try { try { processedBody = JSON.stringify(bodyData); }
processedBody = JSON.stringify(bodyData) catch (e) { throw new Error("Invalid body data provided."); }
} catch (e) {
console.error("Failed to stringify primitive body data:", bodyData, e);
throw new Error("Invalid body data provided.");
}
} }
} }
// --- End Body Preparation ---
// --- Add Authorization Header --- // --- Add Authorization Header ---
const currentToken = getCurrentToken(); // Get token synchronously from auth store const currentToken = getCurrentToken();
// Add header if token exists and Authorization wasn't manually set in options.headers
if (currentToken && !headers.has('Authorization')) { if (currentToken && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${currentToken}`); headers.set('Authorization', `Bearer ${currentToken}`);
} }
// --- End Authorization Header ---
// Assemble final fetch options // --- Assemble fetch options carefully ---
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
method: method.toUpperCase(), method: method.toUpperCase(),
headers, headers: headers,
body: processedBody, // Use the potentially processed body body: processedBody,
credentials: options.credentials ?? 'same-origin', // Default credentials policy
mode: options.mode ?? 'cors', // Default mode
cache: options.cache ?? 'default', // Default cache policy
...options // Spread remaining options, potentially overriding defaults if needed
}; };
const { headers: _, ...restOfOptions } = options;
Object.assign(fetchOptions, restOfOptions);
fetchOptions.credentials = fetchOptions.credentials ?? 'same-origin';
fetchOptions.mode = fetchOptions.mode ?? 'cors';
fetchOptions.cache = fetchOptions.cache ?? 'default';
// --- Execute Fetch and Handle Response --- // --- Execute Fetch and Handle Response ---
try { try {
// Optional: Log request details for debugging
// console.debug(`API Request: ${fetchOptions.method} ${url}`, { headers: Object.fromEntries(headers.entries()), body: bodyData });
const response = await fetch(url, fetchOptions); const response = await fetch(url, fetchOptions);
// Optional: Log response status
// console.debug(`API Response Status: ${response.status} for ${fetchOptions.method} ${url}`);
// Check if the response status code indicates failure (not 2xx)
if (!response.ok) { if (!response.ok) {
let errorJson: unknown = null; let errorJson: unknown = null;
// Attempt to parse error details from the response body try { errorJson = await response.json(); }
try { catch (e) { /* ignore */ }
errorJson = await response.json(); const errorToThrow = new ApiClientError(`HTTP Error ${response.status}`, response.status, errorJson);
// console.debug(`API Error Response Body:`, errorJson); if (response.status === 401) { logout(); }
} catch (e) {
// Ignore if response body isn't valid JSON or empty
console.warn(`API Error response for ${response.status} was not valid JSON or empty.`);
}
// Create the custom error object
const errorToThrow = new ApiClientError(
`API request failed: ${response.status} ${response.statusText}`,
response.status,
errorJson
);
// --- Global 401 (Unauthorized) Handling ---
// If the server returns 401, assume the token is invalid/expired
// and automatically log the user out by clearing the auth store.
if (response.status === 401) {
console.warn(`API Client: Received 401 Unauthorized for ${method} ${path}. Logging out.`);
// Calling logout clears the token from store & localStorage
logout();
// Optional: Trigger a redirect to login page. Often better handled
// by calling code or root layout based on application structure.
// import { goto } from '$app/navigation';
// if (browser) await goto('/login?sessionExpired=true');
}
// --- End Global 401 Handling ---
// Throw the error regardless, so the calling code knows the request failed
throw errorToThrow; throw errorToThrow;
} }
if (response.status === 204) { return null as T; }
// Handle successful responses with no content (e.g., 204 No Content for DELETE) return (await response.json()) as T;
if (response.status === 204) {
// Assert type as T, assuming T can accommodate null or void if needed
return null as T;
}
// Parse successful JSON response body
const responseData = await response.json();
// Assert the response data matches the expected generic type T
return responseData as T;
} catch (err) { } catch (err) {
// Handle network errors (fetch throws TypeError) or errors thrown above if (err instanceof ApiClientError && err.status === 401) { logout(); }
console.error(`API Client request error during ${method} ${path}:`, err); if (err instanceof ApiClientError) { throw err; }
throw new ApiClientError('Unknown error occurred', 0, err);
// Ensure logout is called even if the caught error is a 401 ApiClientError
// This handles cases where parsing a non-ok response might fail but status was 401
if (err instanceof ApiClientError && err.status === 401) {
console.warn(`API Client: Caught ApiClientError 401 for ${method} ${path}. Ensuring logout.`);
// Ensure logout state is cleared even if error originated elsewhere
logout();
}
// Re-throw the error so the calling code can handle it appropriately
// If it's already our custom error, re-throw it directly
if (err instanceof ApiClientError) {
throw err;
}
// Otherwise, wrap network or other unexpected errors in our custom error type
throw new ApiClientError(
`Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`,
0, // Use 0 or a specific code (e.g., -1) for non-HTTP errors
err // Include the original error object as data
);
} }
} }
// --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) --- // --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) ---
// Provide simple wrappers around the core 'request' function // Provide simple wrappers around the core 'request' function

View File

@ -0,0 +1,234 @@
<!-- src/lib/components/ImageOcrInput.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
imageSelected: File; // Dispatch the selected file object
cancel: void; // Dispatch when user cancels
}>();
let selectedFile: File | null = null;
let previewUrl: string | null = null;
let inputKey = Date.now(); // Key to reset file input if needed
let error: string | null = null;
// Refs for the input elements
let fileInput: HTMLInputElement;
let captureInput: HTMLInputElement;
const MAX_FILE_SIZE_MB = 10; // Match backend limit
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
function handleFileChange(event: Event) {
error = null; // Clear previous error
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Basic Validation
if (!ALLOWED_TYPES.includes(file.type)) {
error = `Invalid file type. Please select JPEG, PNG, or WEBP. Type found: ${file.type}`;
resetInput();
return;
}
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
error = `File is too large (max ${MAX_FILE_SIZE_MB}MB). Size: ${(file.size / 1024 / 1024).toFixed(2)}MB`;
resetInput();
return;
}
selectedFile = file;
// Create a preview URL
if (previewUrl) URL.revokeObjectURL(previewUrl); // Revoke previous URL
previewUrl = URL.createObjectURL(file);
console.log('Image selected:', file.name, file.type, file.size);
} else {
// No file selected (e.g., user cancelled file picker)
// Optionally clear existing selection if needed
// clearSelection();
}
}
function clearSelection() {
selectedFile = null;
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
error = null;
resetInput(); // Reset the input fields
}
function resetInput() {
// Changing the key forces Svelte to recreate the input, clearing its value
inputKey = Date.now();
// Also reset the value manually in case key trick doesn't work everywhere
if (fileInput) fileInput.value = '';
if (captureInput) captureInput.value = '';
}
function triggerFileInput() {
fileInput?.click(); // Programmatically click the hidden file input
}
function triggerCaptureInput() {
captureInput?.click(); // Programmatically click the hidden capture input
}
function handleConfirm() {
if (selectedFile) {
dispatch('imageSelected', selectedFile);
} else {
error = 'Please select or capture an image first.';
}
}
function handleCancel() {
clearSelection();
dispatch('cancel');
}
// Clean up object URL when component is destroyed
onDestroy(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
});
import { onDestroy } from 'svelte'; // Ensure onDestroy is imported
</script>
<!-- Basic Modal Structure (adapt styling as needed) -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bg-opacity-60 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
on:click|self={handleCancel}
role="dialog"
aria-modal="true"
aria-labelledby="ocr-modal-title"
>
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
<h2 id="ocr-modal-title" class="mb-4 text-xl font-semibold text-gray-800">
Add Items via Photo
</h2>
{#if error}
<div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
role="alert"
>
{error}
</div>
{/if}
<!-- Hidden Inputs -->
<input
type="file"
accept="image/jpeg, image/png, image/webp"
on:change={handleFileChange}
bind:this={fileInput}
key={inputKey}
class="hidden"
aria-hidden="true"
/>
<input
type="file"
accept="image/jpeg, image/png, image/webp"
capture="environment"
on:change={handleFileChange}
bind:this={captureInput}
key={inputKey}
class="hidden"
aria-hidden="true"
/>
{#if previewUrl}
<!-- Preview Section -->
<div class="mb-4 text-center">
<p class="mb-2 text-sm text-gray-600">Image Preview:</p>
<img
src={previewUrl}
alt="Selected list preview"
class="mx-auto max-h-60 w-auto rounded border border-gray-300 object-contain"
/>
<button
type="button"
on:click={clearSelection}
class="mt-2 text-xs text-red-600 hover:underline"
>
Clear Selection
</button>
</div>
{/if}
<!-- Action Buttons -->
<div class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
on:click={triggerCaptureInput}
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
>
<!-- Basic Camera Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/></svg
>
Take Photo
</button>
<button
type="button"
on:click={triggerFileInput}
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
>
<!-- Basic Upload Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/></svg
>
Upload File
</button>
</div>
<!-- Confirmation/Cancel -->
<div class="mt-6 flex justify-end space-x-3 border-t pt-4">
<button
type="button"
on:click={handleCancel}
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
Cancel
</button>
<button
type="button"
on:click={handleConfirm}
disabled={!selectedFile}
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
Confirm Image
</button>
</div>
</div>
</div>

View File

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

View File

@ -0,0 +1,165 @@
<!-- src/lib/components/OcrReview.svelte -->
<script lang="ts">
import { createEventDispatcher, onMount, tick } from 'svelte'; // Added tick
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
// Props
/** Initial list of item names extracted by OCR */
export let initialItems: string[] = [];
// Events
const dispatch = createEventDispatcher<{
confirm: string[]; // Final list of item names
cancel: void;
}>();
// Internal State
interface ReviewItem {
id: string; // Unique key for {#each} and focus management
name: string;
}
let reviewedItems: ReviewItem[] = [];
export let isLoading: boolean = false; // Add isLoading prop
let inputRefs: Record<string, HTMLInputElement> = {}; // To store references for focusing
// Initialize items with unique IDs when component mounts or prop changes
$: if (initialItems) {
reviewedItems = initialItems.map((name) => ({
id: crypto.randomUUID(), // Generate unique ID for each item
name: name
}));
console.log('OcrReview initialized with items:', reviewedItems);
}
/** Deletes an item from the review list */
function deleteItem(idToDelete: string) {
reviewedItems = reviewedItems.filter((item) => item.id !== idToDelete);
}
/** Adds a new, empty input field to the list */
async function addItemManually() {
const newItemId = crypto.randomUUID();
// Add a new empty item at the end
reviewedItems = [...reviewedItems, { id: newItemId, name: '' }];
// Wait for the DOM to update, then focus the new input
await tick();
if (inputRefs[newItemId]) {
inputRefs[newItemId].focus();
}
}
/** Dispatches the confirmed list of non-empty item names */
function handleConfirm() {
// Filter out empty items and extract just the names
const finalItemNames = reviewedItems
.map((item) => item.name.trim())
.filter((name) => name.length > 0);
console.log('OcrReview confirming items:', finalItemNames);
dispatch('confirm', finalItemNames);
}
/** Dispatches the cancel event */
function handleCancel() {
dispatch('cancel');
}
</script>
<!-- Modal Structure -->
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4"
on:click|self={handleCancel}
role="dialog"
aria-modal="true"
aria-labelledby="ocr-review-title"
>
<!-- Prevent backdrop click from closing when clicking inside modal content -->
<div
class="flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl"
on:click|stopPropagation
>
<!-- Header -->
<div class="flex-shrink-0 border-b p-4">
<h2 id="ocr-review-title" class="text-xl font-semibold text-gray-800">
Review Extracted Items
</h2>
<p class="mt-1 text-sm text-gray-600">
Edit, delete, or add items below. Confirm to add them to your list.
</p>
</div>
<!-- Item List (Scrollable) -->
<div class="flex-grow overflow-y-auto p-4">
{#if reviewedItems.length > 0}
<ul class="space-y-2">
{#each reviewedItems as item (item.id)}
<li class="flex items-center gap-2" animate:flip={{ duration: 200 }}>
<!-- Bind input element reference using item.id as key -->
<input
type="text"
bind:value={item.name}
bind:this={inputRefs[item.id]}
placeholder="Enter item name..."
class="flex-grow 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"
aria-label="Item name"
/>
<button
on:click={() => deleteItem(item.id)}
title="Remove item"
class="flex-shrink-0 rounded p-1 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-1 focus:ring-red-400"
aria-label="Remove item"
>
<!-- Basic 'X' icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
{/each}
</ul>
{:else}
<p class="py-4 text-center text-sm text-gray-500">
No items extracted. Add items manually below.
</p>
{/if}
</div>
<!-- Add Manually Button -->
<div class="flex-shrink-0 px-4 pb-4">
<button
on:click={addItemManually}
class="w-full rounded border border-dashed border-gray-400 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
+ Add Item Manually
</button>
</div>
<!-- Footer Actions -->
<div class="flex flex-shrink-0 justify-end space-x-3 border-t bg-gray-50 p-4">
<button
type="button"
on:click={handleCancel}
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Cancel
</button>
<button
type="button"
on:click={handleConfirm}
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Adding...' : 'Confirm & Add Items'}
</button>
</div>
</div>
</div>

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

@ -0,0 +1,8 @@
export interface OcrExtractResponse {
extracted_items: string[]; // Matches the backend schema
}
export interface OcrReviewItem {
id: number; // Temporary unique ID for the {#each} key
text: string; // The item name, editable
}

View File

@ -2,25 +2,30 @@
<script lang="ts"> <script lang="ts">
// Svelte/SvelteKit Imports // Svelte/SvelteKit Imports
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte'; import type { PageData } from './$types'; // Correct import for PageData
import type { PageData } from '../$types'; 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 // Component Imports
import ItemDisplay from '$lib/components/ItemDisplay.svelte'; 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 // Utility/Store Imports
import { apiClient, ApiClientError } from '$lib/apiClient'; import { apiClient, ApiClientError } from '$lib/apiClient';
import { authStore } from '$lib/stores/authStore'; // Get current user ID import { authStore } from 'c:/Users/Vinylnostalgia/Desktop/dev/doe/fe/src/lib/stores/authStore';
import { get, writable } from 'svelte/store'; // For local reactive list state
// Schema Imports // Schema Imports
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item'; 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'; // Corrected import
import type { ExpenseRecordPublic, ExpenseSharePublic } from '$lib/schemas/expense';
// --- DB and Sync Imports --- // --- DB and Sync Imports ---
import { import {
getListFromDb, getListFromDb,
getItemsByListIdFromDb,
putListToDb, putListToDb,
putItemToDb, putItemToDb,
deleteItemFromDb, deleteItemFromDb,
@ -28,41 +33,58 @@
} from '$lib/db'; } from '$lib/db';
import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService'; import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte';
// --- End DB and Sync Imports --- // --- End DB and Sync Imports ---
// --- Props --- // --- 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 --- // --- Local State ---
// Use a writable store locally to manage the list and items for easier updates const localListStore = writable<ListDetail | null>(null);
// Initialize with data from SSR/load function as fallback const localExpensesStore = writable<ExpenseRecordPublic[]>([]);
const localListStore = writable<ListDetail | null>(data.list); let initialLoadError: string | null = null;
// --- Add Item State --- // Add Item State
let newItemName = ''; let newItemName = '';
let newItemQuantity = ''; let newItemQuantity = '';
let isAddingItem = false; let isAddingItem = false;
let addItemError: string | null = null; let addItemError: string | null = null;
// General Item Update Error Display
// --- General Item Error Display ---
let itemUpdateError: string | null = null; let itemUpdateError: string | null = null;
let itemErrorTimeout: ReturnType<typeof setTimeout> | undefined = undefined; let itemErrorTimeout: ReturnType<typeof setTimeout>;
// Polling State
// --- Polling State ---
let pollIntervalId: ReturnType<typeof setInterval> | null = null; let pollIntervalId: ReturnType<typeof setInterval> | null = null;
let lastKnownStatus: { let lastKnownStatus: any | null = null;
// Ensure this stores Date objects or null
list_updated_at: Date;
latest_item_updated_at: Date | null;
item_count: number;
} | null = null;
let isRefreshing = false; let isRefreshing = false;
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds const POLLING_INTERVAL_MS = 15000;
// OCR State
let showOcrModal = false;
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 --- // --- Lifecycle ---
onMount(() => { onMount(() => {
let isMounted = true;
(async () => { (async () => {
let listId: number | null = null; let listId: number | null = null;
try { try {
@ -73,64 +95,64 @@
if (!listId) { if (!listId) {
console.error('List Detail Mount: Invalid or missing listId in params.'); console.error('List Detail Mount: Invalid or missing listId in params.');
// Optionally redirect or show permanent error initialLoadError = 'Invalid List ID specified in URL.'; // Show error
localListStore.set(null); // Ensure list is null
localExpensesStore.set([]);
return; return;
} }
// 1. Load from IndexedDB first for faster initial display/offline // 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) { if (browser) {
console.log('List Detail Mount: Loading from IndexedDB for list', listId); console.log('List Detail Mount: Loading from IndexedDB for list', listId);
const listFromDb = await getListFromDb(listId); const listFromDb = await getListFromDb(listId);
if (listFromDb) { if (listFromDb) {
console.log('List Detail Mount: Found list in DB', listFromDb); console.log('List Detail Mount: Found list in DB', listFromDb);
// Items should be part of ListDetail object store localListStore.set(listFromDb);
if (isMounted) { initializePollingStatus(listFromDb);
localListStore.set(listFromDb); // TODO: Load expenses from DB too? Requires adding expenses to DB schema/functions
initializePollingStatus(listFromDb);
}
} else { } else {
console.log('List Detail Mount: List not found in DB, using SSR/load data.'); console.log('List Detail Mount: List not found in DB, using SSR/load data.');
if (isMounted) { // Already set above from data prop
localListStore.set(data.list); // Fallback to initial data
initializePollingStatus(data.list);
}
} }
// 2. If online, trigger an API fetch in background to update DB/UI // If online, trigger API fetches in background
if (navigator.onLine) { if (navigator.onLine) {
console.log('List Detail Mount: Online, fetching fresh data...'); console.log('List Detail Mount: Online, fetching fresh data...');
fetchAndUpdateList(listId); // Don't await, let it run in background fetchAndUpdateList(listId); // Don't await
// Also trigger sync queue processing fetchExpenseHistory(listId); // Don't await
processSyncQueue(); // Don't await processSyncQueue(); // Don't await
} }
// 3. Start polling // Start polling
startPolling(); startPolling();
} else {
// Server side: Use data from load function directly
if (isMounted) {
localListStore.set(data.list);
initializePollingStatus(data.list);
}
} }
})(); })();
return () => { return () => {
isMounted = false;
stopPolling(); stopPolling();
clearTimeout(itemErrorTimeout); clearTimeout(itemErrorTimeout);
}; };
}); });
// 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) { async function fetchAndUpdateList(listId: number) {
if (isRefreshing) return; // Prevent concurrent refreshes
isRefreshing = true; isRefreshing = true;
console.log('List Detail: Fetching fresh list data for', listId);
try { try {
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`); const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
await putListToDb(freshList); // Update IndexedDB await putListToDb(freshList);
localListStore.set(freshList); // Update the UI store localListStore.set(freshList);
// No need to re-initialize polling status here, checkListStatus will update it // Polling status will be reset by checkListStatus after refresh
console.log('List Detail: Fetched and updated list', listId); console.log('List Detail: Fetched and updated list', listId);
clearItemError();
} catch (err) { } catch (err) {
console.error('List Detail: Failed to fetch fresh list data', err); console.error('List Detail: Failed to fetch fresh list data', err);
handleItemUpdateError( handleItemUpdateError(
@ -141,7 +163,26 @@
} }
} }
// Helper to initialize polling status from ListDetail data /** 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) { function initializePollingStatus(listData: ListDetail | null) {
if (!listData) { if (!listData) {
lastKnownStatus = null; lastKnownStatus = null;
@ -151,6 +192,7 @@
const listUpdatedAt = new Date(listData.updated_at); const listUpdatedAt = new Date(listData.updated_at);
let latestItemUpdate: Date | null = null; let latestItemUpdate: Date | null = null;
if (listData.items && listData.items.length > 0) { if (listData.items && listData.items.length > 0) {
// Find the latest date string first, then convert
const latestDateString = listData.items.reduce( const latestDateString = listData.items.reduce(
(latest, item) => (item.updated_at > latest ? item.updated_at : latest), (latest, item) => (item.updated_at > latest ? item.updated_at : latest),
listData.items[0].updated_at listData.items[0].updated_at
@ -169,27 +211,32 @@
} }
} }
// --- Polling Logic --- /** Starts the polling interval */
function startPolling() { function startPolling() {
stopPolling(); stopPolling();
if (!$localListStore) return; const currentList = get(localListStore);
if (!currentList || !browser) return; // Only poll in browser
console.log( console.log(
`Polling: Starting polling for list ${$localListStore.id} every ${POLLING_INTERVAL_MS}ms` `Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms`
); );
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS); pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
} }
/** Stops the polling interval */
function stopPolling() { function stopPolling() {
if (pollIntervalId) { if (pollIntervalId) {
console.log(`Polling: Stopping polling for list ${get(localListStore)?.id}`);
clearInterval(pollIntervalId); clearInterval(pollIntervalId);
pollIntervalId = null; pollIntervalId = null;
} }
} }
/** Checks the list status endpoint and triggers a full refresh if changes detected */
async function checkListStatus() { async function checkListStatus() {
const currentList = get(localListStore); // Use get for non-reactive access inside async const currentList = get(localListStore);
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) { // Skip if no list, already refreshing, status unknown, or offline
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.'); if (!currentList || isRefreshing || !lastKnownStatus || (browser && !navigator.onLine)) {
if (browser && !navigator.onLine) console.log('Polling: Offline, skipping status check.');
return; return;
} }
console.log(`Polling: Checking status for list ${currentList.id}`); console.log(`Polling: Checking status for list ${currentList.id}`);
@ -200,12 +247,13 @@
? new Date(currentStatus.latest_item_updated_at) ? new Date(currentStatus.latest_item_updated_at)
: null; : null;
// Compare timestamps using getTime() and item count
const listChanged = const listChanged =
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime(); currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
const itemsChanged = const itemsChanged =
currentLatestItemUpdatedAt?.getTime() !== currentLatestItemUpdatedAt?.getTime() !==
lastKnownStatus.latest_item_updated_at?.getTime() || lastKnownStatus.latest_item_updated_at?.getTime() ||
currentStatus.item_count !== lastKnownStatus.item_count; Number(currentStatus.item_count) !== lastKnownStatus.item_count;
if (listChanged || itemsChanged) { if (listChanged || itemsChanged) {
console.log('Polling: Change detected!', { listChanged, itemsChanged }); console.log('Polling: Change detected!', { listChanged, itemsChanged });
@ -214,83 +262,146 @@
lastKnownStatus = { lastKnownStatus = {
list_updated_at: currentListUpdatedAt, list_updated_at: currentListUpdatedAt,
latest_item_updated_at: currentLatestItemUpdatedAt, latest_item_updated_at: currentLatestItemUpdatedAt,
item_count: currentStatus.item_count item_count: Number(currentStatus.item_count)
}; };
} else { } else {
console.log('Polling: No changes detected.'); console.log('Polling: No changes detected.');
} }
} catch (err) { } catch (err) {
console.error('Polling: Failed to fetch list status:', 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() { async function refreshListData() {
// Refactored to use store value
const listId = get(localListStore)?.id; const listId = get(localListStore)?.id;
if (listId) { if (!listId || !browser || !navigator.onLine) return; // Only refresh if online
await fetchAndUpdateList(listId); 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 --- // --- Event Handlers from ItemDisplay ---
/** Handles the itemUpdated event from ItemDisplay */
async function handleItemUpdated(event: CustomEvent<ItemPublic>) { async function handleItemUpdated(event: CustomEvent<ItemPublic>) {
const updatedItem = event.detail; const updatedItem = event.detail;
console.log('Parent received itemUpdated:', updatedItem); console.log('Parent received itemUpdated:', updatedItem);
// Update DB (already done in ItemDisplay optimistic update)
// Update store for UI // Update store for UI
localListStore.update((currentList) => { localListStore.update((currentList) => {
if (!currentList) return null; 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) { if (index !== -1) {
currentList.items[index] = updatedItem; 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 { ...currentList, items: [...currentList.items] };
}); });
// DB update was handled optimistically in ItemDisplay
clearItemError(); clearItemError();
} }
/** Handles the itemDeleted event from ItemDisplay */
async function handleItemDeleted(event: CustomEvent<number>) { async function handleItemDeleted(event: CustomEvent<number>) {
const deletedItemId = event.detail; const deletedItemId = event.detail;
console.log('Parent received itemDeleted:', deletedItemId); console.log('Parent received itemDeleted:', deletedItemId);
// Update DB (already done in ItemDisplay optimistic update)
// Update store for UI // Update store for UI
localListStore.update((currentList) => { localListStore.update((currentList) => {
if (!currentList) return null; if (!currentList) return null;
return { return {
...currentList, ...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(); clearItemError();
} }
/** Handles the updateError event from ItemDisplay */
function handleItemUpdateError(event: CustomEvent<string>) { function handleItemUpdateError(event: CustomEvent<string>) {
/* ... (keep existing) ... */ const errorMsg = event.detail;
} console.log('Parent received updateError:', errorMsg);
function clearItemError() { itemUpdateError = errorMsg;
/* ... (keep existing) ... */ clearTimeout(itemErrorTimeout);
itemErrorTimeout = setTimeout(() => {
itemUpdateError = null;
}, 5000);
} }
// --- Add Item Logic --- /** Clears the general item update error message */
function clearItemError() {
itemUpdateError = null;
clearTimeout(itemErrorTimeout);
}
// --- Add Item Logic (Single Item) ---
/** Handles submission of the Add Item form */
async function handleAddItem() { async function handleAddItem() {
const currentList = get(localListStore); // Use get for non-reactive access const currentList = get(localListStore);
if (!newItemName.trim() || !currentList) return; if (!newItemName.trim() || !currentList) {
addItemError = 'Item name cannot be empty.';
return;
}
if (isAddingItem) return; if (isAddingItem) return;
isAddingItem = true; isAddingItem = true;
addItemError = null; addItemError = null;
clearItemError(); clearItemError();
// 1. Optimistic UI Update with Temporary ID (Using negative random number for simplicity) // 1. Optimistic UI Update with Temporary ID
const tempId = Math.floor(Math.random() * -1000000); const currentUserId = get(authStore).user?.id;
const currentUserId = get(authStore).user?.id; // Get current user ID synchronously
if (!currentUserId) { if (!currentUserId) {
addItemError = 'Cannot add item: User not identified.'; addItemError = 'Cannot add item: User not identified.';
isAddingItem = false; isAddingItem = false;
return; return;
} }
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const optimisticItem: ItemPublic = { const optimisticItem: ItemPublic = {
id: tempId, // Use temporary ID id: tempId as any, // Cast needed as DB expects number, but temp is string/negative
list_id: currentList.id, list_id: currentList.id,
name: newItemName.trim(), name: newItemName.trim(),
quantity: newItemQuantity.trim() || null, quantity: newItemQuantity.trim() || null,
@ -305,8 +416,7 @@
localListStore.update((list) => localListStore.update((list) =>
list ? { ...list, items: [...list.items, optimisticItem] } : null list ? { ...list, items: [...list.items, optimisticItem] } : null
); );
// Note: Cannot add item with temp ID to IndexedDB if keyPath is 'id' and type is number. // Skip adding temp item to IndexedDB for simplicity in MVP
// For MVP, we skip adding temp items to DB and rely on sync + refresh.
// 2. Queue Sync Action // 2. Queue Sync Action
const actionPayload: ItemCreate = { const actionPayload: ItemCreate = {
@ -317,8 +427,8 @@
await addSyncAction({ await addSyncAction({
type: 'create_item', type: 'create_item',
payload: { listId: currentList.id, data: actionPayload }, payload: { listId: currentList.id, data: actionPayload },
timestamp: Date.now() timestamp: Date.now(),
// tempId: tempId // Optional: include tempId for mapping later tempId: tempId // Include tempId for potential mapping later
}); });
// 3. Trigger sync if online // 3. Trigger sync if online
@ -330,17 +440,249 @@
} catch (dbError) { } catch (dbError) {
console.error('Failed to queue add item action:', dbError); console.error('Failed to queue add item action:', dbError);
addItemError = 'Failed to save item for offline sync.'; addItemError = 'Failed to save item for offline sync.';
// Revert optimistic UI update? More complex. // Revert optimistic UI update: convert item id to string for reliable comparison
localListStore.update((list) => localListStore.update((list) =>
list ? { ...list, items: list.items.filter((i) => i.id !== tempId) } : null list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
); );
} finally { } finally {
isAddingItem = false; isAddingItem = false;
} }
} }
// --- OCR Handling ---
function openOcrModal() {
ocrError = null;
confirmOcrError = null;
showOcrModal = true;
}
function closeOcrModal() {
showOcrModal = false;
}
function closeOcrReview() {
showOcrReview = false;
ocrResults = [];
}
/** Handles image selection from the modal, uploads it, and shows review modal */
async function handleImageSelected(event: CustomEvent<File>) {
const imageFile = event.detail;
closeOcrModal();
isProcessingOcr = true;
ocrError = null;
confirmOcrError = null;
const formData = new FormData();
formData.append('image_file', imageFile);
try {
const result = await apiClient.post<OcrExtractResponse>('/v1/ocr/extract-items', formData);
console.log('OCR Extraction successful:', result);
if (result.extracted_items && result.extracted_items.length > 0) {
ocrResults = result.extracted_items;
showOcrReview = true; // Show the review modal
} else {
ocrError = 'OCR processing finished, but no items were extracted.';
}
} catch (err) {
console.error('OCR failed:', err);
if (err instanceof ApiClientError) {
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 {
ocrError = 'An unexpected OCR error occurred.';
}
} finally {
isProcessingOcr = false;
}
}
/** Handles confirmation from the OCR Review modal */
async function handleOcrConfirm(event: CustomEvent<string[]>) {
const itemNamesToAdd = event.detail;
closeOcrReview();
if (!itemNamesToAdd || itemNamesToAdd.length === 0) {
return;
}
isConfirmingOcrItems = true;
confirmOcrError = null;
let successCount = 0;
let failCount = 0;
const currentList = get(localListStore);
const currentUserId = get(authStore).user?.id;
if (!currentList || !currentUserId) {
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 (const name of itemNamesToAdd) {
if (!name.trim()) continue;
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
// Optimistic UI update
const optimisticItem: ItemPublic = {
id: tempId as any,
list_id: currentList.id,
name: name.trim(),
quantity: null, // Default quantity for OCR items
is_complete: false,
price: null,
added_by_id: currentUserId,
completed_by_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
localListStore.update((list) =>
list ? { ...list, items: [...list.items, optimisticItem] } : null
);
// Queue Sync Action
const actionPayload: ItemCreate = { name: name.trim(), quantity: undefined };
try {
await addSyncAction({
type: 'create_item',
payload: { listId: currentList.id, data: actionPayload },
timestamp: Date.now(),
tempId: tempId
});
successCount++;
} catch (dbError) {
console.error(`Failed to queue item '${name}':`, dbError);
failCount++;
// 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
);
}
}
// Trigger sync if online
if (browser && navigator.onLine) processSyncQueue();
isConfirmingOcrItems = false;
// Provide feedback
if (failCount > 0) {
confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`;
} else {
console.log(`Successfully queued ${successCount} items from OCR.`);
}
}
// --- 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> </script>
<!-- Template --> <!-- TEMPLATE -->
{#if $localListStore} {#if $localListStore}
{@const list = $localListStore} {@const list = $localListStore}
<!-- Create local const for easier access --> <!-- Create local const for easier access -->
@ -348,17 +690,22 @@
<!-- Sync Status Indicator --> <!-- Sync Status Indicator -->
{#if $syncStatus === 'syncing'} {#if $syncStatus === 'syncing'}
<div <div
class="fixed right-4 bottom-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow" class="fixed bottom-4 right-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
role="status" role="status"
> >
Syncing changes... Syncing changes...
</div> </div>
{:else if $syncStatus === 'error' && $syncError} {:else if $syncStatus === 'error' && $syncError}
<div <div
class="fixed right-4 bottom-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow" class="fixed bottom-4 right-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
role="alert" role="alert"
> >
Sync Error: {$syncError} Sync Error: {$syncError}
<button
class="ml-2 font-semibold underline"
on:click={triggerSync}
title="Retry Synchronization">Retry</button
>
</div> </div>
{/if} {/if}
@ -380,21 +727,49 @@
list.updated_at list.updated_at
).toLocaleString()} ).toLocaleString()}
</p> </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>
<div class="flex-shrink-0 space-x-2"> <div class="flex flex-shrink-0 items-center space-x-2">
<!-- Action Buttons -->
{#if isRefreshing} {#if isRefreshing}
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span> <span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
{/if} {/if}
<!-- OCR Button -->
<button
type="button"
on:click={openOcrModal}
disabled={isProcessingOcr || isConfirmingOcrItems}
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}
<!-- Spinner --> Processing...
{:else if isConfirmingOcrItems}
<!-- Spinner --> Adding Items...
{:else}
📷 Add via Photo
{/if}
</button>
<a <a
href="/lists/{list.id}/edit" href="/lists/{list.id}/edit"
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2 focus:outline-none" class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2"
> >
Edit List Details Edit List Details
</a> </a>
</div> </div>
</div> </div>
{#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 || calculateSplitError}
</div>
{/if}
<!-- Add New Item Form --> <!-- Add New Item Form Section -->
<div class="rounded bg-white p-4 shadow"> <div class="rounded bg-white p-4 shadow">
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2> <h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
<form <form
@ -409,7 +784,7 @@
placeholder="Item name (required)" placeholder="Item name (required)"
required required
bind:value={newItemName} bind:value={newItemName}
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70" class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
disabled={isAddingItem} disabled={isAddingItem}
/> />
</div> </div>
@ -420,13 +795,13 @@
id="new-item-qty" id="new-item-qty"
placeholder="Quantity (opt.)" placeholder="Quantity (opt.)"
bind:value={newItemQuantity} bind:value={newItemQuantity}
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70" class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
disabled={isAddingItem} disabled={isAddingItem}
/> />
</div> </div>
<button <button
type="submit" type="submit"
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white shadow-sm transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" class="whitespace-nowrap rounded bg-blue-600 px-4 py-2 font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isAddingItem} disabled={isAddingItem}
> >
{isAddingItem ? 'Adding...' : 'Add Item'} {isAddingItem ? 'Adding...' : 'Add Item'}
@ -441,6 +816,7 @@
<div class="rounded bg-white p-6 shadow"> <div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2> <h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2>
{#if itemUpdateError} {#if itemUpdateError}
<!-- Display errors bubbled up from items -->
<div <div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
role="alert" role="alert"
@ -450,6 +826,7 @@
{/if} {/if}
{#if list.items && list.items.length > 0} {#if list.items && list.items.length > 0}
<ul class="space-y-2"> <ul class="space-y-2">
<!-- Use {#key} block to help Svelte efficiently update the list -->
{#each list.items as item (item.id)} {#each list.items as item (item.id)}
<ItemDisplay <ItemDisplay
{item} {item}
@ -464,6 +841,111 @@
{/if} {/if}
</div> </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 --> <!-- Back Link -->
<div class="mt-6 border-t border-gray-200 pt-6"> <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> <a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
@ -472,4 +954,26 @@
{:else} {:else}
<!-- Fallback if list data is somehow null/undefined after load function --> <!-- Fallback if list data is somehow null/undefined after load function -->
<p class="text-center text-gray-500">Loading list data...</p> <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}
<!-- Modals -->
{#if showOcrModal}
<ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} />
{/if}
{#if showOcrReview}
<OcrReview
initialItems={ocrResults}
on:confirm={handleOcrConfirm}
on:cancel={closeOcrReview}
bind:isLoading={isConfirmingOcrItems}
/>
{/if} {/if}

View File

@ -2,52 +2,99 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { apiClient, ApiClientError } from '$lib/apiClient'; import { apiClient, ApiClientError } from '$lib/apiClient';
import type { ListDetail } from '$lib/schemas/list'; import type { ListDetail } from '$lib/schemas/list';
// --- Use the correct generated type --- import type { ExpenseRecordPublic } from '$lib/schemas/expense'; // Import expense type
import type { PageLoad } from './$types'; // This type includes correctly typed 'params' 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 { 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 }) => { export const load: PageLoad<ListDetailPageLoadData> = async ({ params, fetch }) => {
// Get listId from the URL parameter provided by SvelteKit routing
const listId = params.listId; const listId = params.listId;
console.log(`List Detail page load: Fetching data for list ID: ${listId}`); console.log(`List Detail page load: Fetching data for list ID: ${listId}`);
// Validate the listId parameter
if (!listId || isNaN(parseInt(listId, 10))) { 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 { try {
// Fetch the specific list details (expecting items to be included) // Fetch list details and expense history concurrently for efficiency
// The backend GET /api/v1/lists/{list_id} should return ListDetail schema const [listResult, expensesResult] = await Promise.allSettled([
const listData = await apiClient.get<ListDetail>(`/v1/lists/${listId}`); apiClient.get<ListDetail>(`/v1/lists/${numericListId}`), // Fetch specific list details
apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${numericListId}/expenses`) // Fetch expense history
]);
if (!listData) { let listData: ListDetail;
// Should not happen if API call was successful, but check defensively let expensesData: ExpenseRecordPublic[] = [];
throw error(404, 'List not found (API returned no data)'); let expensesLoadError: string | null = null;
}
console.log('List Detail page load: Data fetched successfully', listData); // --- Process List Result (Critical) ---
return { if (listResult.status === 'fulfilled' && listResult.value) {
list: listData listData = listResult.value;
}; console.log(`List Detail load: Successfully fetched list ${numericListId}`);
} 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}`);
} else { } else {
// Unknown error // Handle list fetch failure - this is critical, so throw SvelteKit error
throw error(500, 'An unexpected error occurred while loading list data.'); 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'}`);
} }
}; };