Skip to content

Commit b153ddf

Browse files
feat: more shopping list enhancements (#2587)
* fix new position calculataion * ensure consistent list item ordering * fix recipe ref overflow on small screens * added recipe ref elevation * tweaked line height (for long notes) * removed unused user dependency * remove old shopping list items when there's >100 * 🤷 * cleaned up function generator * fixed test * fix potential type error * made max position calc more efficient
1 parent f35bc77 commit b153ddf

9 files changed

Lines changed: 189 additions & 8 deletions

File tree

frontend/components/Domain/Recipe/RecipeIngredientListItem.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default defineComponent({
5454
}
5555
5656
.note {
57-
line-height: 0.8em;
57+
line-height: 1.25em;
5858
font-size: 0.8em;
5959
opacity: 0.7;
6060
}

frontend/components/Domain/Recipe/RecipeList.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
<template>
22
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'">
3-
<v-sheet v-for="recipe, index in recipes" :key="recipe.id" :class="attrs.class.sheet" :style="tile ? 'width: fit-content;' : 'width: 100%;'">
3+
<v-sheet
4+
v-for="recipe, index in recipes"
5+
:key="recipe.id"
6+
:elevation="2"
7+
:class="attrs.class.sheet"
8+
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
9+
>
410
<v-list-item :to="'/recipe/' + recipe.slug" :class="attrs.class.listItem">
511
<v-list-item-avatar :class="attrs.class.avatar">
612
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>

frontend/pages/shopping-lists/_id.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export default defineComponent({
268268
// only update the list with the new value if we're not loading, to prevent UI jitter
269269
if (!loadingCounter.value) {
270270
shoppingList.value = newListValue;
271+
sortListItems();
271272
updateItemsByLabel();
272273
}
273274
}
@@ -473,6 +474,15 @@ export default defineComponent({
473474
474475
const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});
475476
477+
function sortListItems() {
478+
if (!shoppingList.value?.listItems?.length) {
479+
return;
480+
}
481+
482+
// sort by position ascending, then createdAt descending
483+
shoppingList.value.listItems.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1))
484+
}
485+
476486
function updateItemsByLabel() {
477487
const items: { [prop: string]: ShoppingListItemOut[] } = {};
478488
const noLabelText = i18n.tc("shopping-list.no-label");
@@ -603,6 +613,7 @@ export default defineComponent({
603613
});
604614
}
605615
616+
sortListItems();
606617
updateItemsByLabel();
607618
608619
loadingCounter.value += 1;
@@ -656,7 +667,9 @@ export default defineComponent({
656667
loadingCounter.value += 1;
657668
658669
// make sure it's inserted into the end of the list, which may have been updated
659-
createListItemData.value.position = shoppingList.value?.listItems?.length || 1;
670+
createListItemData.value.position = shoppingList.value?.listItems?.length
671+
? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1
672+
: 0;
660673
const { data } = await userApi.shopping.items.createOne(createListItemData.value);
661674
loadingCounter.value -= 1;
662675

mealie/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async def start_scheduler():
5757
tasks.purge_password_reset_tokens,
5858
tasks.purge_group_data_exports,
5959
tasks.create_mealplan_timeline_events,
60+
tasks.delete_old_checked_list_items,
6061
)
6162

6263
SchedulerRegistry.register_minutely(

mealie/routes/groups/controller_shopping_lists.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def publish_list_item_events(publisher: Callable, items_collection: ShoppingList
8989
class ShoppingListItemController(BaseCrudController):
9090
@cached_property
9191
def service(self):
92-
return ShoppingListService(self.repos, self.user, self.group)
92+
return ShoppingListService(self.repos, self.group)
9393

9494
@cached_property
9595
def repo(self):
@@ -154,7 +154,7 @@ def delete_one(self, item_id: UUID4):
154154
class ShoppingListController(BaseCrudController):
155155
@cached_property
156156
def service(self):
157-
return ShoppingListService(self.repos, self.user, self.group)
157+
return ShoppingListService(self.repos, self.group)
158158

159159
@cached_property
160160
def repo(self):

mealie/services/group_services/shopping_lists.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,12 @@
1919
)
2020
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit, RecipeIngredient
2121
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
22-
from mealie.schema.user.user import GroupInDB, PrivateUser
22+
from mealie.schema.user.user import GroupInDB
2323

2424

2525
class ShoppingListService:
26-
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
26+
def __init__(self, repos: AllRepositories, group: GroupInDB):
2727
self.repos = repos
28-
self.user = user
2928
self.group = group
3029
self.shopping_lists = repos.group_shopping_lists
3130
self.list_items = repos.group_shopping_list_item

mealie/services/scheduler/tasks/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .create_timeline_events import create_mealplan_timeline_events
2+
from .delete_old_checked_shopping_list_items import delete_old_checked_list_items
23
from .post_webhooks import post_group_webhooks
34
from .purge_group_exports import purge_group_data_exports
45
from .purge_password_reset import purge_password_reset_tokens
@@ -7,6 +8,7 @@
78

89
__all__ = [
910
"create_mealplan_timeline_events",
11+
"delete_old_checked_list_items",
1012
"post_group_webhooks",
1113
"purge_password_reset_tokens",
1214
"purge_group_data_exports",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from collections.abc import Callable
2+
3+
from pydantic import UUID4
4+
5+
from mealie.db.db_setup import session_context
6+
from mealie.repos.all_repositories import get_repositories
7+
from mealie.routes.groups.controller_shopping_lists import publish_list_item_events
8+
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
9+
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
10+
from mealie.services.event_bus_service.event_bus_service import EventBusService
11+
from mealie.services.event_bus_service.event_types import EventDocumentDataBase, EventTypes
12+
from mealie.services.group_services.shopping_lists import ShoppingListService
13+
14+
MAX_CHECKED_ITEMS = 100
15+
16+
17+
def _create_publish_event(event_bus_service: EventBusService, group_id: UUID4):
18+
def publish_event(event_type: EventTypes, document_data: EventDocumentDataBase, message: str = ""):
19+
event_bus_service.dispatch(
20+
integration_id=DEFAULT_INTEGRATION_ID,
21+
group_id=group_id,
22+
event_type=event_type,
23+
document_data=document_data,
24+
message=message,
25+
)
26+
27+
return publish_event
28+
29+
30+
def _trim_list_items(shopping_list_service: ShoppingListService, shopping_list_id: UUID4, event_publisher: Callable):
31+
pagination = PaginationQuery(
32+
page=1,
33+
per_page=-1,
34+
query_filter=f'shopping_list_id="{shopping_list_id}" AND checked=true',
35+
order_by="update_at",
36+
order_direction=OrderDirection.desc,
37+
)
38+
query = shopping_list_service.list_items.page_all(pagination)
39+
if len(query.items) <= MAX_CHECKED_ITEMS:
40+
return
41+
42+
items_to_delete = query.items[MAX_CHECKED_ITEMS:]
43+
items_response = shopping_list_service.bulk_delete_items([item.id for item in items_to_delete])
44+
publish_list_item_events(event_publisher, items_response)
45+
46+
47+
def delete_old_checked_list_items(group_id: UUID4 | None = None):
48+
with session_context() as session:
49+
repos = get_repositories(session)
50+
if group_id is None:
51+
# if not specified, we check all groups
52+
groups = repos.groups.page_all(PaginationQuery(page=1, per_page=-1)).items
53+
54+
else:
55+
group = repos.groups.get_one(group_id)
56+
if not group:
57+
raise Exception(f'Group not found: "{group_id}"')
58+
59+
groups = [group]
60+
61+
for group in groups:
62+
event_bus_service = EventBusService(session=session, group_id=group.id)
63+
shopping_list_service = ShoppingListService(repos, group)
64+
shopping_list_data = repos.group_shopping_lists.by_group(group.id).page_all(
65+
PaginationQuery(page=1, per_page=-1)
66+
)
67+
for shopping_list in shopping_list_data.items:
68+
_trim_list_items(
69+
shopping_list_service, shopping_list.id, _create_publish_event(event_bus_service, group.id)
70+
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from datetime import datetime
2+
3+
from mealie.repos.repository_factory import AllRepositories
4+
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate, ShoppingListItemOut, ShoppingListSave
5+
from mealie.services.scheduler.tasks.delete_old_checked_shopping_list_items import (
6+
MAX_CHECKED_ITEMS,
7+
delete_old_checked_list_items,
8+
)
9+
from tests.utils.factories import random_int, random_string
10+
from tests.utils.fixture_schemas import TestUser
11+
12+
13+
def test_cleanup(database: AllRepositories, unique_user: TestUser):
14+
list_repo = database.group_shopping_lists.by_group(unique_user.group_id)
15+
list_item_repo = database.group_shopping_list_item
16+
17+
shopping_list = list_repo.create(ShoppingListSave(name=random_string(), group_id=unique_user.group_id))
18+
unchecked_items = list_item_repo.create_many(
19+
[
20+
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
21+
for _ in range(random_int(MAX_CHECKED_ITEMS + 10, MAX_CHECKED_ITEMS + 20))
22+
]
23+
)
24+
25+
# create them one at a time so the update timestamps are different
26+
checked_items: list[ShoppingListItemOut] = []
27+
for _ in range(random_int(MAX_CHECKED_ITEMS + 10, MAX_CHECKED_ITEMS + 20)):
28+
new_item = list_item_repo.create(
29+
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
30+
)
31+
new_item.checked = True
32+
checked_items.append(list_item_repo.update(new_item.id, new_item))
33+
34+
# make sure we see all items
35+
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
36+
assert shopping_list
37+
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
38+
for item in unchecked_items + checked_items:
39+
assert item in shopping_list.list_items
40+
41+
checked_items.sort(key=lambda x: x.update_at or datetime.now(), reverse=True)
42+
expected_kept_items = unchecked_items + checked_items[:MAX_CHECKED_ITEMS]
43+
expected_deleted_items = checked_items[MAX_CHECKED_ITEMS:]
44+
45+
# make sure we only see the expected items
46+
delete_old_checked_list_items()
47+
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
48+
assert shopping_list
49+
assert len(shopping_list.list_items) == len(expected_kept_items)
50+
for item in expected_kept_items:
51+
assert item in shopping_list.list_items
52+
for item in expected_deleted_items:
53+
assert item not in shopping_list.list_items
54+
55+
56+
def test_no_cleanup(database: AllRepositories, unique_user: TestUser):
57+
list_repo = database.group_shopping_lists.by_group(unique_user.group_id)
58+
list_item_repo = database.group_shopping_list_item
59+
60+
shopping_list = list_repo.create(ShoppingListSave(name=random_string(), group_id=unique_user.group_id))
61+
unchecked_items = list_item_repo.create_many(
62+
[
63+
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
64+
for _ in range(MAX_CHECKED_ITEMS)
65+
]
66+
)
67+
68+
# create them one at a time so the update timestamps are different
69+
checked_items: list[ShoppingListItemOut] = []
70+
for _ in range(MAX_CHECKED_ITEMS):
71+
new_item = list_item_repo.create(
72+
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
73+
)
74+
new_item.checked = True
75+
checked_items.append(list_item_repo.update(new_item.id, new_item))
76+
77+
# make sure we see all items
78+
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
79+
assert shopping_list
80+
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
81+
for item in unchecked_items + checked_items:
82+
assert item in shopping_list.list_items
83+
84+
# make sure we still see all items
85+
delete_old_checked_list_items()
86+
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
87+
assert shopping_list
88+
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
89+
for item in unchecked_items + checked_items:
90+
assert item in shopping_list.list_items

0 commit comments

Comments
 (0)