end of phase 4

This commit is contained in:
mohamad 2025-03-31 00:07:43 +02:00
parent 4fbbe77658
commit 53c7382b88
26 changed files with 2563 additions and 94 deletions

View File

@ -0,0 +1,73 @@
"""Add list and item tables
Revision ID: d25788f63e2c
Revises: d90ab7116920
Create Date: 2025-03-30 19:43:49.925240
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd25788f63e2c'
down_revision: Union[str, None] = 'd90ab7116920'
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('lists',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('is_complete', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_lists_id'), 'lists', ['id'], unique=False)
op.create_index(op.f('ix_lists_name'), 'lists', ['name'], unique=False)
op.create_table('items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('quantity', sa.String(), nullable=True),
sa.Column('is_complete', sa.Boolean(), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('added_by_id', sa.Integer(), nullable=False),
sa.Column('completed_by_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['added_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['completed_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False)
op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False)
op.drop_index('ix_invites_code', table_name='invites')
op.create_index(op.f('ix_invites_code'), 'invites', ['code'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_invites_code'), table_name='invites')
op.create_index('ix_invites_code', 'invites', ['code'], unique=True)
op.drop_index(op.f('ix_items_name'), table_name='items')
op.drop_index(op.f('ix_items_id'), table_name='items')
op.drop_table('items')
op.drop_index(op.f('ix_lists_name'), table_name='lists')
op.drop_index(op.f('ix_lists_id'), table_name='lists')
op.drop_table('lists')
# ### end Alembic commands ###

View File

@ -6,6 +6,8 @@ from app.api.v1.endpoints import auth
from app.api.v1.endpoints import users
from app.api.v1.endpoints import groups
from app.api.v1.endpoints import invites
from app.api.v1.endpoints import lists
from app.api.v1.endpoints import items
api_router_v1 = APIRouter()
@ -14,6 +16,7 @@ api_router_v1.include_router(auth.router, prefix="/auth", tags=["Authentication"
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
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(lists.router, prefix="/lists", tags=["Lists"])
api_router_v1.include_router(items.router, tags=["Items"])
# Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -0,0 +1,150 @@
# app/api/v1/endpoints/items.py
import logging
from typing import List as PyList
from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.dependencies import get_current_user
# --- Import Models Correctly ---
from app.models import User as UserModel
from app.models import Item as ItemModel # <-- IMPORT Item and alias it
# --- End Import Models ---
from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic
from app.crud import item as crud_item
from app.crud import list as crud_list
logger = logging.getLogger(__name__)
router = APIRouter()
# --- Helper Dependency for Item Permissions ---
# Now ItemModel is defined before being used as a type hint
async def get_item_and_verify_access(
item_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user)
) -> ItemModel: # Now this type hint is valid
item_db = await crud_item.get_item_by_id(db, item_id=item_id)
if not item_db:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found")
# Check permission on the parent list
list_db = await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id)
if not list_db:
# User doesn't have access to the list this item belongs to
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to access this item's list")
return item_db # Return the fetched item if authorized
# --- Endpoints ---
@router.post(
"/lists/{list_id}/items", # Nested under lists
response_model=ItemPublic,
status_code=status.HTTP_201_CREATED,
summary="Add Item to List",
tags=["Items"]
)
async def create_list_item(
list_id: int,
item_in: ItemCreate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""Adds a new item to a specific list. User must have access to the list."""
logger.info(f"User {current_user.email} adding item to list {list_id}: {item_in.name}")
# Verify user has access to the target list
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
if not list_db:
# Check if list exists at all for correct error code
exists = await crud_list.get_list_by_id(db, list_id)
status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN
detail = "List not found" if not exists else "You do not have permission to add items to this list"
logger.warning(f"Add item failed for list {list_id} by user {current_user.email}: {detail}")
raise HTTPException(status_code=status_code, detail=detail)
created_item = await crud_item.create_item(
db=db, item_in=item_in, list_id=list_id, user_id=current_user.id
)
logger.info(f"Item '{created_item.name}' (ID: {created_item.id}) added to list {list_id} by user {current_user.email}.")
return created_item
@router.get(
"/lists/{list_id}/items", # Nested under lists
response_model=PyList[ItemPublic],
summary="List Items in List",
tags=["Items"]
)
async def read_list_items(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
# Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc'
):
"""Retrieves all items for a specific list if the user has access."""
logger.info(f"User {current_user.email} listing items for list {list_id}")
# Verify user has access to the list
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
if not list_db:
exists = await crud_list.get_list_by_id(db, list_id)
status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN
detail = "List not found" if not exists else "You do not have permission to view items in this list"
logger.warning(f"List items failed for list {list_id} by user {current_user.email}: {detail}")
raise HTTPException(status_code=status_code, detail=detail)
items = await crud_item.get_items_by_list_id(db=db, list_id=list_id)
return items
@router.put(
"/items/{item_id}", # Operate directly on item ID
response_model=ItemPublic,
summary="Update Item",
tags=["Items"]
)
async def update_item(
item_id: int, # Item ID from path
item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by
):
"""
Updates an item's details (name, quantity, is_complete, price).
User must have access to the list the item belongs to.
Sets/unsets `completed_by_id` based on `is_complete` flag.
"""
logger.info(f"User {current_user.email} attempting to update item ID: {item_id}")
# Permission check is handled by get_item_and_verify_access dependency
updated_item = await crud_item.update_item(
db=db, item_db=item_db, item_in=item_in, user_id=current_user.id
)
logger.info(f"Item {item_id} updated successfully by user {current_user.email}.")
return updated_item
@router.delete(
"/items/{item_id}", # Operate directly on item ID
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Item",
tags=["Items"]
)
async def delete_item(
item_id: int, # Item ID from path
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), # Log who deleted it
):
"""
Deletes an item. User must have access to the list the item belongs to.
(MVP: Any member with list access can delete items).
"""
logger.info(f"User {current_user.email} attempting to delete item ID: {item_id}")
# Permission check is handled by get_item_and_verify_access dependency
await crud_item.delete_item(db=db, item_db=item_db)
logger.info(f"Item {item_id} deleted successfully by user {current_user.email}.")
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1,211 @@
# app/api/v1/endpoints/lists.py
import logging
from typing import List as PyList # Alias for Python List type hint
from fastapi import APIRouter, Depends, HTTPException, status, Response
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
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list
from app.crud import group as crud_group # Need for group membership check
from app.schemas.list import ListStatus
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"", # Route relative to prefix "/lists"
response_model=ListPublic, # Return basic list info on creation
status_code=status.HTTP_201_CREATED,
summary="Create New List",
tags=["Lists"]
)
async def create_list(
list_in: ListCreate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""
Creates a new shopping list.
- If `group_id` is provided, the user must be a member of that group.
- If `group_id` is null, it's a personal list.
"""
logger.info(f"User {current_user.email} creating list: {list_in.name}")
group_id = list_in.group_id
# Permission Check: If sharing with a group, verify membership
if group_id:
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a member of the specified group",
)
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
return created_list
@router.get(
"", # Route relative to prefix "/lists"
response_model=PyList[ListPublic], # Return a list of basic list info
summary="List Accessible Lists",
tags=["Lists"]
)
async def read_lists(
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
# Add pagination parameters later if needed: skip: int = 0, limit: int = 100
):
"""
Retrieves lists accessible to the current user:
- Personal lists created by the user.
- Lists belonging to groups the user is a member of.
"""
logger.info(f"Fetching lists accessible to user: {current_user.email}")
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
return lists
@router.get(
"/{list_id}",
response_model=ListDetail, # Return detailed list info including items
summary="Get List Details",
tags=["Lists"]
)
async def read_list(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""
Retrieves details for a specific list, including its items,
if the user has permission (creator or group member).
"""
logger.info(f"User {current_user.email} requesting details for list ID: {list_id}")
# Use the helper to fetch and check permission simultaneously
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
if not list_db:
# check_list_permission returns None if list not found OR permission denied
# We need to check if the list exists at all to return 404 vs 403
exists = await crud_list.get_list_by_id(db, list_id)
if not exists:
logger.warning(f"List ID {list_id} not found for request by user {current_user.email}.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found")
else:
logger.warning(f"Access denied: User {current_user.email} cannot access list {list_id}.")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not have permission to access this list")
# list_db already has items loaded due to check_list_permission
return list_db
@router.put(
"/{list_id}",
response_model=ListPublic, # Return updated basic info
summary="Update List",
tags=["Lists"]
)
async def update_list(
list_id: int,
list_in: ListUpdate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""
Updates a list's details (name, description, is_complete).
Requires user to be the creator or a member of the list's group.
(MVP: Allows any member to update these fields).
"""
logger.info(f"User {current_user.email} attempting to update list ID: {list_id}")
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
if not list_db:
exists = await crud_list.get_list_by_id(db, list_id)
status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN
detail = "List not found" if not exists else "You do not have permission to update this list"
logger.warning(f"Update failed for list {list_id} by user {current_user.email}: {detail}")
raise HTTPException(status_code=status_code, detail=detail)
# Prevent changing group_id or creator via this endpoint for simplicity
# if list_in.group_id is not None or list_in.created_by_id is not None:
# raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change group or creator via this endpoint")
updated_list = await crud_list.update_list(db=db, list_db=list_db, list_in=list_in)
logger.info(f"List {list_id} updated successfully by user {current_user.email}.")
return updated_list
@router.delete(
"/{list_id}",
status_code=status.HTTP_204_NO_CONTENT, # Standard for successful DELETE with no body
summary="Delete List",
tags=["Lists"]
)
async def delete_list(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""
Deletes a list. Requires user to be the creator of the list.
(Alternatively, could allow group owner).
"""
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}")
# Use the helper, requiring creator permission
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
if not list_db:
exists = await crud_list.get_list_by_id(db, list_id)
status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN
detail = "List not found" if not exists else "Only the list creator can delete this list"
logger.warning(f"Delete failed for list {list_id} by user {current_user.email}: {detail}")
raise HTTPException(status_code=status_code, detail=detail)
await crud_list.delete_list(db=db, list_db=list_db)
logger.info(f"List {list_id} deleted successfully by user {current_user.email}.")
# Return Response with 204 status explicitly if needed, otherwise FastAPI handles it
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/{list_id}/status",
response_model=ListStatus,
summary="Get List Status (for polling)",
tags=["Lists"]
)
async def read_list_status(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""
Retrieves the last update time for the list and its items, plus item count.
Used for polling to check if a full refresh is needed.
Requires user to have permission to view the list.
"""
# Verify user has access to the list first
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
if not list_db:
# Check if list exists at all for correct error code
exists = await crud_list.get_list_by_id(db, list_id)
status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN
detail = "List not found" if not exists else "You do not have permission to access this list's status"
logger.warning(f"Status check failed for list {list_id} by user {current_user.email}: {detail}")
raise HTTPException(status_code=status_code, detail=detail)
# Fetch the status details
list_status = await crud_list.get_list_status(db=db, list_id=list_id)
if not list_status:
# Should not happen if check_list_permission passed, but handle defensively
logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found")
return list_status

67
be/app/crud/item.py Normal file
View File

@ -0,0 +1,67 @@
# app/crud/item.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases
from typing import Optional, List as PyList
from datetime import datetime, timezone
from app.models import Item as ItemModel
from app.schemas.item import ItemCreate, ItemUpdate
async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel:
"""Creates a new item record for a specific list."""
db_item = ItemModel(
name=item_in.name,
quantity=item_in.quantity,
list_id=list_id,
added_by_id=user_id,
is_complete=False # Default on creation
)
db.add(db_item)
await db.commit()
await db.refresh(db_item)
return db_item
async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemModel]:
"""Gets all items belonging to a specific list, ordered by creation time."""
result = await db.execute(
select(ItemModel)
.where(ItemModel.list_id == list_id)
.order_by(ItemModel.created_at.asc()) # Or desc() if preferred
)
return result.scalars().all()
async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
"""Gets a single item by its ID."""
result = await db.execute(select(ItemModel).where(ItemModel.id == item_id))
return result.scalars().first()
async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record."""
update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields
# Special handling for is_complete
if 'is_complete' in update_data:
if update_data['is_complete'] is True:
# Mark as complete: set completed_by_id if not already set
if item_db.completed_by_id is None:
update_data['completed_by_id'] = user_id
else:
# Mark as incomplete: clear completed_by_id
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)
for key, value in update_data.items():
setattr(item_db, key, value)
db.add(item_db) # Add to session to track changes
await db.commit()
await db.refresh(item_db)
return item_db
async def delete_item(db: AsyncSession, item_db: ItemModel) -> None:
"""Deletes an item record."""
await db.delete(item_db)
await db.commit()
return None # Or return True/False

151
be/app/crud/list.py Normal file
View File

@ -0,0 +1,151 @@
# app/crud/list.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import or_, and_, delete as sql_delete # Use alias for delete
from typing import Optional, List as PyList # Use alias for List
from sqlalchemy import func as sql_func, desc # Import func and desc
from app.schemas.list import ListStatus # Import the new schema
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
from app.schemas.list import ListCreate, ListUpdate
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record."""
db_list = ListModel(
name=list_in.name,
description=list_in.description,
group_id=list_in.group_id,
created_by_id=creator_id,
is_complete=False # Default on creation
)
db.add(db_list)
await db.commit()
await db.refresh(db_list)
return db_list
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]:
"""
Gets all lists accessible by a user:
- Personal lists created by the user (group_id is NULL).
- Lists belonging to groups the user is a member of.
"""
# Get IDs of groups the user is a member of
group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
)
user_group_ids = group_ids_result.scalars().all()
# Query for lists
query = select(ListModel).where(
or_(
# Personal lists
and_(ListModel.created_by_id == user_id, ListModel.group_id == None),
# Group lists where user is a member
ListModel.group_id.in_(user_group_ids)
)
).order_by(ListModel.updated_at.desc()) # Order by most recently updated
result = await db.execute(query)
return result.scalars().all()
async def get_list_by_id(db: AsyncSession, list_id: int, load_items: bool = False) -> Optional[ListModel]:
"""Gets a single list by ID, optionally loading its items."""
query = select(ListModel).where(ListModel.id == list_id)
if load_items:
# Eager load items and their creators/completers if needed
query = query.options(
selectinload(ListModel.items)
.options(
joinedload(ItemModel.added_by_user), # Use joinedload for simple FKs
joinedload(ItemModel.completed_by_user)
)
)
result = await db.execute(query)
return result.scalars().first()
async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate) -> ListModel:
"""Updates an existing list record."""
update_data = list_in.model_dump(exclude_unset=True) # Get only provided fields
for key, value in update_data.items():
setattr(list_db, key, value)
db.add(list_db) # Add to session to track changes
await db.commit()
await db.refresh(list_db)
return list_db
async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
"""Deletes a list record."""
# Items should be deleted automatically due to cascade="all, delete-orphan"
# on List.items relationship and ondelete="CASCADE" on Item.list_id FK
await db.delete(list_db)
await db.commit()
return None # Or return True/False if needed
# --- Helper for Permission Checks ---
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> Optional[ListModel]:
"""
Fetches a list and verifies user permission.
Args:
db: Database session.
list_id: The ID of the list to check.
user_id: The ID of the user requesting access.
require_creator: If True, only allows the creator access.
Returns:
The ListModel if found and permission granted, otherwise None.
(Raising exceptions might be better handled in the endpoint).
"""
list_db = await get_list_by_id(db, list_id=list_id, load_items=True) # Load items for detail/update/delete context
if not list_db:
return None # List not found
# Check if user is the creator
is_creator = list_db.created_by_id == user_id
if require_creator:
return list_db if is_creator else None
# If not requiring creator, check membership if it's a group list
if is_creator:
return list_db # Creator always has access
if list_db.group_id:
# Check if user is member of the list's group
from app.crud.group import is_user_member # Avoid circular import at top level
is_member = await is_user_member(db, group_id=list_db.group_id, user_id=user_id)
return list_db if is_member else None
else:
# Personal list, not the creator -> no access
return None
async def get_list_status(db: AsyncSession, list_id: int) -> Optional[ListStatus]:
"""
Gets the update timestamps and item count for a list.
Returns None if the list itself doesn't exist.
"""
# Fetch list updated_at time
list_query = select(ListModel.updated_at).where(ListModel.id == list_id)
list_result = await db.execute(list_query)
list_updated_at = list_result.scalar_one_or_none()
if list_updated_at is None:
return None # List not found
# Fetch the latest item update time and count for that list
item_status_query = (
select(
sql_func.max(ItemModel.updated_at).label("latest_item_updated_at"),
sql_func.count(ItemModel.id).label("item_count")
)
.where(ItemModel.list_id == list_id)
)
item_result = await db.execute(item_status_query)
item_status = item_result.first() # Use first() as aggregate always returns one row
return ListStatus(
list_updated_at=list_updated_at,
latest_item_updated_at=item_status.latest_item_updated_at if item_status else None,
item_count=item_status.item_count if item_status else 0
)

View File

@ -1,7 +1,7 @@
# app/models.py
import enum
import secrets # For generating invite codes
from datetime import datetime, timedelta, timezone # For invite expiry
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import (
Column,
@ -10,20 +10,20 @@ from sqlalchemy import (
DateTime,
ForeignKey,
Boolean,
Enum as SAEnum, # Renamed to avoid clash with Python's enum
Enum as SAEnum,
UniqueConstraint,
Index, # Added for invite code index
Index,
DDL,
event,
delete, # Added for potential cascade delete if needed (though FK handles it)
func, # Added for func.count()
text as sa_text # For raw SQL in index definition if needed
delete,
func,
text as sa_text,
Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price
)
from sqlalchemy.orm import relationship
# Removed func import as it's imported above
# from sqlalchemy.sql import func # For server_default=func.now()
from .database import Base # Import Base from database setup
from .database import Base
# --- Enums ---
class UserRoleEnum(enum.Enum):
@ -36,25 +36,20 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False) # Column name used in CRUD
password_hash = Column(String, nullable=False)
name = Column(String, index=True, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
# Groups created by this user
created_groups = relationship("Group", back_populates="creator") # Links to Group.creator
created_groups = relationship("Group", back_populates="creator")
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
created_invites = relationship("Invite", back_populates="creator")
# Association object for group membership (many-to-many)
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user
# Invites created by this user (one-to-many)
created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator
# Optional relationships for items/lists (Add later)
# added_items = relationship("Item", foreign_keys="[Item.added_by_id]", back_populates="added_by_user")
# completed_items = relationship("Item", foreign_keys="[Item.completed_by_id]", back_populates="completed_by_user")
# expense_shares = relationship("ExpenseShare", back_populates="user")
# created_lists = relationship("List", foreign_keys="[List.created_by_id]", back_populates="creator")
# --- NEW Relationships for Lists/Items ---
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") # Link List.created_by_id -> User
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user") # Link Item.added_by_id -> User
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User
# --- End NEW Relationships ---
# --- Group Model ---
@ -63,78 +58,88 @@ class Group(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User table
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
# The user who created this group (many-to-one)
creator = relationship("User", back_populates="created_groups") # Links to User.created_groups
creator = relationship("User", back_populates="created_groups")
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan")
# Association object for group membership (one-to-many)
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group
# Invites belonging to this group (one-to-many)
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") # Links to Invite.group
# Lists belonging to this group (Add later)
# lists = relationship("List", back_populates="group")
# --- NEW Relationship for Lists ---
lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group
# --- End NEW Relationship ---
# --- UserGroup Association Model (Many-to-Many link) ---
# --- UserGroup Association Model ---
class UserGroup(Base):
__tablename__ = "user_groups"
# Ensure a user cannot be in the same group twice
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
id = Column(Integer, primary_key=True, index=True) # Surrogate primary key
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) # Use Enum, ensure type is created
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member)
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
# Link back to User (many-to-one from the perspective of this table row)
user = relationship("User", back_populates="group_associations") # Links to User.group_associations
# Link back to Group (many-to-one from the perspective of this table row)
group = relationship("Group", back_populates="member_associations") # Links to Group.member_associations
user = relationship("User", back_populates="group_associations")
group = relationship("Group", back_populates="member_associations")
# --- Invite Model ---
class Invite(Base):
__tablename__ = "invites"
# Ensure unique codes *within active invites* using a partial index (PostgreSQL specific)
# If not using PostgreSQL or need simpler logic, a simple unique=True on 'code' works,
# but doesn't allow reusing old codes once deactivated.
__table_args__ = (
Index(
'ix_invites_active_code',
'code',
unique=True,
postgresql_where=sa_text('is_active = true') # Partial index condition
),
Index('ix_invites_active_code', 'code', unique=True, postgresql_where=sa_text('is_active = true')),
)
id = Column(Integer, primary_key=True, index=True)
# Generate a secure random code by default
code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) # Index helps lookup, uniqueness handled by partial index
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User
code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16))
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Set default expiry (e.g., 7 days from creation)
expires_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + timedelta(days=7))
is_active = Column(Boolean, default=True, nullable=False) # To mark as used/invalid
is_active = Column(Boolean, default=True, nullable=False)
group = relationship("Group", back_populates="invites")
creator = relationship("User", back_populates="created_invites")
# === NEW: List Model ===
class List(Base):
__tablename__ = "lists"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who created this list
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # Which group it belongs to (NULL if personal)
is_complete = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
# Link back to the Group this invite is for (many-to-one)
group = relationship("Group", back_populates="invites") # Links to Group.invites
# Link back to the User who created the invite (many-to-one)
creator = relationship("User", back_populates="created_invites") # Links to User.created_invites
creator = relationship("User", back_populates="created_lists") # Link to User.created_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
# --- Models for Lists, Items, Expenses (Add later) ---
# class List(Base): ...
# class Item(Base): ...
# class Expense(Base): ...
# class ExpenseShare(Base): ...
# === NEW: Item Model ===
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list
name = Column(String, index=True, nullable=False)
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99)
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
list = relationship("List", back_populates="items") # Link to List.items
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items

34
be/app/schemas/item.py Normal file
View File

@ -0,0 +1,34 @@
# app/schemas/item.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
from decimal import Decimal
# Properties to return to client
class ItemPublic(BaseModel):
id: int
list_id: int
name: str
quantity: Optional[str] = None
is_complete: bool
price: Optional[Decimal] = None
added_by_id: int
completed_by_id: Optional[int] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Properties to receive via API on creation
class ItemCreate(BaseModel):
name: str
quantity: Optional[str] = None
# list_id will be from path param
# added_by_id will be from current_user
# Properties to receive via API on update
class ItemUpdate(BaseModel):
name: Optional[str] = None
quantity: Optional[str] = None
is_complete: Optional[bool] = None
price: Optional[Decimal] = None # Price added here for update
# completed_by_id will be set internally if is_complete is true

45
be/app/schemas/list.py Normal file
View File

@ -0,0 +1,45 @@
# app/schemas/list.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional, List
from .item import ItemPublic # Import item schema for nesting
# Properties to receive via API on creation
class ListCreate(BaseModel):
name: str
description: Optional[str] = None
group_id: Optional[int] = None # Optional for sharing
# Properties to receive via API on update
class ListUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_complete: Optional[bool] = None
# Potentially add group_id update later if needed
# Base properties returned by API (common fields)
class ListBase(BaseModel):
id: int
name: str
description: Optional[str] = None
created_by_id: int
group_id: Optional[int] = None
is_complete: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Properties returned when listing lists (no items)
class ListPublic(ListBase):
pass # Inherits all from ListBase
# Properties returned for a single list detail (includes items)
class ListDetail(ListBase):
items: List[ItemPublic] = [] # Include list of items
class ListStatus(BaseModel):
list_updated_at: datetime
latest_item_updated_at: Optional[datetime] = None # Can be null if list has no items
item_count: int

9
fe/package-lock.json generated
View File

@ -7,6 +7,9 @@
"": {
"name": "fe",
"version": "0.0.1",
"dependencies": {
"idb": "^8.0.2"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.16.0",
@ -1527,6 +1530,12 @@
"node": ">= 0.4"
}
},
"node_modules/idb": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.2.tgz",
"integrity": "sha512-CX70rYhx7GDDQzwwQMDwF6kDRQi5vVs6khHUumDrMecBylKkwvZ8HWvKV08AGb7VbpoGCWUQ4aHzNDgoUiOIUg==",
"license": "ISC"
},
"node_modules/import-meta-resolve": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",

View File

@ -27,5 +27,8 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"idb": "^8.0.2"
}
}

View File

@ -0,0 +1,318 @@
<!-- src/lib/components/ItemDisplay.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { ItemPublic, ItemUpdate } from '$lib/schemas/item';
// --- DB and Sync Imports ---
import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db';
import { processSyncQueue } from '$lib/syncService';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/authStore'; // Get current user ID
import { get } from 'svelte/store'; // Import get
// --- End DB and Sync Imports ---
export let item: ItemPublic;
const dispatch = createEventDispatcher<{
itemUpdated: ItemPublic; // Event when item is successfully updated (toggle/edit)
itemDeleted: number; // Event when item is successfully deleted (sends ID)
updateError: string; // Event to bubble up errors
}>();
// --- Component State ---
let isEditing = false;
let isToggling = false;
let isDeleting = false;
let isSavingEdit = false;
// State for edit form
let editName = '';
let editQuantity = '';
// --- Edit Mode ---
function startEdit() {
if (isEditing) return;
editName = item.name;
editQuantity = item.quantity ?? '';
isEditing = true;
dispatch('updateError', ''); // Clear previous errors when starting edit
}
function cancelEdit() {
isEditing = false;
dispatch('updateError', ''); // Clear errors on cancel too
}
// --- API Interactions (Modified for Offline) ---
async function handleToggleComplete() {
if (isToggling || isEditing) return;
isToggling = true;
dispatch('updateError', '');
const newStatus = !item.is_complete;
const updateData: ItemUpdate = { is_complete: newStatus };
const currentUserId = get(authStore).user?.id; // Get user ID synchronously
// 1. Optimistic DB Update (UI update delegated to parent via event)
const optimisticItem = {
...item,
is_complete: newStatus,
// Set completed_by_id based on new status and current user
completed_by_id: newStatus ? (currentUserId ?? item.completed_by_id ?? null) : null,
updated_at: new Date().toISOString() // Update timestamp locally
};
try {
await putItemToDb(optimisticItem);
dispatch('itemUpdated', optimisticItem); // Dispatch optimistic update immediately
} catch (dbError) {
console.error('Optimistic toggle DB update failed:', dbError);
dispatch('updateError', 'Failed to save state locally.');
isToggling = false;
return; // Stop if DB update fails
}
// 2. Queue or Send API Call
console.log(`Toggling item ${item.id} to ${newStatus}`);
try {
if (browser && !navigator.onLine) {
// OFFLINE: Queue action
console.log(`Offline: Queuing update for item ${item.id}`);
await addSyncAction({
type: 'update_item',
payload: { id: item.id, data: updateData },
timestamp: Date.now()
});
} else {
// ONLINE: Send API call directly
const updatedItemFromServer = await apiClient.put<ItemPublic>(
`/v1/items/${item.id}`,
updateData
);
// Update DB and dispatch again with potentially more accurate server data
await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer);
}
// Trigger sync if online after queuing or direct call
if (browser && navigator.onLine) processSyncQueue();
} catch (err) {
console.error(`Toggle item ${item.id} failed:`, err);
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Toggle failed';
dispatch('updateError', errorMsg);
// TODO: Consider reverting optimistic update on error? More complex.
// For now, just show error. User might need to manually fix state or refresh.
} finally {
isToggling = false;
}
}
async function handleSaveEdit() {
if (!editName.trim()) {
dispatch('updateError', 'Item name cannot be empty.');
return;
}
if (isSavingEdit) return;
isSavingEdit = true;
dispatch('updateError', '');
const updateData: ItemUpdate = {
name: editName.trim(),
quantity: editQuantity.trim() || undefined // Send undefined if empty
};
// 1. Optimistic DB / UI
const optimisticItem = {
...item,
name: updateData.name!,
quantity: updateData.quantity ?? null,
updated_at: new Date().toISOString()
};
try {
await putItemToDb(optimisticItem);
dispatch('itemUpdated', optimisticItem);
} catch (dbError) {
console.error('Optimistic edit DB update failed:', dbError);
dispatch('updateError', 'Failed to save state locally.');
isSavingEdit = false;
return;
}
// 2. Queue or Send API Call
console.log(`Saving edits for item ${item.id}`, updateData);
try {
if (browser && !navigator.onLine) {
console.log(`Offline: Queuing update for item ${item.id}`);
await addSyncAction({
type: 'update_item',
payload: { id: item.id, data: updateData },
timestamp: Date.now()
});
} else {
const updatedItemFromServer = await apiClient.put<ItemPublic>(
`/v1/items/${item.id}`,
updateData
);
await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer); // Update with server data
}
if (browser && navigator.onLine) processSyncQueue();
isEditing = false; // Exit edit mode on success
} catch (err) {
console.error(`Save edit for item ${item.id} failed:`, err);
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Save failed';
dispatch('updateError', errorMsg);
// TODO: Revert optimistic update?
} finally {
isSavingEdit = false;
}
}
async function handleDelete() {
if (isDeleting || isEditing) return;
if (!confirm(`Are you sure you want to delete item "${item.name}"?`)) {
return;
}
isDeleting = true;
dispatch('updateError', '');
const itemIdToDelete = item.id;
// 1. Optimistic DB / UI
try {
await deleteItemFromDb(itemIdToDelete);
dispatch('itemDeleted', itemIdToDelete); // Notify parent immediately
} catch (dbError) {
console.error('Optimistic delete DB update failed:', dbError);
dispatch('updateError', 'Failed to delete item locally.');
isDeleting = false;
return;
}
// 2. Queue or Send API Call
console.log(`Deleting item ${itemIdToDelete}`);
try {
if (browser && !navigator.onLine) {
console.log(`Offline: Queuing delete for item ${itemIdToDelete}`);
await addSyncAction({
type: 'delete_item',
payload: { id: itemIdToDelete },
timestamp: Date.now()
});
} else {
await apiClient.delete(`/v1/items/${itemIdToDelete}`);
}
if (browser && navigator.onLine) processSyncQueue();
// Component will be destroyed by parent on success
} catch (err) {
console.error(`Delete item ${itemIdToDelete} failed:`, err);
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Delete failed';
dispatch('updateError', errorMsg);
// If API delete failed, the item was already removed from UI/DB optimistically.
// User may need to refresh to see it again if the delete wasn't valid server-side.
// For MVP, just show the error.
isDeleting = false; // Reset loading state only on error
}
}
</script>
<!-- TEMPLATE -->
<li
class="flex items-center justify-between gap-4 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50"
class:border-gray-200={!isEditing}
class:border-blue-400={isEditing}
class:opacity-60={item.is_complete && !isEditing}
>
{#if isEditing}
<!-- Edit Mode Form -->
<form on:submit|preventDefault={handleSaveEdit} class="flex flex-grow items-center gap-2">
<input
type="text"
bind:value={editName}
required
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isSavingEdit}
aria-label="Edit item name"
/>
<input
type="text"
bind:value={editQuantity}
placeholder="Qty (opt.)"
class="w-20 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isSavingEdit}
aria-label="Edit item quantity"
/>
<button
type="submit"
class="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700 disabled:opacity-50"
disabled={isSavingEdit}
aria-label="Save changes"
>
{isSavingEdit ? '...' : 'Save'}
</button>
<button
type="button"
on:click={cancelEdit}
class="rounded bg-gray-500 px-2 py-1 text-xs text-white hover:bg-gray-600"
disabled={isSavingEdit}
aria-label="Cancel edit"
>
Cancel
</button>
</form>
{:else}
<!-- Display Mode -->
<div class="flex flex-grow items-center gap-3 overflow-hidden">
<input
type="checkbox"
checked={item.is_complete}
disabled={isToggling || isDeleting}
aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}"
class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
on:change={handleToggleComplete}
/>
<div class="flex-grow overflow-hidden">
<span
class="block truncate font-medium text-gray-800"
class:line-through={item.is_complete}
class:text-gray-500={item.is_complete}
title={item.name}
>
{item.name}
</span>
{#if item.quantity}
<span
class="block truncate text-sm text-gray-500"
class:line-through={item.is_complete}
title={item.quantity}
>
Qty: {item.quantity}
</span>
{/if}
</div>
</div>
<div class="flex flex-shrink-0 items-center space-x-2">
<button
on:click={startEdit}
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-200 hover:text-gray-700"
title="Edit Item"
disabled={isToggling || isDeleting}
>
✏️
</button>
<button
on:click={handleDelete}
class="rounded p-1 text-xs text-red-400 hover:bg-red-100 hover:text-red-600"
title="Delete Item"
disabled={isToggling || isDeleting}
>
{#if isDeleting}{:else}🗑️{/if}
</button>
</div>
{/if}
</li>

View File

@ -0,0 +1,201 @@
<!-- src/lib/components/ListForm.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { GroupPublic } from '$lib/schemas/group';
import type { ListPublic, ListCreate, ListUpdate } from '$lib/schemas/list'; // Import necessary types
// Props
/** Optional existing list data for editing */
export let list: ListPublic | null = null;
/** Array of user's groups for the dropdown */
export let groups: GroupPublic[] = [];
/** Optional error message passed from parent (e.g., load error) */
export let apiError: string | null = null;
// Form State
let name = '';
let description = '';
let selectedGroupId: string = 'null'; // Use 'null' string for the "Personal" option value
let isLoading = false;
let errorMessage: string | null = null;
let successMessage: string | null = null;
// Determine mode and initialize form
let isEditMode = false;
$: {
// Reactive block: runs when props change
isEditMode = !!list;
// Reset form when list prop changes (navigating between edit pages)
// or initialize for creation
name = list?.name ?? '';
description = list?.description ?? '';
// Set dropdown: if list has group_id, convert to string; otherwise, use 'null' string
selectedGroupId = list?.group_id != null ? String(list.group_id) : 'null';
errorMessage = null; // Clear errors on list change
successMessage = null;
isLoading = false;
console.log('ListForm initialized. Edit mode:', isEditMode, 'List:', list);
}
// Update local error if apiError prop changes
$: if (apiError) errorMessage = apiError;
async function handleSubmit() {
if (!name.trim()) {
errorMessage = 'List name cannot be empty.';
return;
}
isLoading = true;
errorMessage = null;
successMessage = null;
// Prepare data based on create or edit mode
const requestBody: ListCreate | ListUpdate = {
name: name.trim(),
description: description.trim() || undefined // Send undefined if empty
// Only include group_id for creation, not typically editable this way
// For edit, we'd usually handle 'is_complete' if needed, but not group_id change here
};
if (!isEditMode) {
(requestBody as ListCreate).group_id =
selectedGroupId === 'null' ? null : parseInt(selectedGroupId, 10);
}
// If editing, you might add other updatable fields like is_complete
// if (isEditMode) {
// (requestBody as ListUpdate).is_complete = someCheckboxValue;
// }
console.log(`Submitting list data (${isEditMode ? 'Edit' : 'Create'}):`, requestBody);
try {
let resultList: ListPublic;
if (isEditMode && list) {
// PUT request for updating
resultList = await apiClient.put<ListPublic>(`/v1/lists/${list.id}`, requestBody);
successMessage = `List "${resultList.name}" updated successfully!`;
} else {
// POST request for creating
resultList = await apiClient.post<ListPublic>('/v1/lists', requestBody);
successMessage = `List "${resultList.name}" created successfully!`;
}
console.log('List submission successful:', resultList);
// Redirect after a short delay to show success message
setTimeout(async () => {
// Redirect to dashboard after create/edit
await goto('/dashboard');
// Or redirect to the list detail page after edit?
// if (isEditMode) await goto(`/groups/${resultList.id}`); // Need group detail route
}, 1000); // 1 second delay
} catch (err) {
console.error(`List ${isEditMode ? 'update' : 'creation'} failed:`, err);
if (err instanceof ApiClientError) {
let detail = `Failed to ${isEditMode ? 'update' : 'create'} list.`;
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
detail = (err.errorData as { detail: string }).detail; // Use 'as' assertion
}
errorMessage = `Error (${err.status}): ${detail}`;
} else if (err instanceof Error) {
errorMessage = `Error: ${err.message}`;
} else {
errorMessage = 'An unexpected error occurred.';
}
isLoading = false; // Ensure loading stops on error
}
// No finally needed here as success leads to navigation
}
</script>
<form on:submit|preventDefault={handleSubmit} class="space-y-4 rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">
{isEditMode ? 'Edit List' : 'Create New List'}
</h2>
{#if successMessage}
<div
class="mb-4 rounded border border-green-400 bg-green-100 p-3 text-center text-sm text-green-700"
role="status"
>
{successMessage} Redirecting...
</div>
{/if}
{#if errorMessage}
<div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
role="alert"
>
{errorMessage}
</div>
{/if}
<div>
<label for="list-name" class="mb-1 block text-sm font-medium text-gray-600">List Name</label>
<input
type="text"
id="list-name"
bind:value={name}
required
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
disabled={isLoading || !!successMessage}
/>
</div>
<div>
<label for="list-description" class="mb-1 block text-sm font-medium text-gray-600"
>Description (Optional)</label
>
<!-- Corrected textarea tag -->
<textarea
id="list-description"
bind:value={description}
rows="3"
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
disabled={isLoading || !!successMessage}
></textarea>
<!-- Ensure closing tag -->
</div>
<!-- Only show group selector in create mode -->
{#if !isEditMode}
<div>
<label for="list-group" class="mb-1 block text-sm font-medium text-gray-600"
>Share with Group (Optional)</label
>
<select
id="list-group"
bind:value={selectedGroupId}
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
disabled={isLoading || !!successMessage}
>
<option value="null">Personal (No Group)</option>
{#each groups as group (group.id)}
<option value={String(group.id)}>{group.name}</option>
{/each}
</select>
{#if groups.length === 0}
<p class="mt-1 text-xs text-gray-500">You are not a member of any groups to share with.</p>
{/if}
</div>
{/if}
<div class="flex items-center justify-end space-x-3 pt-2">
<a href="/dashboard" class="text-sm text-gray-600 hover:underline">Cancel</a>
<button
type="submit"
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white transition duration-150 ease-in-out 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"
disabled={isLoading || !!successMessage}
>
{#if isLoading}
Saving...
{:else if isEditMode}
Save Changes
{:else}
Create List
{/if}
</button>
</div>
</form>

195
fe/src/lib/db.ts Normal file
View File

@ -0,0 +1,195 @@
// src/lib/db.ts
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
import type { ListDetail, ListPublic } from './schemas/list'; // Import your list types
import type { ItemPublic } from './schemas/item'; // Import your item type
const DB_NAME = 'SharedListsDB';
const DB_VERSION = 1; // Increment this when changing schema
// Define the structure for queued actions
export interface SyncAction {
id?: number; // Optional: will be added by IndexedDB autoIncrement
type: 'create_list' | 'update_list' | 'delete_list' | 'create_item' | 'update_item' | 'delete_item';
payload: any; // Data needed for the API call (e.g., listId, itemId, updateData)
timestamp: number;
tempId?: string; // Optional temporary ID for optimistic UI mapping (e.g., for newly created items)
}
// Define the database schema using TypeScript interface
interface SharedListsDBSchema extends DBSchema {
lists: {
key: number; // Primary key (list.id)
value: ListDetail; // Store full detail including items
indexes: Record<string, string>; // Example indexes
};
items: {
key: number; // Primary key (item.id)
value: ItemPublic;
indexes: Record<string, string>; // Index by listId is crucial
};
syncQueue: {
key: number; // Auto-incrementing key
value: SyncAction;
// No indexes needed for simple queue processing
};
}
let dbPromise: Promise<IDBPDatabase<SharedListsDBSchema>> | null = null;
/** Gets the IndexedDB database instance, creating/upgrading if necessary. */
function getDb(): Promise<IDBPDatabase<SharedListsDBSchema>> {
if (!dbPromise) {
dbPromise = openDB<SharedListsDBSchema>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, newVersion, transaction, event) {
console.log(`Upgrading DB from version ${oldVersion} to ${newVersion}`);
// Create 'lists' store if it doesn't exist
if (!db.objectStoreNames.contains('lists')) {
const listStore = db.createObjectStore('lists', { keyPath: 'id' });
listStore.createIndex('groupId', 'group_id'); // Index for potential filtering by group
listStore.createIndex('updated_at', 'updated_at'); // Index for sorting/filtering by date
console.log('Created lists object store');
}
// Create 'items' store if it doesn't exist
if (!db.objectStoreNames.contains('items')) {
const itemStore = db.createObjectStore('items', { keyPath: 'id' });
// Crucial index for fetching items belonging to a list
itemStore.createIndex('listId', 'list_id');
itemStore.createIndex('updated_at', 'updated_at'); // Index for sorting/filtering by date
console.log('Created items object store');
}
// Create 'syncQueue' store if it doesn't exist
if (!db.objectStoreNames.contains('syncQueue')) {
// Use autoIncrementing key
db.createObjectStore('syncQueue', { autoIncrement: true, keyPath: 'id' });
console.log('Created syncQueue object store');
}
// --- Handle specific version upgrades ---
// Example: If upgrading from version 1 to 2
// if (oldVersion < 2) {
// // Make changes needed for version 2
// const listStore = transaction.objectStore('lists');
// // listStore.createIndex('newIndex', 'newField');
// }
// if (oldVersion < 3) { ... }
},
blocked(currentVersion, blockedVersion, event) {
// Fires if an older version of the DB is open in another tab/window
console.error(`IndexedDB blocked. Current: ${currentVersion}, Blocked: ${blockedVersion}. Close other tabs.`);
alert('Database update blocked. Please close other tabs/windows using this app and refresh.');
},
blocking(currentVersion, blockedVersion, event) {
// Fires in the older tab/window that is blocking the upgrade
console.warn(`IndexedDB blocking upgrade. Current: ${currentVersion}, Upgrade: ${blockedVersion}. Closing connection.`);
// Attempt to close the connection in the blocking tab
// db.close(); // 'db' is not available here, need to handle differently if required
},
terminated() {
// Fires if the browser abruptly terminates the connection (e.g., OS shutdown)
console.error('IndexedDB connection terminated unexpectedly.');
dbPromise = null; // Reset promise to allow reconnection attempt
},
});
}
return dbPromise;
}
// --- List CRUD Operations ---
/** Gets a single list (including items) from IndexedDB by ID. */
export async function getListFromDb(id: number): Promise<ListDetail | undefined> {
const db = await getDb();
return db.get('lists', id);
}
/** Gets all lists stored in IndexedDB. */
export async function getAllListsFromDb(): Promise<ListDetail[]> {
const db = await getDb();
// Consider adding sorting or filtering here if needed
return db.getAll('lists');
}
/** Adds or updates a list in IndexedDB. */
export async function putListToDb(list: ListDetail | ListPublic): Promise<number> {
const db = await getDb();
// Ensure items array exists, even if empty, for ListDetail type consistency
const listToStore: ListDetail = {
...list,
items: (list as ListDetail).items ?? [] // Default to empty array if items missing
};
return db.put('lists', listToStore);
}
/** Deletes a list and its associated items from IndexedDB. */
export async function deleteListFromDb(id: number): Promise<void> {
const db = await getDb();
// Use a transaction to delete list and its items atomically
const tx = db.transaction(['lists', 'items'], 'readwrite');
const listStore = tx.objectStore('lists');
const itemStore = tx.objectStore('items');
const itemIndex = itemStore.index('listId'); // Use the index
// Delete the list itself
await listStore.delete(id);
// Find and delete all items associated with the list
let cursor = await itemIndex.openCursor(id.toString()); // Open cursor on the index with the listId
while (cursor) {
await cursor.delete(); // Delete the item the cursor points to
cursor = await cursor.continue(); // Move to the next item with the same listId
}
await tx.done; // Complete the transaction
console.log(`Deleted list ${id} and its items from DB.`);
}
// --- Item CRUD Operations ---
/** Gets a single item from IndexedDB by ID. */
export async function getItemFromDb(id: number): Promise<ItemPublic | undefined> {
const db = await getDb();
return db.get('items', id);
}
/** Gets all items for a specific list from IndexedDB using the index. */
export async function getItemsByListIdFromDb(listId: number): Promise<ItemPublic[]> {
const db = await getDb();
return db.getAllFromIndex('items', 'listId', listId.toString());
}
/** Adds or updates an item in IndexedDB. */
export async function putItemToDb(item: ItemPublic): Promise<number> {
const db = await getDb();
return db.put('items', item);
}
/** Deletes an item from IndexedDB by ID. */
export async function deleteItemFromDb(id: number): Promise<void> {
const db = await getDb();
return db.delete('items', id);
}
// --- Sync Queue Operations ---
/** Adds an action to the synchronization queue. */
export async function addSyncAction(action: Omit<SyncAction, 'id'>): Promise<number> {
const db = await getDb();
// Add the action (payload should be serializable)
return db.add('syncQueue', action);
}
/** Retrieves all actions currently in the synchronization queue. */
export async function getSyncQueue(): Promise<SyncAction[]> {
const db = await getDb();
// Fetch all items, default order is by key (insertion order)
return db.getAll('syncQueue');
}
/** Deletes a specific action from the synchronization queue by its ID. */
export async function deleteSyncAction(id: number): Promise<void> {
const db = await getDb();
return db.delete('syncQueue', id);
}

View File

@ -0,0 +1,27 @@
// Ensure this interface is exported
export interface ItemPublic {
id: number;
list_id: number;
name: string;
quantity?: string | null;
is_complete: boolean;
price?: number | null; // Or Decimal if using a library
added_by_id: number;
completed_by_id?: number | null;
created_at: string;
updated_at: string;
}
export interface ItemCreate {
name: string;
quantity?: string | null;
}
export interface ItemUpdate {
name?: string | null;
quantity?: string | null;
is_complete?: boolean | null;
price?: number | null; // Using number
}

View File

@ -0,0 +1,35 @@
import type { ItemPublic } from './item'; // Assuming item schema exists and is exported
export interface ListBase {
id: number;
name: string;
description?: string | null;
created_by_id: number;
group_id?: number | null;
is_complete: boolean;
created_at: string;
updated_at: string;
}
// Export interfaces to make the file a module
export interface ListPublic extends ListBase { }
export interface ListDetail extends ListBase {
items: ItemPublic[];
}
export interface ListCreate {
name: string;
description?: string | null;
group_id?: number | null;
}
export interface ListUpdate {
name?: string | null;
description?: string | null;
is_complete?: boolean | null;
}
export interface ListStatus {
list_updated_at: string; // Expect string from JSON
latest_item_updated_at?: string | null; // Expect string or null from JSON
item_count: number;
}

154
fe/src/lib/syncService.ts Normal file
View File

@ -0,0 +1,154 @@
// src/lib/syncService.ts
import { browser } from '$app/environment';
import { getSyncQueue, deleteSyncAction } from './db'; // Import DB functions
import { apiClient, ApiClientError } from './apiClient'; // Import API client
import { writable, get } from 'svelte/store'; // Import get for reading store value
// Store for sync status feedback
export const syncStatus = writable<'idle' | 'syncing' | 'error'>('idle');
export const syncError = writable<string | null>(null);
let isSyncing = false; // Prevent concurrent sync runs
/**
* Processes the offline synchronization queue.
* Fetches actions from IndexedDB and attempts to send them to the API.
* Removes successful actions, handles basic errors/conflicts.
*/
export async function processSyncQueue() {
// Run only in browser, when online, and if not already syncing
if (!browser || !navigator.onLine || isSyncing) {
if (isSyncing) console.log('Sync: Already in progress, skipping.');
return;
}
isSyncing = true;
syncStatus.set('syncing');
syncError.set(null); // Clear previous errors
console.log('Sync: Starting queue processing...');
try {
const queue = await getSyncQueue();
console.log(`Sync: Found ${queue.length} actions in queue.`);
if (queue.length === 0) {
syncStatus.set('idle');
isSyncing = false;
return; // Nothing to do
}
// Process actions one by one (sequential processing)
for (const action of queue) {
// Should always have an ID from IndexedDB autoIncrement
if (!action.id) {
console.error("Sync: Action missing ID, skipping.", action);
continue;
}
console.log(`Sync: Processing action ID ${action.id}, Type: ${action.type}`);
let success = false;
try {
// --- Perform API call based on action type ---
switch (action.type) {
case 'create_list':
await apiClient.post('/v1/lists', action.payload);
// TODO: Handle mapping tempId if used
break;
case 'update_list':
// Assuming payload is { id: listId, data: ListUpdate }
await apiClient.put(`/v1/lists/${action.payload.id}`, action.payload.data);
break;
case 'delete_list':
// Assuming payload is { id: listId }
await apiClient.delete(`/v1/lists/${action.payload.id}`);
break;
case 'create_item':
// Assuming payload is { listId: number, data: ItemCreate }
await apiClient.post(`/v1/lists/${action.payload.listId}/items`, action.payload.data);
// TODO: Handle mapping tempId if used
break;
case 'update_item':
// Assuming payload is { id: itemId, data: ItemUpdate }
await apiClient.put(`/v1/items/${action.payload.id}`, action.payload.data);
break;
case 'delete_item':
// Assuming payload is { id: itemId }
await apiClient.delete(`/v1/items/${action.payload.id}`);
break;
default:
console.error(`Sync: Unknown action type: ${(action as any).type}`);
// Optionally treat as error or just skip
throw new Error(`Unknown sync action type: ${(action as any).type}`);
}
success = true; // Mark as successful if API call didn't throw
console.log(`Sync: Action ID ${action.id} (${action.type}) successful.`);
// Remove from queue ONLY on definite success
await deleteSyncAction(action.id);
} catch (err: any) {
console.error(`Sync: Failed to process action ID ${action.id} (${action.type})`, err);
// --- Basic Conflict/Error Handling ---
let errorHandled = false;
if (err instanceof ApiClientError) {
if (err.status === 409) { // Example: Conflict
syncError.set(`Sync conflict for ${action.type} (ID: ${action.payload?.id ?? 'N/A'}). Data may be outdated. Please refresh.`);
// Remove conflicting action from queue - requires manual refresh/resolution by user
await deleteSyncAction(action.id);
errorHandled = true;
} else if (err.status >= 400 && err.status < 500 && err.status !== 401) {
// Other client errors (400 Bad Request, 403 Forbidden, 404 Not Found)
// Often mean the action is invalid now (e.g., deleting something already deleted).
syncError.set(`Sync failed for ${action.type} (Error ${err.status}). Action discarded.`);
await deleteSyncAction(action.id);
errorHandled = true;
}
// Note: 401 Unauthorized is handled globally by apiClient, which calls logout.
// Sync might stop if token becomes invalid mid-process.
}
if (!errorHandled) {
// Network error or Server error (5xx) - Keep in queue and stop processing for now
syncError.set(`Sync failed for ${action.type}. Will retry later.`);
syncStatus.set('error'); // Indicate sync stopped due to error
isSyncing = false; // Allow retry later
return; // Stop processing the rest of the queue
}
}
} // End for loop
// If loop completed without critical errors
console.log('Sync: Queue processing finished.');
syncStatus.set('idle'); // Reset status if all processed or handled
} catch (outerError) {
// Catch errors during queue fetching or unexpected issues in the loop
console.error("Sync: Critical error during queue processing loop.", outerError);
syncError.set("An unexpected error occurred during synchronization.");
syncStatus.set('error');
} finally {
isSyncing = false; // Ensure this is always reset
// If an error occurred and wasn't handled by stopping, ensure status reflects it
if (get(syncError) && get(syncStatus) !== 'error') {
syncStatus.set('error');
}
}
}
// --- Initialize Sync ---
// Listen for online event to trigger sync
if (browser) {
window.addEventListener('online', processSyncQueue);
// Trigger sync shortly after app loads if online
if (navigator.onLine) {
setTimeout(processSyncQueue, 3000); // Delay 3s
}
}
// Optional: Add function to manually trigger sync if needed from UI
export function triggerSync() {
console.log("Sync: Manual trigger requested.");
processSyncQueue();
}

View File

@ -64,7 +64,16 @@
</script>
<div class="space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-800">Your Groups</h1>
<!-- Link to create list page -->
<a
href="/lists/new"
class="rounded bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
+ Create New List
</a>
</div>
<!-- Group Creation Section -->
<div class="rounded bg-white p-6 shadow">
@ -95,6 +104,47 @@
{/if}
</div>
<div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Lists</h2>
{#if loadError}
<div class="rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
<p class="font-bold">Error Loading Data</p>
<p>{loadError}</p>
</div>
{:else if !data.lists || data.lists.length === 0}
<p class="text-gray-500">You haven't created any lists yet.</p>
{:else}
<ul class="space-y-3">
{#each data.lists as list (list.id)}
<li
class="rounded border border-gray-200 p-4 transition duration-150 ease-in-out hover:bg-gray-50"
>
<div class="flex items-center justify-between gap-4">
<div>
<!-- Make name a link -->
<a
href="/lists/{list.id}"
class="font-medium text-gray-800 hover:text-blue-600 hover:underline"
>
{list.name}
</a>
<!-- ... (shared/personal tags) ... -->
</div>
<div class="flex space-x-3">
<!-- Link to Edit Page -->
<a href="/lists/{list.id}/edit" class="text-sm text-yellow-600 hover:underline"
>Edit</a
>
<!-- Add Delete button later -->
</div>
</div>
<!-- ... (description, updated date) ... -->
</li>
{/each}
</ul>
{/if}
</div>
<!-- Group List Section -->
<div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Groups</h2>

View File

@ -3,6 +3,7 @@ import { error } from '@sveltejs/kit';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type
import type { PageLoad } from './$types'; // SvelteKit's type for load functions
import type { ListPublic } from '$lib/schemas/list';
// Define the expected shape of the data returned by this load function
export interface DashboardLoadData {
@ -18,9 +19,11 @@ export const load: PageLoad<DashboardLoadData> = async ({ fetch }) => {
console.log('Dashboard page load: Fetching groups...');
try {
const groups = await apiClient.get<GroupPublic[]>('/v1/groups'); // apiClient adds auth header
const lists = await apiClient.get<ListPublic[]>('/v1/lists'); // apiClient adds auth header
console.log('Dashboard page load: Groups fetched successfully', groups);
return {
groups: groups ?? [], // Return empty array if API returns null/undefined
lists: lists ?? [],
error: null
};
} catch (err) {

View File

@ -0,0 +1,475 @@
<!-- src/routes/(app)/lists/[listId]/+page.svelte -->
<script lang="ts">
// Svelte/SvelteKit Imports
import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte';
import type { PageData } from '../$types';
// Component Imports
import ItemDisplay from '$lib/components/ItemDisplay.svelte';
// Utility/Store Imports
import { apiClient, ApiClientError } from '$lib/apiClient';
import { authStore } from '$lib/stores/authStore'; // Get current user ID
import { get, writable } from 'svelte/store'; // For local reactive list state
// Schema Imports
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
import type { ListDetail, ListStatus } from '$lib/schemas/list';
// --- DB and Sync Imports ---
import {
getListFromDb,
getItemsByListIdFromDb,
putListToDb,
putItemToDb,
deleteItemFromDb,
addSyncAction
} from '$lib/db';
import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService';
import { browser } from '$app/environment';
// --- End DB and Sync Imports ---
// --- Props ---
export let data: PageData; // Contains initial { list: ListDetail } from server/cache/load
// --- Local Reactive State ---
// Use a writable store locally to manage the list and items for easier updates
// Initialize with data from SSR/load function as fallback
const localListStore = writable<ListDetail | null>(data.list);
// --- Add Item State ---
let newItemName = '';
let newItemQuantity = '';
let isAddingItem = false;
let addItemError: string | null = null;
// --- General Item Error Display ---
let itemUpdateError: string | null = null;
let itemErrorTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
// --- Polling State ---
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
let lastKnownStatus: {
// Ensure this stores Date objects or null
list_updated_at: Date;
latest_item_updated_at: Date | null;
item_count: number;
} | null = null;
let isRefreshing = false;
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds
// --- Lifecycle ---
onMount(() => {
let isMounted = true;
(async () => {
let listId: number | null = null;
try {
listId = parseInt($page.params.listId, 10);
} catch {
/* ignore parsing error */
}
if (!listId) {
console.error('List Detail Mount: Invalid or missing listId in params.');
// Optionally redirect or show permanent error
return;
}
// 1. Load from IndexedDB first for faster initial display/offline
if (browser) {
console.log('List Detail Mount: Loading from IndexedDB for list', listId);
const listFromDb = await getListFromDb(listId);
if (listFromDb) {
console.log('List Detail Mount: Found list in DB', listFromDb);
// Items should be part of ListDetail object store
if (isMounted) {
localListStore.set(listFromDb);
initializePollingStatus(listFromDb);
}
} else {
console.log('List Detail Mount: List not found in DB, using SSR/load data.');
if (isMounted) {
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 (navigator.onLine) {
console.log('List Detail Mount: Online, fetching fresh data...');
fetchAndUpdateList(listId); // Don't await, let it run in background
// Also trigger sync queue processing
processSyncQueue(); // Don't await
}
// 3. Start polling
startPolling();
} else {
// Server side: Use data from load function directly
if (isMounted) {
localListStore.set(data.list);
initializePollingStatus(data.list);
}
}
})();
return () => {
isMounted = false;
stopPolling();
clearTimeout(itemErrorTimeout);
};
});
// Helper to fetch from API and update DB + Store
async function fetchAndUpdateList(listId: number) {
isRefreshing = true;
try {
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
await putListToDb(freshList); // Update IndexedDB
localListStore.set(freshList); // Update the UI store
// No need to re-initialize polling status here, checkListStatus will update it
console.log('List Detail: Fetched and updated list', listId);
} catch (err) {
console.error('List Detail: Failed to fetch fresh list data', err);
handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
);
} finally {
isRefreshing = false;
}
}
// Helper to initialize polling status from ListDetail data
function initializePollingStatus(listData: ListDetail | null) {
if (!listData) {
lastKnownStatus = null;
return;
}
try {
const listUpdatedAt = new Date(listData.updated_at);
let latestItemUpdate: Date | null = null;
if (listData.items && listData.items.length > 0) {
const latestDateString = listData.items.reduce(
(latest, item) => (item.updated_at > latest ? item.updated_at : latest),
listData.items[0].updated_at
);
latestItemUpdate = new Date(latestDateString);
}
lastKnownStatus = {
list_updated_at: listUpdatedAt,
latest_item_updated_at: latestItemUpdate,
item_count: listData.items?.length ?? 0
};
console.log('Polling: Initial/Reset status set', lastKnownStatus);
} catch (e) {
console.error('Polling Init: Error parsing dates', e);
lastKnownStatus = null;
}
}
// --- Polling Logic ---
function startPolling() {
stopPolling();
if (!$localListStore) return;
console.log(
`Polling: Starting polling for list ${$localListStore.id} every ${POLLING_INTERVAL_MS}ms`
);
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
}
function stopPolling() {
if (pollIntervalId) {
clearInterval(pollIntervalId);
pollIntervalId = null;
}
}
async function checkListStatus() {
const currentList = get(localListStore); // Use get for non-reactive access inside async
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
return;
}
console.log(`Polling: Checking status for list ${currentList.id}`);
try {
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
? new Date(currentStatus.latest_item_updated_at)
: null;
const listChanged =
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
const itemsChanged =
currentLatestItemUpdatedAt?.getTime() !==
lastKnownStatus.latest_item_updated_at?.getTime() ||
currentStatus.item_count !== lastKnownStatus.item_count;
if (listChanged || itemsChanged) {
console.log('Polling: Change detected!', { listChanged, itemsChanged });
await refreshListData(); // Fetch full data
// Update known status AFTER successful refresh
lastKnownStatus = {
list_updated_at: currentListUpdatedAt,
latest_item_updated_at: currentLatestItemUpdatedAt,
item_count: currentStatus.item_count
};
} else {
console.log('Polling: No changes detected.');
}
} catch (err) {
console.error('Polling: Failed to fetch list status:', err);
}
}
async function refreshListData() {
// Refactored to use store value
const listId = get(localListStore)?.id;
if (listId) {
await fetchAndUpdateList(listId);
}
}
// --- Event Handlers from ItemDisplay ---
async function handleItemUpdated(event: CustomEvent<ItemPublic>) {
const updatedItem = event.detail;
console.log('Parent received itemUpdated:', updatedItem);
// Update DB (already done in ItemDisplay optimistic update)
// Update store for UI
localListStore.update((currentList) => {
if (!currentList) return null;
const index = currentList.items.findIndex((i) => i.id === updatedItem.id);
if (index !== -1) {
currentList.items[index] = updatedItem;
}
return { ...currentList, items: [...currentList.items] };
});
clearItemError();
}
async function handleItemDeleted(event: CustomEvent<number>) {
const deletedItemId = event.detail;
console.log('Parent received itemDeleted:', deletedItemId);
// Update DB (already done in ItemDisplay optimistic update)
// Update store for UI
localListStore.update((currentList) => {
if (!currentList) return null;
return {
...currentList,
items: currentList.items.filter((item) => item.id !== deletedItemId)
};
});
clearItemError();
}
function handleItemUpdateError(event: CustomEvent<string>) {
/* ... (keep existing) ... */
}
function clearItemError() {
/* ... (keep existing) ... */
}
// --- Add Item Logic ---
async function handleAddItem() {
const currentList = get(localListStore); // Use get for non-reactive access
if (!newItemName.trim() || !currentList) return;
if (isAddingItem) return;
isAddingItem = true;
addItemError = null;
clearItemError();
// 1. Optimistic UI Update with Temporary ID (Using negative random number for simplicity)
const tempId = Math.floor(Math.random() * -1000000);
const currentUserId = get(authStore).user?.id; // Get current user ID synchronously
if (!currentUserId) {
addItemError = 'Cannot add item: User not identified.';
isAddingItem = false;
return;
}
const optimisticItem: ItemPublic = {
id: tempId, // Use temporary ID
list_id: currentList.id,
name: newItemName.trim(),
quantity: newItemQuantity.trim() || null,
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
);
// Note: Cannot add item with temp ID to IndexedDB if keyPath is 'id' and type is number.
// For MVP, we skip adding temp items to DB and rely on sync + refresh.
// 2. Queue Sync Action
const actionPayload: ItemCreate = {
name: newItemName.trim(),
quantity: newItemQuantity.trim() || undefined
};
try {
await addSyncAction({
type: 'create_item',
payload: { listId: currentList.id, data: actionPayload },
timestamp: Date.now()
// tempId: tempId // Optional: include tempId for mapping later
});
// 3. Trigger sync if online
if (browser && navigator.onLine) processSyncQueue();
// 4. Clear form
newItemName = '';
newItemQuantity = '';
} catch (dbError) {
console.error('Failed to queue add item action:', dbError);
addItemError = 'Failed to save item for offline sync.';
// Revert optimistic UI update? More complex.
localListStore.update((list) =>
list ? { ...list, items: list.items.filter((i) => i.id !== tempId) } : null
);
} finally {
isAddingItem = false;
}
}
</script>
<!-- Template -->
{#if $localListStore}
{@const list = $localListStore}
<!-- Create local const for easier access -->
<div class="space-y-6">
<!-- Sync Status Indicator -->
{#if $syncStatus === 'syncing'}
<div
class="fixed right-4 bottom-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
role="status"
>
Syncing changes...
</div>
{:else if $syncStatus === 'error' && $syncError}
<div
class="fixed right-4 bottom-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
role="alert"
>
Sync Error: {$syncError}
</div>
{/if}
<!-- List Header -->
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-200 pb-4">
<div>
<h1 class="text-3xl font-bold text-gray-800">{list.name}</h1>
{#if list.description}
<p class="mt-1 text-base text-gray-600">{list.description}</p>
{/if}
<p class="mt-1 text-xs text-gray-500">
ID: {list.id} |
{#if list.group_id}
<span class="font-medium text-purple-600">Shared</span> |
{:else}
<span class="font-medium text-gray-600">Personal</span> |
{/if}
Status: {list.is_complete ? 'Complete' : 'In Progress'} | Updated: {new Date(
list.updated_at
).toLocaleString()}
</p>
</div>
<div class="flex-shrink-0 space-x-2">
{#if isRefreshing}
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
{/if}
<a
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"
>
Edit List Details
</a>
</div>
</div>
<!-- Add New Item Form -->
<div class="rounded bg-white p-4 shadow">
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
<form
on:submit|preventDefault={handleAddItem}
class="flex flex-col gap-3 sm:flex-row sm:items-end"
>
<div class="flex-grow">
<label for="new-item-name" class="sr-only">Item Name</label>
<input
type="text"
id="new-item-name"
placeholder="Item name (required)"
required
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"
disabled={isAddingItem}
/>
</div>
<div class="sm:w-1/4">
<label for="new-item-qty" class="sr-only">Quantity</label>
<input
type="text"
id="new-item-qty"
placeholder="Quantity (opt.)"
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"
disabled={isAddingItem}
/>
</div>
<button
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"
disabled={isAddingItem}
>
{isAddingItem ? 'Adding...' : 'Add Item'}
</button>
</form>
{#if addItemError}
<p class="mt-2 text-sm text-red-600">{addItemError}</p>
{/if}
</div>
<!-- Item List Section -->
<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>
{#if itemUpdateError}
<div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
role="alert"
>
{itemUpdateError}
</div>
{/if}
{#if list.items && list.items.length > 0}
<ul class="space-y-2">
{#each list.items as item (item.id)}
<ItemDisplay
{item}
on:itemUpdated={handleItemUpdated}
on:itemDeleted={handleItemDeleted}
on:updateError={handleItemUpdateError}
/>
{/each}
</ul>
{:else}
<p class="py-4 text-center text-gray-500">This list is empty. Add items above!</p>
{/if}
</div>
<!-- Back Link -->
<div class="mt-6 border-t border-gray-200 pt-6">
<a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
</div>
</div>
{:else}
<!-- Fallback if list data is somehow null/undefined after load function -->
<p class="text-center text-gray-500">Loading list data...</p>
{/if}

View File

@ -0,0 +1,53 @@
// src/routes/(app)/lists/[listId]/+page.ts
import { error } from '@sveltejs/kit';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { ListDetail } from '$lib/schemas/list';
// --- Use the correct generated type ---
import type { PageLoad } from './$types'; // This type includes correctly typed 'params'
export interface ListDetailPageLoadData {
list: ListDetail;
}
export const load: PageLoad<ListDetailPageLoadData> = async ({ params, fetch }) => {
const listId = params.listId;
console.log(`List Detail page load: Fetching data for list ID: ${listId}`);
if (!listId || isNaN(parseInt(listId, 10))) {
throw error(400, 'Invalid List ID');
}
try {
// Fetch the specific list details (expecting items to be included)
// The backend GET /api/v1/lists/{list_id} should return ListDetail schema
const listData = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
if (!listData) {
// Should not happen if API call was successful, but check defensively
throw error(404, 'List not found (API returned no data)');
}
console.log('List Detail page load: Data fetched successfully', listData);
return {
list: listData
};
} catch (err) {
console.error(`List Detail page load: Failed to fetch list ${listId}:`, err);
if (err instanceof ApiClientError) {
if (err.status === 404) {
throw error(404, 'List not found');
}
if (err.status === 403) {
// User is authenticated (layout guard passed) but not member/creator
throw error(403, 'Forbidden: You do not have permission to view this list');
}
// For other API errors (like 500)
throw error(err.status || 500, `API Error: ${err.message}`);
} else if (err instanceof Error) {
// Network or other client errors
throw error(500, `Failed to load list data: ${err.message}`);
} else {
// Unknown error
throw error(500, 'An unexpected error occurred while loading list data.');
}
}
};

View File

@ -0,0 +1,16 @@
<!-- src/routes/(app)/lists/[listId]/edit/+page.svelte -->
<script lang="ts">
import ListForm from '$lib/components/ListForm.svelte';
import type { PageData } from './$types'; // Type for { list, groups, error }
export let data: PageData;
</script>
<div class="mx-auto max-w-xl">
<a href="/dashboard" class="mb-4 inline-block text-sm text-blue-600 hover:underline"
>← Back to Dashboard</a
>
<!-- Pass the fetched list, groups, and potential group load error -->
<!-- The 'list' prop tells ListForm it's in edit mode -->
<ListForm list={data.list} groups={data.groups} apiError={data.error} />
</div>

View File

@ -0,0 +1,75 @@
// src/routes/(app)/lists/[listId]/edit/+page.ts
import { error } from '@sveltejs/kit';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { GroupPublic } from '$lib/schemas/group';
import type { ListPublic } from '$lib/schemas/list'; // Use ListPublic or ListDetail
import type { PageLoad } from './$types';
export interface EditListPageLoadData {
list: ListPublic; // Or ListDetail if needed
groups: GroupPublic[];
error?: string | null; // For group loading errors
}
// Fetch the specific list to edit AND the user's groups for the dropdown
export const load: PageLoad<EditListPageLoadData> = async ({ params, fetch }) => {
const listId = params.listId;
console.log(`Edit List page load: Fetching list ${listId} and groups...`);
if (!listId || isNaN(parseInt(listId, 10))) {
throw error(400, 'Invalid List ID');
}
try {
// Fetch list details and groups in parallel
// Use apiClient for automatic auth handling
const [listResult, groupsResult] = await Promise.allSettled([
apiClient.get<ListPublic>(`/v1/lists/${listId}`), // Fetch specific list
apiClient.get<GroupPublic[]>('/v1/groups') // Fetch groups for dropdown
]);
let listData: ListPublic;
let groupsData: GroupPublic[] = [];
let groupsError: string | null = null;
// Process list result
if (listResult.status === 'fulfilled' && listResult.value) {
listData = listResult.value;
} else {
// Handle list fetch failure
const reason = listResult.status === 'rejected' ? listResult.reason : new Error('List data missing');
console.error(`Edit List page load: Failed to fetch list ${listId}:`, reason);
if (reason instanceof ApiClientError) {
if (reason.status === 404) throw error(404, 'List not found');
if (reason.status === 403) throw error(403, 'Forbidden: You cannot edit this list');
throw error(reason.status || 500, `API Error loading list: ${reason.message}`);
}
throw error(500, `Failed to load list data: ${reason instanceof Error ? reason.message : 'Unknown error'}`);
}
// Process groups result (non-critical, form can work without it)
if (groupsResult.status === 'fulfilled' && groupsResult.value) {
groupsData = groupsResult.value;
} else {
const reason = groupsResult.status === 'rejected' ? groupsResult.reason : new Error('Groups data missing');
console.error('Edit List page load: Failed to fetch groups:', reason);
groupsError = `Failed to load groups for sharing options: ${reason instanceof Error ? reason.message : 'Unknown error'}`;
// Don't throw error here, just pass the message to the component
}
return {
list: listData,
groups: groupsData,
error: groupsError // Pass group loading error to the page
};
} catch (err) {
// Catch errors thrown by Promise.allSettled handling or initial setup
console.error(`Edit List page load: Unexpected error for list ${listId}:`, err);
// Check if it's a SvelteKit error object before re-throwing
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
throw err;
}
throw error(500, `An unexpected error occurred: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};

View File

@ -0,0 +1,13 @@
<!-- src/routes/(app)/lists/new/+page.svelte -->
<script lang="ts">
import ListForm from '$lib/components/ListForm.svelte';
import type { PageData } from './$types'; // Type for { groups, error }
export let data: PageData;
</script>
<div class="mx-auto max-w-xl">
<!-- Pass groups and potential load error to the form component -->
<!-- 'list' prop is omitted/null, so ListForm knows it's in create mode -->
<ListForm groups={data.groups} apiError={data.error} />
</div>

View File

@ -0,0 +1,32 @@
// src/routes/(app)/lists/new/+page.ts
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { GroupPublic } from '$lib/schemas/group';
import type { PageLoad } from './$types';
export interface NewListPageLoadData {
groups: GroupPublic[];
error?: string | null;
}
// Fetch groups needed for the dropdown in the form
export const load: PageLoad<NewListPageLoadData> = async ({ fetch }) => {
console.log('New List page load: Fetching groups...');
try {
const groups = await apiClient.get<GroupPublic[]>('/v1/groups');
return {
groups: groups ?? [],
error: null
};
} catch (err) {
console.error('New List page load: Failed to fetch groups:', err);
let errorMessage = 'Failed to load group data for sharing options.';
// Handle specific errors if needed (e.g., 401 handled globally)
if (err instanceof Error) {
errorMessage = `Error loading groups: ${err.message}`;
}
return {
groups: [],
error: errorMessage
};
}
};

View File

@ -1,64 +1,135 @@
// src/service-worker.ts
/// <reference types="@sveltejs/kit" />
// REMOVED: /// <reference types="@types/workbox-sw" />
/// <reference lib="webworker" />
// Import SvelteKit-provided variables ONLY
// This import IS correct - it's provided by SvelteKit
import { build, files, version } from '$service-worker';
declare let self: ServiceWorkerGlobalScope;
// Declare 'workbox' as any for now IF TypeScript still complains after removing @types/workbox-sw.
// Often, SvelteKit's types are enough, but this is a fallback.
declare const workbox: any; // Uncomment this line ONLY if 'Cannot find name workbox' persists
// Declare workbox global if needed (if TS complains after removing @types/workbox-sw)
declare const workbox: any; // Using 'any' for simplicity if specific types cause issues
console.log(`[Service Worker] Version: ${version}`);
// --- Precaching ---
// Use the global workbox object (assuming SvelteKit injects it)
// --- Precaching Core Assets ---
// Cache essential SvelteKit build artifacts and static files
workbox.precaching.precacheAndRoute(build);
workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null })));
// --- Runtime Caching ---
// Google Fonts
// --- Runtime Caching Strategies ---
// Example: Cache Google Fonts (Optional)
workbox.routing.registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com',
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'google-fonts',
cacheName: 'google-fonts-cache',
plugins: [
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
new workbox.expiration.ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 }),
new workbox.expiration.ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 Days
],
}),
);
// Images from origin
// Example: Cache images from origin (Optional)
workbox.routing.registerRoute(
({ request, url }) => !!request && request.destination === 'image' && url.origin === self.location.origin,
({ request, url }) => request.destination === 'image' && url.origin === self.location.origin,
new workbox.strategies.CacheFirst({
cacheName: 'images',
cacheName: 'images-cache',
plugins: [
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 30 * 24 * 60 * 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
purgeOnQuotaError: true,
}),
],
}),
);
// --- Lifecycle ---
// --- API GET Request Caching ---
// Cache the list of lists (NetworkFirst: try network, fallback cache)
workbox.routing.registerRoute(
({ url, request }) => url.pathname === '/api/v1/lists' && request.method === 'GET',
new workbox.strategies.NetworkFirst({
cacheName: 'api-lists-cache',
plugins: [
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
new workbox.expiration.ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 }) // 1 Day
]
})
);
// Cache individual list details (NetworkFirst)
workbox.routing.registerRoute(
({ url, request }) => /^\/api\/v1\/lists\/\d+$/.test(url.pathname) && request.method === 'GET',
new workbox.strategies.NetworkFirst({
cacheName: 'api-list-detail-cache',
plugins: [
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
new workbox.expiration.ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 }) // 1 Day
]
})
);
// Cache list status (StaleWhileRevalidate: serve cache fast, update background)
workbox.routing.registerRoute(
({ url, request }) => /^\/api\/v1\/lists\/\d+\/status$/.test(url.pathname) && request.method === 'GET',
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'api-list-status-cache',
plugins: [
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
new workbox.expiration.ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 }) // 1 Hour
]
})
);
// --- Service Worker Lifecycle ---
self.addEventListener('install', (event) => {
console.log('[Service Worker] Install event');
// Force activation immediately (use with caution, ensure clients handle updates)
// event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
const extendableEvent = event as ExtendableEvent;
const extendableEvent = event as ExtendableEvent; // Cast for type safety
console.log('[Service Worker] Activate event');
// Remove outdated caches managed by Workbox's precaching
extendableEvent.waitUntil(workbox.precaching.cleanupOutdatedCaches());
// event.waitUntil(self.clients.claim());
// Take control of uncontrolled clients immediately
// extendableEvent.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
// Workbox's registered routes handle fetch events automatically.
// console.log(`[Service Worker] Fetching: ${event.request.url}`);
// --- Background Sync Interception (More Advanced - requires Workbox Background Sync module) ---
// This is a basic example; a full implementation requires the workbox-background-sync package
// and more robust error handling and queue management.
// const isApiMutation = event.request.url.startsWith(self.location.origin + '/api/') && event.request.method !== 'GET';
// if (isApiMutation) {
// // Example using BackgroundSync Queue (conceptual)
// // Requires: import { Queue } from 'workbox-background-sync';
// // const queue = new Queue('apiSyncQueue');
// // event.respondWith(
// // fetch(event.request.clone()).catch(() => {
// // console.log('[Service Worker] Fetch failed, queueing request:', event.request.url);
// // return queue.pushRequest({ request: event.request });
// // })
// // );
// }
});
// Optional: Listen for messages from clients (e.g., trigger sync manually)
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'TRIGGER_SYNC') {
console.log('[Service Worker] Received TRIGGER_SYNC message.');
// Import and call processSyncQueue if structured that way,
// or use Background Sync's replayRequests.
// Example: Triggering background sync replay (if using Workbox Background Sync)
// const queue = new workbox.backgroundSync.Queue('apiSyncQueue');
// event.waitUntil(queue.replayRequests());
}
});