feat: Remove Not-Sort-By-Label and Refactor Shopping List Page (#5866)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2025-08-14 00:23:11 -05:00 committed by GitHub
commit 31530a68e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1030 additions and 932 deletions

View file

@ -1,5 +1,5 @@
<template> <template>
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'"> <v-list :class="tile ? 'd-flex flex-wrap background' : 'background'" style="background-color: transparent;">
<v-sheet <v-sheet
v-for="recipe, index in recipes" v-for="recipe, index in recipes"
:key="recipe.id" :key="recipe.id"

View file

@ -20,6 +20,7 @@
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn <v-btn
size="small" size="small"
variant="text"
class="ml-2 handle" class="ml-2 handle"
icon icon
v-bind="props" v-bind="props"

View file

@ -13,7 +13,7 @@
v-model="listItem.checked" v-model="listItem.checked"
hide-details hide-details
density="compact" density="compact"
class="mt-0" class="mt-0 flex-shrink-0"
color="null" color="null"
@change="$emit('checked', listItem)" @change="$emit('checked', listItem)"
/> />
@ -27,16 +27,6 @@
</div> </div>
</v-col> </v-col>
<v-spacer /> <v-spacer />
<v-col
v-if="label && showLabel"
cols="3"
class="text-right"
>
<MultiPurposeLabel
:label="label"
size="small"
/>
</v-col>
<v-col <v-col
cols="auto" cols="auto"
class="text-right" class="text-right"
@ -75,27 +65,6 @@
</template> </template>
<span>Toggle Recipes</span> <span>Toggle Recipes</span>
</v-tooltip> </v-tooltip>
<!-- Dummy button so the spacing is consistent when labels are enabled -->
<v-btn
v-else
size="small"
variant="text"
class="ml-2"
icon
disabled
/>
<v-btn
size="small"
variant="text"
class="ml-2 handle"
icon
v-bind="props"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
<v-btn <v-btn
size="small" size="small"
variant="text" variant="text"
@ -107,6 +76,17 @@
{{ $globals.icons.edit }} {{ $globals.icons.edit }}
</v-icon> </v-icon>
</v-btn> </v-btn>
<v-btn
size="small"
variant="text"
class="handle"
icon
v-bind="props"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template> </template>
<v-list density="compact"> <v-list density="compact">
<v-list-item <v-list-item
@ -177,7 +157,6 @@
import { useOnline } from "@vueuse/core"; import { useOnline } from "@vueuse/core";
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
import type { ShoppingListItemOut } from "~/lib/api/types/household"; import type { ShoppingListItemOut } from "~/lib/api/types/household";
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels"; import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
@ -189,16 +168,12 @@ interface actions {
} }
export default defineNuxtComponent({ export default defineNuxtComponent({
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem }, components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem },
props: { props: {
modelValue: { modelValue: {
type: Object as () => ShoppingListItemOut, type: Object as () => ShoppingListItemOut,
required: true, required: true,
}, },
showLabel: {
type: Boolean,
default: false,
},
labels: { labels: {
type: Array as () => MultiPurposeLabelOut[], type: Array as () => MultiPurposeLabelOut[],
required: true, required: true,
@ -220,7 +195,7 @@ export default defineNuxtComponent({
setup(props, context) { setup(props, context) {
const i18n = useI18n(); const i18n = useI18n();
const displayRecipeRefs = ref(false); const displayRecipeRefs = ref(false);
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : props.showLabel ? "4" : "6"); const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : "6");
const isOffline = computed(() => useOnline().value === false); const isOffline = computed(() => useOnline().value === false);
const contextMenu: actions[] = [ const contextMenu: actions[] = [
@ -305,7 +280,7 @@ export default defineNuxtComponent({
} }
listItem.value.recipeReferences.forEach((ref) => { listItem.value.recipeReferences.forEach((ref) => {
const recipe = props.recipes.get(ref.recipeId); const recipe = props.recipes?.get(ref.recipeId);
if (recipe) { if (recipe) {
recipeList.push(recipe); recipeList.push(recipe);
} }

View file

@ -0,0 +1,50 @@
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[] = [];
Object.entries(itemsByLabel).forEach(([label, items], idx) => {
if (idx) {
text.push("");
}
text.push(formatCopiedLabelHeading(copyType, 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,
};
}

View file

@ -0,0 +1,263 @@
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<ShoppingListOut | null>,
loadingCounter: Ref<number>,
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<ShoppingListItemOut>(listItemFactory());
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>();
function listItemFactory(): ShoppingListItemOut {
return {
id: uuid4(),
shoppingListId: shoppingList.value?.id || "",
checked: false,
position: shoppingList.value?.listItems?.length || 1,
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();
}
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();
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<boolean>) {
// stop polling and populate localLabels
loadingCounter.value += 1;
reorderLabelsDialog.value = !reorderLabelsDialog.value;
localLabels.value = shoppingList.value?.labelSettings;
}
// Context menu actions
const contextActions = {
delete: "delete",
};
const contextMenu = [
{ title: t("general.delete"), action: contextActions.delete },
];
return {
createListItemData,
localLabels,
listItemFactory,
checkAllItems,
uncheckAllItems,
deleteCheckedItems,
saveListItem,
deleteListItem,
deleteListItems,
createListItem,
updateUncheckedListItems,
updateLabelOrder,
cancelLabelOrder,
saveLabelOrder,
toggleReorderLabelsDialog,
contextActions,
contextMenu,
};
}

View file

@ -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<ShoppingListOut | null>, loadingCounter: Ref<number>) {
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<typeof setInterval>;
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,
};
}

View file

@ -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<ShoppingListOut | null>) {
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,
};
}

View file

@ -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<ShoppingListOut | null>,
loadingCounter: Ref<number>,
recipeReferenceLoading: Ref<boolean>,
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,
};
}

View file

@ -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<string, ListItemGroup>();
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.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,
};
}

View file

@ -0,0 +1,70 @@
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
/**
* Composable for managing shopping list state and reactive data
*/
export function useShoppingListState() {
const shoppingList = ref<ShoppingListOut | null>(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])),
);
const recipeList = computed(() => Array.from(recipeMap.value.values()));
return {
shoppingList,
loadingCounter,
recipeReferenceLoading,
preserveItemOrder,
edit,
threeDot,
reorderLabelsDialog,
createEditorOpen,
state,
listItems,
recipeMap,
recipeList,
sortCheckedItems,
};
}

View file

@ -0,0 +1,194 @@
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();
const {
shoppingList,
loadingCounter,
recipeReferenceLoading,
preserveItemOrder,
listItems,
sortCheckedItems,
} = 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 shopping list 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 {
itemsByLabel,
isOffline,
// Sub-composables
...state,
...labels,
...crud,
...recipes,
// Specialized functions
updateIndexUncheckedByLabel,
copyListItems,
// Dialog actions
openCheckAll,
openUncheckAll,
openDeleteChecked,
checkAll,
uncheckAll,
deleteChecked,
// Label management
toggleReorderLabelsDialog,
saveLabelOrder,
// Data refresh
refresh,
};
}

View file

@ -33,7 +33,6 @@ export interface UserRecipePreferences {
export interface UserShoppingListPreferences { export interface UserShoppingListPreferences {
viewAllLists: boolean; viewAllLists: boolean;
viewByLabel: boolean;
} }
export interface UserTimelinePreferences { export interface UserTimelinePreferences {
@ -129,7 +128,6 @@ export function useShoppingListPreferences(): Ref<UserShoppingListPreferences> {
"shopping-list-preferences", "shopping-list-preferences",
{ {
viewAllLists: false, viewAllLists: false,
viewByLabel: true,
}, },
{ mergeDefaults: true }, { mergeDefaults: true },
// we cast to a Ref because by default it will return an optional type ref // we cast to a Ref because by default it will return an optional type ref

View file

@ -4,7 +4,7 @@
class="md-container" class="md-container"
> >
<BaseDialog <BaseDialog
v-model="checkAllDialog" v-model="state.checkAllDialog"
:title="$t('general.confirm')" :title="$t('general.confirm')"
can-confirm can-confirm
@confirm="checkAll" @confirm="checkAll"
@ -15,7 +15,7 @@
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
v-model="uncheckAllDialog" v-model="state.uncheckAllDialog"
:title="$t('general.confirm')" :title="$t('general.confirm')"
can-confirm can-confirm
@confirm="uncheckAll" @confirm="uncheckAll"
@ -26,7 +26,7 @@
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
v-model="deleteCheckedDialog" v-model="state.deleteCheckedDialog"
:title="$t('general.confirm')" :title="$t('general.confirm')"
can-confirm can-confirm
@confirm="deleteChecked" @confirm="deleteChecked"
@ -90,11 +90,6 @@
text: '', text: '',
event: 'three-dot', event: 'three-dot',
children: [ children: [
{
icon: $globals.icons.tags,
text: $t('shopping-list.toggle-label-sort'),
event: 'sort-by-labels',
},
{ {
icon: $globals.icons.tags, icon: $globals.icons.tags,
text: $t('shopping-list.reorder-labels'), text: $t('shopping-list.reorder-labels'),
@ -111,7 +106,6 @@
@edit="edit = true" @edit="edit = true"
@three-dot="threeDot = true" @three-dot="threeDot = true"
@check="openCheckAll" @check="openCheckAll"
@sort-by-labels="sortByLabels"
@copy-plain="copyListItems('plain')" @copy-plain="copyListItems('plain')"
@copy-markdown="copyListItems('markdown')" @copy-markdown="copyListItems('markdown')"
@reorder-labels="toggleReorderLabelsDialog()" @reorder-labels="toggleReorderLabelsDialog()"
@ -159,40 +153,6 @@
</BaseButton> </BaseButton>
</div> </div>
<!-- View without Label grouping -->
<div v-if="!preferences.viewByLabel">
<VueDraggable
v-model="listItems.unchecked"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
@start="loadingCounter += 1"
@end="loadingCounter -= 1"
@update:model-value="updateIndexUnchecked"
>
<v-lazy
v-for="(item, index) in listItems.unchecked"
:key="item.id"
class="my-2"
>
<ShoppingListItem
v-model="listItems.unchecked[index]"
class="my-2 my-sm-0"
:show-label="true"
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
:recipes="recipeMap"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
/>
</v-lazy>
</VueDraggable>
</div>
<!-- View By Label -->
<div v-else>
<div <div
v-for="(value, key) in itemsByLabel" v-for="(value, key) in itemsByLabel"
:key="key" :key="key"
@ -230,7 +190,6 @@
> >
<ShoppingListItem <ShoppingListItem
v-model="value[index]" v-model="value[index]"
:show-label="false"
:labels="allLabels || []" :labels="allLabels || []"
:units="allUnits || []" :units="allUnits || []"
:foods="allFoods || []" :foods="allFoods || []"
@ -244,7 +203,6 @@
</div> </div>
</v-expand-transition> </v-expand-transition>
</div> </div>
</div>
<!-- Reorder Labels --> <!-- Reorder Labels -->
<BaseDialog <BaseDialog
@ -359,12 +317,12 @@
</div> </div>
<v-divider class="my-4" /> <v-divider class="my-4" />
<RecipeList <RecipeList
:recipes="Array.from(recipeMap.values())" :recipes="recipeList"
show-description show-description
:disabled="isOffline" :disabled="isOffline"
> >
<template <template
v-for="(recipe, index) in recipeMap.values()" v-for="(recipe, index) in recipeList"
#[`actions-${recipe.id}`] #[`actions-${recipe.id}`]
:key="'item-actions-decrease' + recipe.id" :key="'item-actions-decrease' + recipe.id"
> >
@ -408,26 +366,14 @@
<script lang="ts"> <script lang="ts">
import { VueDraggable } from "vue-draggable-plus"; import { VueDraggable } from "vue-draggable-plus";
import { useIdle, useOnline, useToggle } from "@vueuse/core";
import { useCopyList } from "~/composables/use-copy";
import { useUserApi } from "~/composables/api";
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"; import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue";
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import type { ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions";
import { useShoppingListPreferences } from "~/composables/use-users/preferences"; import { useShoppingListPreferences } from "~/composables/use-users/preferences";
import { getTextColor } from "~/composables/use-text-color"; import { getTextColor } from "~/composables/use-text-color";
import { uuid4 } from "~/composables/use-utils"; import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page";
type CopyTypes = "plain" | "markdown";
interface PresentLabel {
id: string;
name: string;
}
export default defineNuxtComponent({ export default defineNuxtComponent({
components: { components: {
@ -437,809 +383,34 @@ export default defineNuxtComponent({
RecipeList, RecipeList,
ShoppingListItemEditor, ShoppingListItemEditor,
}, },
// middleware: "sidebase-auth",
setup() { setup() {
const { mdAndUp } = useDisplay(); const { mdAndUp } = useDisplay();
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const preferences = useShoppingListPreferences(); const preferences = useShoppingListPreferences();
const isOffline = computed(() => useOnline().value === false);
useSeoMeta({ useSeoMeta({
title: i18n.t("shopping-list.shopping-list"), title: i18n.t("shopping-list.shopping-list"),
}); });
const { idle } = useIdle(5 * 60 * 1000); // 5 minutes
const loadingCounter = ref(1);
const recipeReferenceLoading = ref(false);
const userApi = useUserApi();
const edit = ref(false);
const threeDot = ref(false);
const reorderLabelsDialog = ref(false);
const preserveItemOrder = ref(false);
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const id = route.params.id as string; const id = route.params.id as string;
const shoppingListItemActions = useShoppingListItemActions(id);
const state = reactive({
checkAllDialog: false,
uncheckAllDialog: false,
deleteCheckedDialog: false,
});
// ===============================================================
// Shopping List Actions
const shoppingList = ref<ShoppingListOut | null>(null);
async function fetchShoppingList() {
const data = await shoppingListItemActions.getList();
return data;
}
async function refresh() {
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();
}
function updateListItemOrder() {
if (!preserveItemOrder.value) {
groupAndSortListItemsByFood();
}
else {
sortListItems();
}
updateItemsByLabel();
}
// constantly polls for changes
async function pollForChanges() {
// pause polling if the user isn't active or we're busy
if (idle.value || loadingCounter.value) {
return;
}
try {
await refresh();
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;
pollForChanges(); // populate initial list
// 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;
const pollTimer: ReturnType<typeof setInterval> = setInterval(() => {
pollForChanges();
}, pollFrequency);
onUnmounted(() => {
clearInterval(pollTimer);
});
// =====================================
// List Item CRUD
// 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 },
);
// =====================================
// Collapsable Labels
const labelOpenState = ref<{ [key: string]: boolean }>({});
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 || i18n.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 || i18n.t("shopping-list.no-label"))
.filter(Boolean) ?? [],
);
});
watch(labelNames, initializeLabelOpenStates, { immediate: true });
function toggleShowLabel(key: string) {
labelOpenState.value[key] = !labelOpenState.value[key];
}
const [showChecked, toggleShowChecked] = useToggle(false);
// =====================================
// Copy List Items
const copy = useCopyList();
function copyListItems(copyType: CopyTypes) {
const text: string[] = [];
if (preferences.value.viewByLabel) {
// if we're sorting by label, we want the copied text in subsections
Object.entries(itemsByLabel.value).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)));
});
}
else {
// labels are toggled off, so just copy in the order they come in
const items = shoppingList.value?.listItems?.filter(item => !item.checked);
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}]`;
}
}
// =====================================
// Check / Uncheck All
function openCheckAll() {
if (shoppingList.value?.listItems?.some(item => !item.checked)) {
state.checkAllDialog = true;
}
}
function checkAll() {
state.checkAllDialog = false;
let hasChanged = false;
shoppingList.value?.listItems?.forEach((item) => {
if (!item.checked) {
hasChanged = true;
item.checked = true;
}
});
if (hasChanged) {
updateUncheckedListItems();
}
}
function openUncheckAll() {
if (shoppingList.value?.listItems?.some(item => item.checked)) {
state.uncheckAllDialog = true;
}
}
function uncheckAll() {
state.uncheckAllDialog = false;
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 openDeleteChecked() {
if (shoppingList.value?.listItems?.some(item => item.checked)) {
state.deleteCheckedDialog = true;
}
}
function deleteChecked() {
const checked = shoppingList.value?.listItems?.filter(item => item.checked);
if (!checked || checked?.length === 0) {
return;
}
loadingCounter.value += 1;
deleteListItems(checked);
loadingCounter.value -= 1;
refresh();
}
// =====================================
// List Item Context Menu
const contextActions = {
delete: "delete",
};
const contextMenu = [
{ title: i18n.t("general.delete"), action: contextActions.delete },
];
// =====================================
// Labels, Units, Foods
// TODO: Extract to Composable
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>();
const shoppingListPage = useShoppingListPage(id);
const { store: allLabels } = useLabelStore(); const { store: allLabels } = useLabelStore();
const { store: allUnits } = useUnitStore(); const { store: allUnits } = useUnitStore();
const { store: allFoods } = useFoodStore(); const { store: allFoods } = useFoodStore();
function getLabelColor(item: ShoppingListItemOut | null) {
return item?.label?.color;
}
function sortByLabels() {
preferences.value.viewByLabel = !preferences.value.viewByLabel;
}
function toggleReorderLabelsDialog() {
// stop polling and populate localLabels
loadingCounter.value += 1;
reorderLabelsDialog.value = !reorderLabelsDialog.value;
localLabels.value = shoppingList.value?.labelSettings;
}
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() {
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();
}
}
const presentLabels = computed(() => {
const labels: PresentLabel[] = [];
shoppingList.value?.listItems?.forEach((item) => {
if (item.labelId && item.label) {
labels.push({
name: item.label.name,
id: item.labelId,
});
}
});
return labels;
});
const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});
interface ListItemGroup {
position: number;
createdAt: string;
items: ShoppingListItemOut[];
}
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() {
if (!shoppingList.value?.listItems?.length) {
return;
}
const checkedItemKey = "__checkedItem";
const listItemGroupsMap = new Map<string, ListItemGroup>();
listItemGroupsMap.set(checkedItemKey, { position: Number.MAX_SAFE_INTEGER, createdAt: "", items: [] });
// group items by checked status, food, or note
shoppingList.value.listItems.forEach((item) => {
const key = item.checked
? checkedItemKey
: 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.value.listItems = sortedItems;
}
function sortListItems() {
if (!shoppingList.value?.listItems?.length) {
return;
}
shoppingList.value.listItems.sort(sortItems);
}
function updateItemsByLabel() {
const items: { [prop: string]: ShoppingListItemOut[] } = {};
const noLabelText = i18n.t("shopping-list.no-label");
const noLabel = [] as ShoppingListItemOut[];
shoppingList.value?.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.value?.labelSettings?.map(labelSetting => labelSetting.label.name);
if (!orderedLabelNames) {
itemsByLabel.value = items;
return;
}
const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {};
if (noLabelText in items) {
itemsSorted[noLabelText] = items[noLabelText];
}
orderedLabelNames.forEach((labelName) => {
if (labelName in items) {
itemsSorted[labelName] = items[labelName];
}
});
itemsByLabel.value = itemsSorted;
}
// =====================================
// Add/Remove Recipe References
const recipeMap = computed(() => new Map(
(shoppingList.value?.recipeReferences?.map(ref => ref.recipe) ?? [])
.map(recipe => [recipe.id || "", recipe])),
);
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();
}
}
// =====================================
// List Item CRUD
/*
* saveListItem updates and update on the backend server. Additionally, if the item is
* checked it will also append that item to the end of the list so that the unchecked items
* are at the top of the list.
*/
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();
}
// =====================================
// Create New Item
const createEditorOpen = ref(false);
const createListItemData = ref<ShoppingListItemOut>(listItemFactory());
function listItemFactory(): ShoppingListItemOut {
return { return {
id: uuid4(),
shoppingListId: id,
checked: false,
position: shoppingList.value?.listItems?.length || 1,
quantity: 0,
note: "",
labelId: undefined,
unitId: undefined,
foodId: undefined,
} as ShoppingListItemOut;
}
/* const newMeal = reactive({
date: "",
title: "",
text: "",
recipeId: undefined as string | undefined,
entryType: "dinner" as PlanEntryType,
existing: false,
id: 0,
groupId: "",
userId: $auth.user.value?.id || "",
}); */
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();
refresh();
}
function updateIndexUnchecked(uncheckedItems: ShoppingListItemOut[]) {
listItems.unchecked = uncheckedItems;
listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || [];
// since the user has manually reordered the list, we should preserve this order
preserveItemOrder.value = true;
updateUncheckedListItems();
}
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 (labelName in itemsByLabel.value) {
allUncheckedItems.push(...itemsByLabel.value[labelName]);
}
// 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) || [];
updateUncheckedListItems();
}
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 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();
}
return {
...toRefs(state),
addRecipeReferenceToList,
allLabels,
contextMenu,
copyListItems,
createEditorOpen,
createListItem,
createListItemData,
deleteChecked,
openDeleteChecked,
deleteListItem,
edit,
threeDot,
getLabelColor,
groupSlug, groupSlug,
itemsByLabel,
listItems,
loadingCounter,
preferences, preferences,
presentLabels, allLabels,
recipeMap,
removeRecipeReferenceToList,
reorderLabelsDialog,
toggleReorderLabelsDialog,
localLabels,
updateLabelOrder,
cancelLabelOrder,
saveLabelOrder,
saveListItem,
shoppingList,
showChecked,
sortByLabels,
labelOpenState,
toggleShowLabel,
toggleShowChecked,
uncheckAll,
openUncheckAll,
checkAll,
openCheckAll,
updateIndexUnchecked,
updateIndexUncheckedByLabel,
allUnits, allUnits,
allFoods, allFoods,
getTextColor, getTextColor,
isOffline,
mdAndUp, mdAndUp,
...shoppingListPage,
}; };
}, },
}); });