diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index 2b28884..e26bd14 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -12,7 +12,7 @@ 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 +from app.schemas.list import ListStatus, ListStatusWithId from app.schemas.expense import ExpensePublic # Import ExpensePublic from app.core.exceptions import ( GroupMembershipError, @@ -106,6 +106,39 @@ async def read_lists( return lists +@router.get( + "/statuses", + response_model=PyList[ListStatusWithId], + summary="Get Status for Multiple Lists", + tags=["Lists"] +) +async def read_lists_statuses( + ids: PyList[int] = Query(...), + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Retrieves the status for a list of lists. + - `updated_at`: The timestamp of the last update to the list itself. + - `item_count`: The total number of items in the list. + The user must have permission to view each list requested. + Lists that the user does not have permission for will be omitted from the response. + """ + logger.info(f"User {current_user.email} requesting statuses for list IDs: {ids}") + + statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id) + + # The CRUD function returns a list of Row objects, so we map them to the Pydantic model + return [ + ListStatusWithId( + id=s.id, + updated_at=s.updated_at, + item_count=s.item_count, + latest_item_updated_at=s.latest_item_updated_at + ) for s in statuses + ] + + @router.get( "/{list_id}", response_model=ListDetail, # Return detailed list info including items @@ -216,28 +249,13 @@ async def read_list_status( current_user: UserModel = Depends(current_active_user), ): """ - Retrieves the completion status for a specific list + Retrieves the update timestamp and item count for a specific list if the user has permission (creator or group member). """ logger.info(f"User {current_user.email} requesting status for list ID: {list_id}") - list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) - - # Calculate status - total_items = len(list_db.items) - completed_items = sum(1 for item in list_db.items if item.is_complete) - - try: - completion_percentage = (completed_items / total_items * 100) if total_items > 0 else 0 - except ZeroDivisionError: - completion_percentage = 0 - - return ListStatus( - list_id=list_db.id, - total_items=total_items, - completed_items=completed_items, - completion_percentage=completion_percentage, - last_updated=list_db.updated_at - ) + # The check_list_permission is not needed here as get_list_status handles not found + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + return await crud_list.get_list_status(db=db, list_id=list_id) @router.get( "/{list_id}/expenses", diff --git a/be/app/crud/list.py b/be/app/crud/list.py index 644a0e0..0aa1dbb 100644 --- a/be/app/crud/list.py +++ b/be/app/crud/list.py @@ -219,27 +219,27 @@ async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, re async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus: """Gets the update timestamps and item count for a list.""" try: - 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() + query = ( + select( + ListModel.updated_at, + sql_func.count(ItemModel.id).label("item_count"), + sql_func.max(ItemModel.updated_at).label("latest_item_updated_at") + ) + .select_from(ListModel) + .outerjoin(ItemModel, ItemModel.list_id == ListModel.id) + .where(ListModel.id == list_id) + .group_by(ListModel.id) + ) + result = await db.execute(query) + status = result.first() - if list_updated_at is None: + if status is None: raise ListNotFoundError(list_id) - 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() - 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 + updated_at=status.updated_at, + item_count=status.item_count, + latest_item_updated_at=status.latest_item_updated_at ) except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") @@ -295,4 +295,58 @@ async def get_list_by_name_and_group( except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") except SQLAlchemyError as e: - raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}") \ No newline at end of file + raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}") + +async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], user_id: int) -> PyList[ListModel]: + """ + Gets status for a list of lists if the user has permission. + Status includes list updated_at and a count of its items. + """ + if not list_ids: + return [] + + try: + # First, get the 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() + + # Build the permission logic + permission_filter = or_( + # User is the creator of the list + and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)), + # List belongs to a group the user is a member of + ListModel.group_id.in_(user_group_ids) + ) + + # Main query to get list data and item counts + query = ( + select( + ListModel.id, + ListModel.updated_at, + sql_func.count(ItemModel.id).label("item_count"), + sql_func.max(ItemModel.updated_at).label("latest_item_updated_at") + ) + .outerjoin(ItemModel, ListModel.id == ItemModel.list_id) + .where( + and_( + ListModel.id.in_(list_ids), + permission_filter + ) + ) + .group_by(ListModel.id) + ) + + result = await db.execute(query) + + # The result will be rows of (id, updated_at, item_count). + # We need to verify that all requested list_ids that the user *should* have access to are present. + # The filter in the query already handles permissions. + + return result.all() # Returns a list of Row objects with id, updated_at, item_count + + except OperationalError as e: + raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") + except SQLAlchemyError as e: + raise DatabaseQueryError(f"Failed to get lists statuses: {str(e)}") \ No newline at end of file diff --git a/be/app/schemas/list.py b/be/app/schemas/list.py index a2d4314..b21e506 100644 --- a/be/app/schemas/list.py +++ b/be/app/schemas/list.py @@ -42,6 +42,9 @@ 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 \ No newline at end of file + updated_at: datetime + item_count: int + latest_item_updated_at: Optional[datetime] = None + +class ListStatusWithId(ListStatus): + id: int \ No newline at end of file diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue index 970b4ed..231454d 100644 --- a/fe/src/pages/GroupDetailPage.vue +++ b/fe/src/pages/GroupDetailPage.vue @@ -81,10 +81,7 @@
{{ t('groupDetailPage.chores.title') }} - - cleaning_services {{ - t('groupDetailPage.chores.manageButton') }} - +
@@ -92,7 +89,7 @@ {{ chore.name }} {{ t('groupDetailPage.chores.duePrefix') }} {{ formatDate(chore.next_due_date) - }} + }}
@@ -107,10 +104,7 @@
{{ t('groupDetailPage.expenses.title') }} - - payments {{ - t('groupDetailPage.expenses.manageButton') }} - +
@@ -226,10 +220,10 @@ @@ -1126,10 +1120,6 @@ onMounted(() => { transition: transform 0.1s ease-in-out; } -.neo-expense-item:hover { - transform: translateY(-2px); -} - .neo-expense-info { display: flex; flex-direction: column; @@ -1205,6 +1195,7 @@ onMounted(() => { .neo-expense-item-wrapper { border-bottom: 1px solid #f0e5d8; + margin-bottom: 0.5rem; } .neo-expense-item-wrapper:last-child { diff --git a/fe/src/pages/ListDetailPage.vue b/fe/src/pages/ListDetailPage.vue index ad90242..10e8657 100644 --- a/fe/src/pages/ListDetailPage.vue +++ b/fe/src/pages/ListDetailPage.vue @@ -378,9 +378,9 @@ @@ -470,6 +470,11 @@ interface ItemWithUI extends Item { showFirework?: boolean; // For firework animation } +interface ListStatus { + updated_at: string; + item_count: number; +} + interface List { id: number; name: string; @@ -514,7 +519,7 @@ const error = ref(null); // For page-level errors const addingItem = ref(false); const pollingInterval = ref | null>(null); const lastListUpdate = ref(null); -const lastItemUpdate = ref(null); +const lastItemCount = ref(null); const newItem = ref<{ name: string; quantity?: number | string }>({ name: '' }); const itemNameInputRef = ref | null>(null); @@ -614,9 +619,8 @@ const fetchListDetails = async () => { }; list.value = localList; lastListUpdate.value = rawList.updated_at; - lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => { - return item.updated_at > latest ? item.updated_at : latest; - }, ''); + lastItemCount.value = rawList.items.length; + if (showCostSummaryDialog.value) { await fetchListCostSummary(); } @@ -638,14 +642,13 @@ const fetchListDetails = async () => { const checkForUpdates = async () => { if (!list.value) return; try { - const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id))); - const { updated_at: newListUpdatedAt, items: newItems } = response.data as ListWithExpenses; - const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => - item.updated_at > latest ? item.updated_at : latest, - ''); + const response = await apiClient.get(API_ENDPOINTS.LISTS.STATUS(String(list.value.id))); + const { updated_at: newListUpdatedAt, item_count: newItemCount } = response.data as ListStatus; - if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) || - (lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) { + if ( + (lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) || + (lastItemCount.value !== null && newItemCount !== lastItemCount.value) + ) { await fetchListDetails(); } } catch (err) { diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue index b0c8a7c..f283857 100644 --- a/fe/src/pages/ListsPage.vue +++ b/fe/src/pages/ListsPage.vue @@ -39,9 +39,11 @@
  • @@ -77,6 +79,13 @@ import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed const { t } = useI18n(); +interface ListStatus { + id: number; + updated_at: string; + item_count: number; + latest_item_updated_at: string | null; +} + interface List { id: number; name: string; @@ -123,6 +132,8 @@ const currentViewedGroup = ref(null); const showCreateModal = ref(false); const newItemInputRefs = ref([]); +const pollingInterval = ref | null>(null); + const currentGroupId = computed(() => { const idFromProp = props.groupId; const idFromRoute = route.params.groupId; @@ -413,11 +424,89 @@ const touchActiveListId = ref(null); const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; }; const handleTouchEnd = () => { touchActiveListId.value = null; }; +const refetchList = async (listId: number) => { + try { + const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId))); + const updatedList = response.data as List; + const listIndex = lists.value.findIndex(l => l.id === listId); + + if (listIndex !== -1) { + // Use direct assignment for better reactivity + lists.value[listIndex] = { ...updatedList, items: updatedList.items || [] }; + + // Update cache + cachedLists.value = JSON.parse(JSON.stringify(lists.value)); + cachedTimestamp.value = Date.now(); + } else { + } + } catch (err) { + console.error(`Failed to refetch list ${listId}:`, err); + } +}; + +const checkForUpdates = async () => { + if (lists.value.length === 0) { + return; + } + + const listIds = lists.value.map(l => l.id); + if (listIds.length === 0) return; + + try { + const response = await apiClient.get(API_ENDPOINTS.LISTS.STATUSES, { + params: { ids: listIds } + }); + const statuses = response.data as ListStatus[]; + + for (const status of statuses) { + const localList = lists.value.find(l => l.id === status.id); + if (localList) { + const localUpdatedAt = new Date(localList.updated_at).getTime(); + const remoteUpdatedAt = new Date(status.updated_at).getTime(); + const localItemCount = localList.items.length; + const remoteItemCount = status.item_count; + + const localLatestItemUpdate = localList.items.reduce((latest, item) => { + const itemDate = new Date(item.updated_at).getTime(); + return itemDate > latest ? itemDate : latest; + }, 0); + + const remoteLatestItemUpdate = status.latest_item_updated_at + ? new Date(status.latest_item_updated_at).getTime() + : 0; + + if ( + remoteUpdatedAt > localUpdatedAt || + localItemCount !== remoteItemCount || + (remoteLatestItemUpdate > localLatestItemUpdate) + ) { + await refetchList(status.id); + } + } + } + } catch (err) { + console.warn('Polling for list updates failed:', err); + } +}; + +const startPolling = () => { + stopPolling(); + pollingInterval.value = setInterval(checkForUpdates, 15000); // Poll every 15 seconds +}; + +const stopPolling = () => { + if (pollingInterval.value) { + clearInterval(pollingInterval.value); + pollingInterval.value = null; + } +}; + onMounted(() => { loadCachedData(); fetchListsAndGroups().then(() => { if (lists.value.length > 0) { setupIntersectionObserver(); + startPolling(); } }); }); @@ -427,6 +516,9 @@ watch(currentGroupId, () => { fetchListsAndGroups().then(() => { if (lists.value.length > 0) { setupIntersectionObserver(); + startPolling(); + } else { + stopPolling(); } }); }); @@ -436,6 +528,7 @@ watch(() => lists.value.length, (newLength, oldLength) => { setupIntersectionObserver(); } if (newLength > 0) { + startPolling(); nextTick(() => { document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => { if (intersectionObserver) { @@ -450,6 +543,7 @@ onUnmounted(() => { if (intersectionObserver) { intersectionObserver.disconnect(); } + stopPolling(); }); @@ -544,6 +638,7 @@ onUnmounted(() => { opacity: 0.6; } +/* Custom Checkbox Styles */ .neo-checkbox-label { display: grid; grid-template-columns: auto 1fr; @@ -551,7 +646,7 @@ onUnmounted(() => { gap: 0.8em; cursor: pointer; position: relative; - width: fit-content; + width: 100%; font-weight: 500; color: #414856; transition: color 0.3s ease; @@ -562,95 +657,89 @@ onUnmounted(() => { -webkit-appearance: none; -moz-appearance: none; position: relative; - height: 18px; - width: 18px; + height: 20px; + width: 20px; outline: none; - border: 2px solid var(--dark); + border: 2px solid #b8c1d1; margin: 0; cursor: pointer; - background: var(--light); - border-radius: 4px; + background: transparent; + border-radius: 6px; display: grid; align-items: center; justify-content: center; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } .neo-checkbox-label input[type="checkbox"]:hover { border-color: var(--secondary); - background: var(--light); transform: scale(1.05); } .neo-checkbox-label input[type="checkbox"]::before, .neo-checkbox-label input[type="checkbox"]::after { - content: ""; - position: absolute; - height: 2px; - background: var(--primary); - border-radius: 2px; - opacity: 0; - transition: opacity 0.2s ease; -} - -.neo-checkbox-label input[type="checkbox"]::before { - width: 0px; - right: 55%; - transform-origin: right bottom; + content: none; } .neo-checkbox-label input[type="checkbox"]::after { - width: 0px; - left: 45%; - transform-origin: left bottom; + content: ""; + position: absolute; + opacity: 0; + left: 5px; + top: 1px; + width: 6px; + height: 12px; + border: solid var(--primary); + border-width: 0 3px 3px 0; + transform: rotate(45deg) scale(0); + transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28); + transition-property: transform, opacity; } .neo-checkbox-label input[type="checkbox"]:checked { border-color: var(--primary); - background: var(--light); - transform: scale(1.05); -} - -.neo-checkbox-label input[type="checkbox"]:checked::before { - opacity: 1; - animation: check-01 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; } .neo-checkbox-label input[type="checkbox"]:checked::after { opacity: 1; - animation: check-02 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; + transform: rotate(45deg) scale(1); +} + +.checkbox-content { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; } .checkbox-text-span { position: relative; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.checkbox-text-span::before, -.checkbox-text-span::after { - content: ""; - position: absolute; - left: 0; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: color 0.4s ease, opacity 0.4s ease; + width: fit-content; } +/* Animated strikethrough line */ .checkbox-text-span::before { - height: 2px; - width: 8px; + content: ''; + position: absolute; top: 50%; - transform: translateY(-50%); - background: var(--secondary); - border-radius: 2px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + left: -0.1em; + right: -0.1em; + height: 2px; + background: var(--dark); + transform: scaleX(0); + transform-origin: right; + transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1); } +/* Firework particle container */ .checkbox-text-span::after { content: ''; position: absolute; - width: 4px; - height: 4px; + width: 6px; + height: 6px; top: 50%; - left: 130%; + left: 50%; transform: translate(-50%, -50%); border-radius: 50%; background: var(--accent); @@ -658,21 +747,39 @@ onUnmounted(() => { pointer-events: none; } -.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span { +/* Selector fixed to target span correctly */ +.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span { color: var(--dark); - opacity: 0.7; - text-decoration: line-through var(--dark); - transform: translateX(4px); + opacity: 0.6; } -.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after { - animation: firework 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.15s; +.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before { + transform: scaleX(1); + transform-origin: left; + transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s; +} + +.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::after { + animation: firework-refined 0.7s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.2s; } .neo-completed-static { color: var(--dark); - opacity: 0.7; - text-decoration: line-through var(--dark); + opacity: 0.6; + position: relative; +} + +/* Static strikethrough for items loaded as complete */ +.neo-completed-static::before { + content: ''; + position: absolute; + top: 50%; + left: -0.1em; + right: -0.1em; + height: 2px; + background: var(--dark); + transform: scaleX(1); + transform-origin: left; } .new-item-input-container .neo-checkbox-label { @@ -777,93 +884,22 @@ onUnmounted(() => { } .neo-list-item { - /* padding: 0.8rem 0; */ - /* Removed as margin-bottom is used */ margin-bottom: 0.7rem; /* Adjusted for mobile */ } } -@keyframes check-01 { - 0% { - width: 4px; - top: auto; - transform: rotate(0); - } - - 50% { - width: 0px; - top: auto; - transform: rotate(0); - } - - 51% { - width: 0px; - top: 8px; - transform: rotate(45deg); - } - - 100% { - width: 6px; - top: 8px; - transform: rotate(45deg); - } -} - -@keyframes check-02 { - 0% { - width: 4px; - top: auto; - transform: rotate(0); - } - - 50% { - width: 0px; - top: auto; - transform: rotate(0); - } - - 51% { - width: 0px; - top: 8px; - transform: rotate(-45deg); - } - - 100% { - width: 11px; - top: 8px; - transform: rotate(-45deg); - } -} - -@keyframes firework { - 0% { +@keyframes firework-refined { + from { opacity: 1; transform: translate(-50%, -50%) scale(0.5); - box-shadow: - 0 0 0 0 var(--accent), - 0 0 0 0 var(--accent), - 0 0 0 0 var(--accent), - 0 0 0 0 var(--accent), - 0 0 0 0 var(--accent), - 0 0 0 0 var(--accent); + box-shadow: 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent); } - 50% { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } - - 100% { + to { opacity: 0; - transform: translate(-50%, -50%) scale(1.2); - box-shadow: - 0 -15px 0 0 var(--accent), - 14px -8px 0 0 var(--accent), - 14px 8px 0 0 var(--accent), - 0 15px 0 0 var(--accent), - -14px 8px 0 0 var(--accent), - -14px -8px 0 0 var(--accent); + transform: translate(-50%, -50%) scale(2); + box-shadow: 0 -20px 0 0 var(--accent), 20px 0px 0 0 var(--accent), 0 20px 0 0 var(--accent), -20px 0px 0 0 var(--accent), 14px -14px 0 0 var(--accent), 14px 14px 0 0 var(--accent), -14px 14px 0 0 var(--accent), -14px -14px 0 0 var(--accent); } } diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 77268d0..9b16cbf 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -3,6 +3,7 @@ import { API_BASE_URL, API_ENDPOINTS } from '@/config/api-config' // api-config. import router from '@/router' // Import the router instance import { useAuthStore } from '@/stores/auth' // Import the auth store import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response +import { stringify } from 'qs'; // Create axios instance const api = axios.create({ @@ -11,6 +12,9 @@ const api = axios.create({ 'Content-Type': 'application/json', }, withCredentials: true, // Enable sending cookies and authentication headers + paramsSerializer: { + serialize: (params) => stringify(params, { arrayFormat: 'repeat' }), + }, }) // Create apiClient with helper methods diff --git a/fe/src/stores/listDetailStore.ts b/fe/src/stores/listDetailStore.ts index 0ea164a..2056be6 100644 --- a/fe/src/stores/listDetailStore.ts +++ b/fe/src/stores/listDetailStore.ts @@ -32,6 +32,11 @@ export const useListDetailStore = defineStore('listDetail', { actions: { async fetchListWithExpenses(listId: string) { + if (!listId || listId === 'undefined' || listId === 'null') { + this.error = 'Invalid list ID provided.'; + console.warn(`fetchListWithExpenses called with invalid ID: ${listId}`); + return; + } this.isLoading = true this.error = null try {