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

View File

@ -0,0 +1,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 # app/models.py
import enum import enum
import secrets # For generating invite codes import secrets
from datetime import datetime, timedelta, timezone # For invite expiry from datetime import datetime, timedelta, timezone
from sqlalchemy import ( from sqlalchemy import (
Column, Column,
@ -10,20 +10,20 @@ from sqlalchemy import (
DateTime, DateTime,
ForeignKey, ForeignKey,
Boolean, Boolean,
Enum as SAEnum, # Renamed to avoid clash with Python's enum Enum as SAEnum,
UniqueConstraint, UniqueConstraint,
Index, # Added for invite code index Index,
DDL, DDL,
event, event,
delete, # Added for potential cascade delete if needed (though FK handles it) delete,
func, # Added for func.count() func,
text as sa_text # For raw SQL in index definition if needed text as sa_text,
Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price
) )
from sqlalchemy.orm import relationship 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 --- # --- Enums ---
class UserRoleEnum(enum.Enum): class UserRoleEnum(enum.Enum):
@ -36,25 +36,20 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False) 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) name = Column(String, index=True, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships --- # --- Relationships ---
# Groups created by this user created_groups = relationship("Group", back_populates="creator")
created_groups = relationship("Group", back_populates="creator") # Links to Group.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) # --- NEW Relationships for Lists/Items ---
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user 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
# Invites created by this user (one-to-many) completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User
created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator # --- End NEW Relationships ---
# 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")
# --- Group Model --- # --- Group Model ---
@ -63,78 +58,88 @@ class Group(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False) 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) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships --- # --- Relationships ---
# The user who created this group (many-to-one) creator = relationship("User", back_populates="created_groups")
creator = relationship("User", back_populates="created_groups") # Links to User.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) # --- NEW Relationship for Lists ---
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group
# --- End NEW Relationship ---
# 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")
# --- UserGroup Association Model (Many-to-Many link) --- # --- UserGroup Association Model ---
class UserGroup(Base): class UserGroup(Base):
__tablename__ = "user_groups" __tablename__ = "user_groups"
# Ensure a user cannot be in the same group twice
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) __table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
id = Column(Integer, primary_key=True, index=True) # Surrogate primary key id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group 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) # Use Enum, ensure type is created 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) joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships --- user = relationship("User", back_populates="group_associations")
# Link back to User (many-to-one from the perspective of this table row) group = relationship("Group", back_populates="member_associations")
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
# --- Invite Model --- # --- Invite Model ---
class Invite(Base): class Invite(Base):
__tablename__ = "invites" __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__ = ( __table_args__ = (
Index( Index('ix_invites_active_code', 'code', unique=True, postgresql_where=sa_text('is_active = true')),
'ix_invites_active_code',
'code',
unique=True,
postgresql_where=sa_text('is_active = true') # Partial index condition
),
) )
id = Column(Integer, primary_key=True, index=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))
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)
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User
created_at = Column(DateTime(timezone=True), server_default=func.now(), 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)) 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 --- # --- Relationships ---
# Link back to the Group this invite is for (many-to-one) creator = relationship("User", back_populates="created_lists") # Link to User.created_lists
group = relationship("Group", back_populates="invites") # Links to Group.invites 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
# Link back to the User who created the invite (many-to-one)
creator = relationship("User", back_populates="created_invites") # Links to User.created_invites
# --- Models for Lists, Items, Expenses (Add later) --- # === NEW: Item Model ===
# class List(Base): ... class Item(Base):
# class Item(Base): ... __tablename__ = "items"
# class Expense(Base): ...
# class ExpenseShare(Base): ... 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", "name": "fe",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"idb": "^8.0.2"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.11", "@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
@ -1527,6 +1530,12 @@
"node": ">= 0.4" "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": { "node_modules/import-meta-resolve": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "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", "tailwindcss": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.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> </script>
<div class="space-y-8"> <div class="space-y-8">
<h1 class="text-3xl font-bold text-gray-800">Your Groups</h1> <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 --> <!-- Group Creation Section -->
<div class="rounded bg-white p-6 shadow"> <div class="rounded bg-white p-6 shadow">
@ -95,6 +104,47 @@
{/if} {/if}
</div> </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 --> <!-- Group List Section -->
<div class="rounded bg-white p-6 shadow"> <div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Groups</h2> <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 { apiClient, ApiClientError } from '$lib/apiClient';
import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type
import type { PageLoad } from './$types'; // SvelteKit's type for load functions 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 // Define the expected shape of the data returned by this load function
export interface DashboardLoadData { export interface DashboardLoadData {
@ -18,9 +19,11 @@ export const load: PageLoad<DashboardLoadData> = async ({ fetch }) => {
console.log('Dashboard page load: Fetching groups...'); console.log('Dashboard page load: Fetching groups...');
try { try {
const groups = await apiClient.get<GroupPublic[]>('/v1/groups'); // apiClient adds auth header 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); console.log('Dashboard page load: Groups fetched successfully', groups);
return { return {
groups: groups ?? [], // Return empty array if API returns null/undefined groups: groups ?? [], // Return empty array if API returns null/undefined
lists: lists ?? [],
error: null error: null
}; };
} catch (err) { } 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" /> /// <reference types="@sveltejs/kit" />
// REMOVED: /// <reference types="@types/workbox-sw" />
/// <reference lib="webworker" /> /// <reference lib="webworker" />
// Import SvelteKit-provided variables ONLY // This import IS correct - it's provided by SvelteKit
import { build, files, version } from '$service-worker'; import { build, files, version } from '$service-worker';
declare let self: ServiceWorkerGlobalScope; // Declare workbox global if needed (if TS complains after removing @types/workbox-sw)
// Declare 'workbox' as any for now IF TypeScript still complains after removing @types/workbox-sw. declare const workbox: any; // Using 'any' for simplicity if specific types cause issues
// 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
console.log(`[Service Worker] Version: ${version}`); console.log(`[Service Worker] Version: ${version}`);
// --- Precaching --- // --- Precaching Core Assets ---
// Use the global workbox object (assuming SvelteKit injects it) // Cache essential SvelteKit build artifacts and static files
workbox.precaching.precacheAndRoute(build); workbox.precaching.precacheAndRoute(build);
workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null }))); workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null })));
// --- Runtime Caching --- // --- Runtime Caching Strategies ---
// Google Fonts
// Example: Cache Google Fonts (Optional)
workbox.routing.registerRoute( workbox.routing.registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com', ({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com',
new workbox.strategies.StaleWhileRevalidate({ new workbox.strategies.StaleWhileRevalidate({
cacheName: 'google-fonts', cacheName: 'google-fonts-cache',
plugins: [ plugins: [
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }), 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( 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({ new workbox.strategies.CacheFirst({
cacheName: 'images', cacheName: 'images-cache',
plugins: [ plugins: [
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }), new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
new workbox.expiration.ExpirationPlugin({ new workbox.expiration.ExpirationPlugin({
maxEntries: 50, maxEntries: 50,
maxAgeSeconds: 30 * 24 * 60 * 60, maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
purgeOnQuotaError: true, 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) => { self.addEventListener('install', (event) => {
console.log('[Service Worker] Install event'); console.log('[Service Worker] Install event');
// Force activation immediately (use with caution, ensure clients handle updates)
// event.waitUntil(self.skipWaiting()); // event.waitUntil(self.skipWaiting());
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
const extendableEvent = event as ExtendableEvent; const extendableEvent = event as ExtendableEvent; // Cast for type safety
console.log('[Service Worker] Activate event'); console.log('[Service Worker] Activate event');
// Remove outdated caches managed by Workbox's precaching
extendableEvent.waitUntil(workbox.precaching.cleanupOutdatedCaches()); 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) => { self.addEventListener('fetch', (event) => {
// Workbox's registered routes handle fetch events automatically.
// console.log(`[Service Worker] Fetching: ${event.request.url}`); // 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());
}
}); });