diff --git a/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-copy.ts b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-copy.ts new file mode 100644 index 000000000..92e10cf7a --- /dev/null +++ b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-copy.ts @@ -0,0 +1,56 @@ +import type { ShoppingListItemOut } from "~/lib/api/types/household"; +import { useCopyList } from "~/composables/use-copy"; + +type CopyTypes = "plain" | "markdown"; + +/** + * Composable for managing shopping list copy functionality + */ +export function useShoppingListCopy() { + const copy = useCopyList(); + + function copyListItems(itemsByLabel: { [key: string]: ShoppingListItemOut[] }, copyType: CopyTypes) { + const text: string[] = []; + + // Copy text into subsections based on label + Object.entries(itemsByLabel).forEach(([label, items], idx) => { + // for every group except the first, add a blank line + if (idx) { + text.push(""); + } + + // add an appropriate heading for the label depending on the copy format + text.push(formatCopiedLabelHeading(copyType, label)); + + // now add the appropriately formatted list items with the given label + items.forEach(item => text.push(formatCopiedListItem(copyType, item))); + }); + + copy.copyPlain(text); + } + + function formatCopiedListItem(copyType: CopyTypes, item: ShoppingListItemOut): string { + const display = item.display || ""; + switch (copyType) { + case "markdown": + return `- [ ] ${display}`; + default: + return display; + } + } + + function formatCopiedLabelHeading(copyType: CopyTypes, label: string): string { + switch (copyType) { + case "markdown": + return `# ${label}`; + default: + return `[${label}]`; + } + } + + return { + copyListItems, + formatCopiedListItem, + formatCopiedLabelHeading, + }; +} diff --git a/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-crud.ts b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-crud.ts new file mode 100644 index 000000000..481ee1b13 --- /dev/null +++ b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-crud.ts @@ -0,0 +1,285 @@ +import type { ShoppingListOut, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household"; +import { useUserApi } from "~/composables/api"; +import { uuid4 } from "~/composables/use-utils"; + +/** + * Composable for managing shopping list item CRUD operations + */ +export function useShoppingListCrud( + shoppingList: Ref, + loadingCounter: Ref, + listItems: { unchecked: ShoppingListItemOut[]; checked: ShoppingListItemOut[] }, + shoppingListItemActions: any, + refresh: () => void, + sortCheckedItems: (a: ShoppingListItemOut, b: ShoppingListItemOut) => number, + updateListItemOrder: () => void, +) { + const { t } = useI18n(); + const userApi = useUserApi(); + + const createListItemData = ref(listItemFactory()); + const localLabels = ref(); + + function listItemFactory(isFood = false): ShoppingListItemOut { + return { + id: uuid4(), + shoppingListId: shoppingList.value?.id || "", + checked: false, + position: shoppingList.value?.listItems?.length || 1, + isFood, + quantity: 0, + note: "", + labelId: undefined, + unitId: undefined, + foodId: undefined, + } as ShoppingListItemOut; + } + + // Check/Uncheck All operations + function checkAllItems() { + let hasChanged = false; + shoppingList.value?.listItems?.forEach((item) => { + if (!item.checked) { + hasChanged = true; + item.checked = true; + } + }); + if (hasChanged) { + updateUncheckedListItems(); + } + } + + function uncheckAllItems() { + let hasChanged = false; + shoppingList.value?.listItems?.forEach((item) => { + if (item.checked) { + hasChanged = true; + item.checked = false; + } + }); + if (hasChanged) { + listItems.unchecked = [...listItems.unchecked, ...listItems.checked]; + listItems.checked = []; + updateUncheckedListItems(); + } + } + + function deleteCheckedItems() { + const checked = shoppingList.value?.listItems?.filter(item => item.checked); + + if (!checked || checked?.length === 0) { + return; + } + + loadingCounter.value += 1; + deleteListItems(checked); + loadingCounter.value -= 1; + refresh(); + } + + // Individual item operations + function saveListItem(item: ShoppingListItemOut) { + if (!shoppingList.value) { + return; + } + + // set a temporary updatedAt timestamp prior to refresh so it appears at the top of the checked items + item.updatedAt = new Date().toISOString(); + + // make updates reflect immediately + if (shoppingList.value.listItems) { + shoppingList.value.listItems.forEach((oldListItem: ShoppingListItemOut, idx: number) => { + if (oldListItem.id === item.id && shoppingList.value?.listItems) { + shoppingList.value.listItems[idx] = item; + } + }); + // Immediately update checked/unchecked arrays for UI + listItems.unchecked = shoppingList.value.listItems.filter(i => !i.checked); + listItems.checked = shoppingList.value.listItems.filter(i => i.checked) + .sort(sortCheckedItems); + } + + // Update the item if it's checked, otherwise updateUncheckedListItems will handle it + if (item.checked) { + shoppingListItemActions.updateItem(item); + } + + updateListItemOrder(); + updateUncheckedListItems(); + } + + function deleteListItem(item: ShoppingListItemOut) { + if (!shoppingList.value) { + return; + } + + shoppingListItemActions.deleteItem(item); + + // remove the item from the list immediately so the user sees the change + if (shoppingList.value.listItems) { + shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => itm.id !== item.id); + } + + refresh(); + } + + function deleteListItems(items: ShoppingListItemOut[]) { + if (!shoppingList.value) { + return; + } + + items.forEach((item) => { + shoppingListItemActions.deleteItem(item); + }); + // remove the items from the list immediately so the user sees the change + if (shoppingList.value?.listItems) { + const deletedItems = new Set(items.map(item => item.id)); + shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => !deletedItems.has(itm.id)); + } + + refresh(); + } + + function createListItem() { + if (!shoppingList.value) { + return; + } + + if (!createListItemData.value.foodId && !createListItemData.value.note) { + // don't create an empty item + return; + } + + loadingCounter.value += 1; + + // make sure it's inserted into the end of the list, which may have been updated + createListItemData.value.position = shoppingList.value?.listItems?.length + ? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1 + : 0; + + createListItemData.value.createdAt = new Date().toISOString(); + createListItemData.value.updatedAt = createListItemData.value.createdAt; + + updateListItemOrder(); + + shoppingListItemActions.createItem(createListItemData.value); + loadingCounter.value -= 1; + + if (shoppingList.value.listItems) { + // add the item to the list immediately so the user sees the change + shoppingList.value.listItems.push(createListItemData.value); + updateListItemOrder(); + } + createListItemData.value = listItemFactory(createListItemData.value.isFood || false); + refresh(); + } + + function updateUncheckedListItems() { + if (!shoppingList.value?.listItems) { + return; + } + + // Set position for unchecked items + listItems.unchecked.forEach((item: ShoppingListItemOut, idx: number) => { + item.position = idx; + shoppingListItemActions.updateItem(item); + }); + + refresh(); + } + + // Label management + function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) { + if (!shoppingList.value) { + return; + } + + labelSettings.forEach((labelSetting, index) => { + labelSetting.position = index; + return labelSetting; + }); + + localLabels.value = labelSettings; + } + + function cancelLabelOrder() { + loadingCounter.value -= 1; + if (!shoppingList.value) { + return; + } + // restore original state + localLabels.value = shoppingList.value.labelSettings; + } + + async function saveLabelOrder(updateItemsByLabel: () => void) { + if (!shoppingList.value || !localLabels.value || (localLabels.value === shoppingList.value.labelSettings)) { + return; + } + + loadingCounter.value += 1; + const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, localLabels.value); + loadingCounter.value -= 1; + + if (data) { + // update shoppingList labels using the API response + shoppingList.value.labelSettings = (data as ShoppingListOut).labelSettings; + updateItemsByLabel(); + } + } + + function toggleReorderLabelsDialog(reorderLabelsDialog: Ref) { + // stop polling and populate localLabels + loadingCounter.value += 1; + reorderLabelsDialog.value = !reorderLabelsDialog.value; + localLabels.value = shoppingList.value?.labelSettings; + } + + // Context menu actions + const contextActions = { + delete: "delete", + setIngredient: "setIngredient", + }; + + const contextMenu = [ + { title: t("general.delete"), action: contextActions.delete }, + { title: t("recipe.ingredient"), action: contextActions.setIngredient }, + ]; + + function contextMenuAction(action: string, item: ShoppingListItemOut, idx: number) { + if (!shoppingList.value?.listItems) { + return; + } + + switch (action) { + case contextActions.delete: + shoppingList.value.listItems = shoppingList.value?.listItems.filter(itm => itm.id !== item.id); + break; + case contextActions.setIngredient: + shoppingList.value.listItems[idx].isFood = !shoppingList.value.listItems[idx].isFood; + break; + default: + break; + } + } + + return { + createListItemData, + localLabels, + listItemFactory, + checkAllItems, + uncheckAllItems, + deleteCheckedItems, + saveListItem, + deleteListItem, + deleteListItems, + createListItem, + updateUncheckedListItems, + updateLabelOrder, + cancelLabelOrder, + saveLabelOrder, + toggleReorderLabelsDialog, + contextActions, + contextMenu, + contextMenuAction, + }; +} diff --git a/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-data.ts b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-data.ts new file mode 100644 index 000000000..39f128a8e --- /dev/null +++ b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-data.ts @@ -0,0 +1,117 @@ +import { useOnline, useIdle } from "@vueuse/core"; +import type { ShoppingListOut } from "~/lib/api/types/household"; +import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions"; + +/** + * Composable for managing shopping list data fetching and polling + */ +export function useShoppingListData(listId: string, shoppingList: Ref, loadingCounter: Ref) { + const isOffline = computed(() => useOnline().value === false); + const { idle } = useIdle(5 * 60 * 1000); // 5 minutes + const shoppingListItemActions = useShoppingListItemActions(listId); + + async function fetchShoppingList() { + const data = await shoppingListItemActions.getList(); + return data; + } + + async function refresh(updateListItemOrder: () => void) { + loadingCounter.value += 1; + try { + await shoppingListItemActions.process(); + } + catch (error) { + console.error(error); + } + + let newListValue: typeof shoppingList.value = null; + try { + newListValue = await fetchShoppingList(); + } + catch (error) { + console.error(error); + } + + loadingCounter.value -= 1; + + // only update the list with the new value if we're not loading, to prevent UI jitter + if (loadingCounter.value) { + return; + } + + // Prevent overwriting local changes with stale backend data when offline + if (isOffline.value) { + // Do not update shoppingList.value from backend when offline + updateListItemOrder(); + return; + } + + // if we're not connected to the network, this will be null, so we don't want to clear the list + if (newListValue) { + shoppingList.value = newListValue; + } + + updateListItemOrder(); + } + + // constantly polls for changes + async function pollForChanges(updateListItemOrder: () => void) { + // pause polling if the user isn't active or we're busy + if (idle.value || loadingCounter.value) { + return; + } + + try { + await refresh(updateListItemOrder); + + if (shoppingList.value) { + attempts = 0; + return; + } + + // if the refresh was unsuccessful, the shopping list will be null, so we increment the attempt counter + attempts++; + } + catch { + attempts++; + } + + // if we hit too many errors, stop polling + if (attempts >= maxAttempts) { + clearInterval(pollTimer); + } + } + + // start polling + loadingCounter.value -= 1; + + // max poll time = pollFrequency * maxAttempts = 24 hours + // we use a long max poll time since polling stops when the user is idle anyway + const pollFrequency = 5000; + const maxAttempts = 17280; + let attempts = 0; + let pollTimer: ReturnType; + + function startPolling(updateListItemOrder: () => void) { + pollForChanges(updateListItemOrder); // populate initial list + + pollTimer = setInterval(() => { + pollForChanges(updateListItemOrder); + }, pollFrequency); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + } + } + + return { + isOffline, + fetchShoppingList, + refresh, + startPolling, + stopPolling, + shoppingListItemActions, + }; +} diff --git a/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-labels.ts b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-labels.ts new file mode 100644 index 000000000..3cd9d5c1b --- /dev/null +++ b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-labels.ts @@ -0,0 +1,73 @@ +import { useToggle } from "@vueuse/core"; +import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household"; + +/** + * Composable for managing shopping list label state and operations + */ +export function useShoppingListLabels(shoppingList: Ref) { + const { t } = useI18n(); + const labelOpenState = ref<{ [key: string]: boolean }>({}); + const [showChecked, toggleShowChecked] = useToggle(false); + + const initializeLabelOpenStates = () => { + if (!shoppingList.value?.listItems) return; + + const existingLabels = new Set(Object.keys(labelOpenState.value)); + let hasChanges = false; + + for (const item of shoppingList.value.listItems) { + const labelName = item.label?.name || t("shopping-list.no-label"); + if (!existingLabels.has(labelName) && !(labelName in labelOpenState.value)) { + labelOpenState.value[labelName] = true; + hasChanges = true; + } + } + + if (hasChanges) { + labelOpenState.value = { ...labelOpenState.value }; + } + }; + + const labelNames = computed(() => { + return new Set( + shoppingList.value?.listItems + ?.map(item => item.label?.name || t("shopping-list.no-label")) + .filter(Boolean) ?? [], + ); + }); + + watch(labelNames, initializeLabelOpenStates, { immediate: true }); + + function toggleShowLabel(key: string) { + labelOpenState.value[key] = !labelOpenState.value[key]; + } + + function getLabelColor(item: ShoppingListItemOut | null) { + return item?.label?.color; + } + + const presentLabels = computed(() => { + const labels: Array<{ id: string; name: string }> = []; + + shoppingList.value?.listItems?.forEach((item) => { + if (item.labelId && item.label) { + labels.push({ + name: item.label.name, + id: item.labelId, + }); + } + }); + + return labels; + }); + + return { + labelOpenState, + showChecked, + toggleShowChecked, + toggleShowLabel, + getLabelColor, + presentLabels, + initializeLabelOpenStates, + }; +} diff --git a/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-recipes.ts b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-recipes.ts new file mode 100644 index 000000000..38a8704da --- /dev/null +++ b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-recipes.ts @@ -0,0 +1,51 @@ +import type { ShoppingListOut } from "~/lib/api/types/household"; +import { useUserApi } from "~/composables/api"; + +/** + * Composable for managing shopping list recipe references + */ +export function useShoppingListRecipes( + shoppingList: Ref, + loadingCounter: Ref, + recipeReferenceLoading: Ref, + refresh: () => void, +) { + const userApi = useUserApi(); + + async function addRecipeReferenceToList(recipeId: string) { + if (!shoppingList.value || recipeReferenceLoading.value) { + return; + } + + loadingCounter.value += 1; + recipeReferenceLoading.value = true; + const { data } = await userApi.shopping.lists.addRecipes(shoppingList.value.id, [{ recipeId }]); + recipeReferenceLoading.value = false; + loadingCounter.value -= 1; + + if (data) { + refresh(); + } + } + + async function removeRecipeReferenceToList(recipeId: string) { + if (!shoppingList.value || recipeReferenceLoading.value) { + return; + } + + loadingCounter.value += 1; + recipeReferenceLoading.value = true; + const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId); + recipeReferenceLoading.value = false; + loadingCounter.value -= 1; + + if (data) { + refresh(); + } + } + + return { + addRecipeReferenceToList, + removeRecipeReferenceToList, + }; +} diff --git a/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-sorting.ts b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-sorting.ts new file mode 100644 index 000000000..802162250 --- /dev/null +++ b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-sorting.ts @@ -0,0 +1,135 @@ +import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household"; + +interface ListItemGroup { + position: number; + createdAt: string; + items: ShoppingListItemOut[]; +} + +/** + * Composable for managing shopping list item sorting and organization + */ +export function useShoppingListSorting() { + const { t } = useI18n(); + + function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) { + // Sort by position ASC, then by createdAt ASC + const posA = a.position ?? 0; + const posB = b.position ?? 0; + if (posA !== posB) { + return posA - posB; + } + const createdA = a.createdAt ?? ""; + const createdB = b.createdAt ?? ""; + if (createdA !== createdB) { + return createdA < createdB ? -1 : 1; + } + return 0; + } + + function groupAndSortListItemsByFood(shoppingList: ShoppingListOut) { + if (!shoppingList?.listItems?.length) { + return; + } + + const checkedItemKey = "__checkedItem"; + const listItemGroupsMap = new Map(); + listItemGroupsMap.set(checkedItemKey, { position: Number.MAX_SAFE_INTEGER, createdAt: "", items: [] }); + + // group items by checked status, food, or note + shoppingList.listItems.forEach((item) => { + const key = item.checked + ? checkedItemKey + : item.isFood && item.food?.name + ? item.food.name + : item.note || ""; + + const group = listItemGroupsMap.get(key); + if (!group) { + listItemGroupsMap.set(key, { position: item.position || 0, createdAt: item.createdAt || "", items: [item] }); + } + else { + group.items.push(item); + } + }); + + const listItemGroups = Array.from(listItemGroupsMap.values()); + listItemGroups.sort(sortItems); + + // sort group items, then aggregate them + const sortedItems: ShoppingListItemOut[] = []; + let nextPosition = 0; + listItemGroups.forEach((listItemGroup) => { + listItemGroup.items.sort(sortItems); + listItemGroup.items.forEach((item) => { + item.position = nextPosition; + nextPosition += 1; + sortedItems.push(item); + }); + }); + + shoppingList.listItems = sortedItems; + } + + function sortListItems(shoppingList: ShoppingListOut) { + if (!shoppingList?.listItems?.length) { + return; + } + + shoppingList.listItems.sort(sortItems); + } + + function updateItemsByLabel(shoppingList: ShoppingListOut) { + const items: { [prop: string]: ShoppingListItemOut[] } = {}; + const noLabelText = t("shopping-list.no-label"); + const noLabel = [] as ShoppingListItemOut[]; + + shoppingList?.listItems?.forEach((item) => { + if (item.checked) { + return; + } + + if (item.labelId) { + if (item.label && item.label.name in items) { + items[item.label.name].push(item); + } + else if (item.label) { + items[item.label.name] = [item]; + } + } + else { + noLabel.push(item); + } + }); + + if (noLabel.length > 0) { + items[noLabelText] = noLabel; + } + + // sort the map by label order + const orderedLabelNames = shoppingList?.labelSettings?.map(labelSetting => labelSetting.label.name); + if (!orderedLabelNames) { + return items; + } + + const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {}; + if (noLabelText in items) { + itemsSorted[noLabelText] = items[noLabelText]; + } + + orderedLabelNames.forEach((labelName) => { + if (labelName in items) { + itemsSorted[labelName] = items[labelName]; + } + }); + + return itemsSorted; + } + + return { + sortItems, + groupAndSortListItemsByFood, + sortListItems, + updateItemsByLabel, + }; +} diff --git a/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-state.ts b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-state.ts new file mode 100644 index 000000000..7e4538f7c --- /dev/null +++ b/frontend/composables/shopping-list-page/sub-composables/use-shopping-list-state.ts @@ -0,0 +1,67 @@ +import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household"; + +/** + * Composable for managing shopping list state and reactive data + */ +export function useShoppingListState(listId: string) { + const shoppingList = ref(null); + const loadingCounter = ref(1); + const recipeReferenceLoading = ref(false); + const preserveItemOrder = ref(false); + + // UI state + const edit = ref(false); + const threeDot = ref(false); + const reorderLabelsDialog = ref(false); + const createEditorOpen = ref(false); + + // Dialog states + const state = reactive({ + checkAllDialog: false, + uncheckAllDialog: false, + deleteCheckedDialog: false, + }); + + // Hydrate listItems from shoppingList.value?.listItems + const listItems = reactive({ + unchecked: [] as ShoppingListItemOut[], + checked: [] as ShoppingListItemOut[], + }); + + function sortCheckedItems(a: ShoppingListItemOut, b: ShoppingListItemOut) { + if (a.updatedAt! === b.updatedAt!) { + return ((a.position || 0) > (b.position || 0)) ? -1 : 1; + } + return a.updatedAt! < b.updatedAt! ? 1 : -1; + } + + watch( + () => shoppingList.value?.listItems, + (items) => { + listItems.unchecked = (items?.filter(item => !item.checked) ?? []); + listItems.checked = (items?.filter(item => item.checked) + .sort(sortCheckedItems) ?? []); + }, + { immediate: true }, + ); + + const recipeMap = computed(() => new Map( + (shoppingList.value?.recipeReferences?.map(ref => ref.recipe) ?? []) + .map(recipe => [recipe.id || "", recipe])), + ); + + return { + shoppingList, + loadingCounter, + recipeReferenceLoading, + preserveItemOrder, + edit, + threeDot, + reorderLabelsDialog, + createEditorOpen, + state, + listItems, + recipeMap, + sortCheckedItems, + }; +} diff --git a/frontend/composables/shopping-list-page/use-shopping-list-page.ts b/frontend/composables/shopping-list-page/use-shopping-list-page.ts new file mode 100644 index 000000000..06807807c --- /dev/null +++ b/frontend/composables/shopping-list-page/use-shopping-list-page.ts @@ -0,0 +1,209 @@ +import type { ShoppingListItemOut } from "~/lib/api/types/household"; +import { useShoppingListState } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-state"; +import { useShoppingListData } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-data"; +import { useShoppingListSorting } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-sorting"; +import { useShoppingListLabels } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-labels"; +import { useShoppingListCopy } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-copy"; +import { useShoppingListCrud } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-crud"; +import { useShoppingListRecipes } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-recipes"; + +/** + * Main composable that orchestrates all shopping list page functionality + */ +export function useShoppingListPage(listId: string) { + // Initialize state + const state = useShoppingListState(listId); + const { + shoppingList, + loadingCounter, + recipeReferenceLoading, + preserveItemOrder, + listItems, + sortCheckedItems, + recipeMap, + } = state; + + // Initialize sorting functionality + const sorting = useShoppingListSorting(); + const { groupAndSortListItemsByFood, sortListItems, updateItemsByLabel } = sorting; + + // Track items organized by label + const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({}); + + function updateListItemOrder() { + if (!shoppingList.value) return; + + if (!preserveItemOrder.value) { + groupAndSortListItemsByFood(shoppingList.value); + } else { + sortListItems(shoppingList.value); + } + + const labeledItems = updateItemsByLabel(shoppingList.value); + if (labeledItems) { + itemsByLabel.value = labeledItems; + } + } + + // Initialize data management + const dataManager = useShoppingListData(listId, shoppingList, loadingCounter); + const { isOffline, refresh: baseRefresh, startPolling, stopPolling, shoppingListItemActions } = dataManager; + + const refresh = () => baseRefresh(updateListItemOrder); + + // Initialize labels + const labels = useShoppingListLabels(shoppingList); + + // Initialize copy functionality + const copyManager = useShoppingListCopy(); + + // Initialize CRUD operations + const crud = useShoppingListCrud( + shoppingList, + loadingCounter, + listItems, + shoppingListItemActions, + refresh, + sortCheckedItems, + updateListItemOrder, + ); + + // Initialize recipe management + const recipes = useShoppingListRecipes( + shoppingList, + loadingCounter, + recipeReferenceLoading, + refresh, + ); + + // Handle item reordering by label + function updateIndexUncheckedByLabel(labelName: string, labeledUncheckedItems: ShoppingListItemOut[]) { + if (!itemsByLabel.value[labelName]) { + return; + } + + // update this label's item order + itemsByLabel.value[labelName] = labeledUncheckedItems; + + // reset list order of all items + const allUncheckedItems: ShoppingListItemOut[] = []; + for (const labelKey in itemsByLabel.value) { + allUncheckedItems.push(...itemsByLabel.value[labelKey]); + } + + // since the user has manually reordered the list, we should preserve this order + preserveItemOrder.value = true; + + // save changes + listItems.unchecked = allUncheckedItems; + listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || []; + crud.updateUncheckedListItems(); + } + + // Dialog helpers + function openCheckAll() { + if (shoppingList.value?.listItems?.some(item => !item.checked)) { + state.state.checkAllDialog = true; + } + } + + function openUncheckAll() { + if (shoppingList.value?.listItems?.some(item => item.checked)) { + state.state.uncheckAllDialog = true; + } + } + + function openDeleteChecked() { + if (shoppingList.value?.listItems?.some(item => item.checked)) { + state.state.deleteCheckedDialog = true; + } + } + + function checkAll() { + state.state.checkAllDialog = false; + crud.checkAllItems(); + } + + function uncheckAll() { + state.state.uncheckAllDialog = false; + crud.uncheckAllItems(); + } + + function deleteChecked() { + state.state.deleteCheckedDialog = false; + crud.deleteCheckedItems(); + } + + // Copy functionality wrapper + function copyListItems(copyType: "plain" | "markdown") { + copyManager.copyListItems(itemsByLabel.value, copyType); + } + + // Label reordering helpers + function toggleReorderLabelsDialog() { + crud.toggleReorderLabelsDialog(state.reorderLabelsDialog); + } + + async function saveLabelOrder() { + await crud.saveLabelOrder(() => { + const labeledItems = updateItemsByLabel(shoppingList.value!); + if (labeledItems) { + itemsByLabel.value = labeledItems; + } + }); + } + + // Lifecycle management + onMounted(() => { + startPolling(updateListItemOrder); + }); + + onUnmounted(() => { + stopPolling(); + }); + + return { + // State - flatten the dialog state for easier access + ...state.state, + shoppingList, + loadingCounter, + recipeReferenceLoading, + preserveItemOrder, + edit: state.edit, + threeDot: state.threeDot, + reorderLabelsDialog: state.reorderLabelsDialog, + createEditorOpen: state.createEditorOpen, + listItems, + recipeMap, + itemsByLabel, + isOffline, + + // Labels + ...labels, + + // CRUD operations + ...crud, + + // Recipe management + ...recipes, + + // Specialized functions + updateIndexUncheckedByLabel, + copyListItems, + + // Dialog actions + openCheckAll, + openUncheckAll, + openDeleteChecked, + checkAll, + uncheckAll, + deleteChecked, + + // Label management + toggleReorderLabelsDialog, + saveLabelOrder, + + // Data refresh + refresh, + }; +} diff --git a/frontend/pages/shopping-lists/[id].vue b/frontend/pages/shopping-lists/[id].vue index 511096015..ee8be924c 100644 --- a/frontend/pages/shopping-lists/[id].vue +++ b/frontend/pages/shopping-lists/[id].vue @@ -367,26 +367,14 @@