Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ It is a route in the Phoenix router, so it is up whenever the app is, on the dat
| `search_supermarket` | Search the supermarket's product catalogue |
| `get_shopping_list` | Read the current order: items, quantities, totals, availability and the freeze state |
| `set_ingredient_quantity` | Put an ingredient on the list, change it, or remove it with quantity 0 |
| `set_mode` | Switch where the list lives: the supermarket basket or Picape's own checklist |
| `check_item` | Tick an item off the checklist, or untick it |
| `add_ingredient` | Teach Picape an ingredient, backed by a supermarket product ID |
| `edit_ingredient` | Change an ingredient, for example onto another supermarket product |
| `set_ingredient_replacement` | Name the ingredient Picape orders instead when the supermarket cannot deliver this one |
Expand Down Expand Up @@ -81,6 +83,7 @@ Notes on the tools:
- `edit_ingredient` is the other way round: it changes only the fields you send and leaves the rest, tags included. When the supermarket drops a product, `search_ingredients` reports a `warning` and `edit_ingredient` moves the ingredient onto a replacement product, which keeps the recipes that use it.
- `set_ingredient_replacement` is different from repointing an ingredient: the ingredient keeps its product, and sync orders the replacement's product only while the wanted one cannot be delivered. The cart row then says what it stands in for (`substituted_for`).
- `get_shopping_list` marks products the supermarket cannot deliver right now with `is_orderable: false`; `only_unavailable: true` returns just those. While the supermarket processes a paid order the answer's `is_placed` is true and Picape freezes: `set_ingredient_quantity` and `plan_recipe` still record the intent, but nothing reaches the supermarket until the order is delivered.
- `get_shopping_list` also reports `mode`. `ah` means the list lives in the supermarket basket; `checklist` means it lives in Picape's own database and planning, ordering and ticking work without the supermarket. Switch with `set_mode`; switching back to `ah` pushes the accumulated list into the basket and the reply's `pushed_to_supermarket` says whether that worked. `check_item` ticks items only in checklist mode.
- There is no delete for recipes or ingredients.
- The two history tools read finished orders only, nine years of them. The order being planned now is not history yet, an ingredient taken off a list again was never bought, and a recipe that was unplanned again was never cooked, so none of the three count.
- `ingredient_history` reports `average_gap_days` next to `times_bought_last_year` on purpose. A gap on its own cannot tell an ingredient that is due from one that was dropped: both sit far past it. An ingredient with a ten-day gap, last bought 538 days ago and bought zero times in the last year is not overdue, it is gone.
Expand Down
Binary file modified e2e/tests/__screenshots__/ipad/shop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/tests/__screenshots__/iphone-standalone/shop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/tests/__screenshots__/iphone/shop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions e2e/tests/screens.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,45 @@ test('the cart names the ingredient a replacement stands in for', async ({ page,
}
});

// The mode exists for the supermarket being unreachable, so the test puts the
// fake into its outage state: the list, the tick and the switch have to work
// with every call to it failing.
test('checklist mode ticks items while the supermarket is down', async ({ page, request }, testInfo) => {
const problems = watch(page);
const found = await mcp(request, 'search_ingredients', { query: 'Chicken' });
const chicken = found.find((i) => i.name === 'Chicken');

await mcp(request, 'set_mode', { mode: 'checklist' });
await mcp(request, 'set_ingredient_quantity', { ingredient_id: chicken.id, quantity: 1 });
await request.post('http://localhost:4020/__down');

try {
await page.goto('/');
await settle(page);
await tab(page, /Mandje/).click();
await settle(page);

// The heading names the mode, and the row carries an unticked circle.
await expect(page.getByText('Je lijstje', { exact: true })).toHaveCount(1);
const tick = page.getByRole('button', { name: 'Vink af' });
await expect(tick).toHaveCount(1);
await tick.click();
await expect(page.getByRole('button', { name: 'Afgevinkt' })).toHaveCount(1);

// And the same state answers over MCP.
const list = await mcp(request, 'get_shopping_list', {});
expect(list.mode).toBe('checklist');
expect(list.items.find((i) => i.ingredient && i.ingredient.id === chicken.id).checked).toBe(
true
);

expect(problems).toEqual([]);
} finally {
await request.post('http://localhost:4020/__up');
// The next test's beforeEach reset-plan restores ah mode and clears ticks.
}
});

test('a paid order freezes the cart as Besteld', async ({ page, request }, testInfo) => {
const problems = watch(page);
await request.post('http://localhost:4020/__place_order');
Expand Down
34 changes: 34 additions & 0 deletions frontend/components/Ingredient/CheckItem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useMutation, gql } from '@apollo/client';
import * as React from 'react';

import { CircleIcon } from '../Icon';

const CHECK_ITEM = gql`
mutation checkItem($ingredientId: ID!, $checked: Boolean!) {
checkItem(ingredientId: $ingredientId, checked: $checked) {
id
items {
id
checked
}
}
}
`;

export function CheckItem({ id, checked }) {
const [checkItem] = useMutation(CHECK_ITEM);

return (
<CircleIcon
name={checked ? 'checkmark' : 'ellipse-outline'}
selected={checked}
accessibilityLabel={checked ? 'Afgevinkt' : 'Vink af'}
onPress={(e) => {
e.preventDefault();
checkItem({
variables: { ingredientId: id, checked: !checked },
});
}}
/>
);
}
5 changes: 5 additions & 0 deletions frontend/components/ListItem/ListItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export function ListItem({
badges,
subtitle,
children,
// Sits ahead of the picture, the way a Reminders row opens on its check
// circle. Lists without something to put there pass nothing and the row is
// unchanged.
leading,
imageUrl,
textStyle,
onImagePress,
Expand Down Expand Up @@ -92,6 +96,7 @@ export function ListItem({
},
style,
]}>
{leading}
{image}
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text numberOfLines={2} style={[Type.row, { color: colors.cardText }, textStyle]}>
Expand Down
2 changes: 2 additions & 0 deletions frontend/operations/getOrder.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const orderFields = gql`
deliveryStartTime
deliveryEndTime
isPlaced
mode
items {
id
name
Expand All @@ -18,6 +19,7 @@ export const orderFields = gql`
isOrderable
availabilityLabel
substitutedFor
checked
ingredient {
id
name
Expand Down
10 changes: 10 additions & 0 deletions frontend/operations/setMode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';

export const SET_MODE = gql`
mutation setMode($mode: String!) {
setMode(mode: $mode) {
id
mode
}
}
`;
32 changes: 29 additions & 3 deletions frontend/screens/ListScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { IngredientDetailScreen } from './IngredientDetailScreen';
import { ErrorState } from '../components/ErrorState';
import { Badge } from '../components/Badge/Badge';
import { ImageCard } from '../components/Card/ImageCard';
import { CheckItem } from '../components/Ingredient/CheckItem';
import { Nutriscore, hasNutriscore } from '../components/Ingredient/Nutriscore';
import { OrderQuantity } from '../components/Ingredient/OrderQuantity';
import { SplitView, useSelection } from '../components/Layout/SplitView';
Expand All @@ -22,6 +23,7 @@ import Type from '../constants/Type';
import { SUBSCRIBE_ORDER, GET_ORDER } from '../operations/getOrder';
import { GET_ORDER_COUNT } from '../operations/getOrderCount';
import { GET_RECIPES } from '../operations/getRecipes';
import { SET_MODE } from '../operations/setMode';
import { START_SHOPPING } from '../operations/startShopping';

const euros = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
Expand Down Expand Up @@ -181,12 +183,16 @@ export default function ListScreen({ navigation }) {
const [startShopping] = useMutation(START_SHOPPING, {
refetchQueries: ['BasicsList', 'OrderList', 'RecipeList', 'LastOrderedRecipes'],
});
const [setMode] = useMutation(SET_MODE, {
refetchQueries: ['OrderList'],
});

if (error) return <ErrorState error={error} />;

const { currentOrder: currentOrderQuery = {} } = data;
const { currentOrder: currentOrderSubscription } = subscription;
const currentOrder = currentOrderSubscription || currentOrderQuery;
const checklistMode = currentOrder.mode === 'checklist';
// A paid order is the supermarket's, not the basket's: while it is being
// processed Picape freezes, so the heading says so rather than naming the
// day the basket's next delivery is booked for.
Expand All @@ -197,7 +203,7 @@ export default function ListScreen({ navigation }) {
<ScrollView
ref={scrollRef}
contentContainerStyle={[contentColumn, { paddingBottom: bottomInset }]}>
<SectionHeader title="Je mandje" large>
<SectionHeader title={checklistMode ? 'Je lijstje' : 'Je mandje'} large>
<View style={styles.orderTotal}>
<View style={styles.orderTotalLine}>
{deliveryDay && (
Expand All @@ -207,6 +213,16 @@ export default function ListScreen({ navigation }) {
{currentOrder.totalPrice > 0 ? formatEuros(currentOrder.totalPrice) : ''}
</Text>
</View>
{currentOrder.mode && (
<Text
style={[Type.subtitle, { color: Colors.secondaryText }]}
onPress={(e) => {
e.preventDefault();
setMode({ variables: { mode: checklistMode ? 'ah' : 'checklist' } });
}}>
{checklistMode ? 'Naar supermarkt' : 'Naar checklist'}
</Text>
)}
{currentOrder.totalDiscount > 0 && (
<Text style={[Type.subtitle, { color: Colors.savingsText }]}>
{formatEuros(currentOrder.totalDiscount)} bespaard
Expand Down Expand Up @@ -246,7 +262,13 @@ export default function ListScreen({ navigation }) {
: Colors.cardBackground,
},
]}
leading={
checklistMode && ingredient ? (
<CheckItem id={ingredient.id} checked={item.checked} />
) : null
}
title={ingredient?.name || item.name}
textStyle={item.checked ? styles.checkedText : undefined}
imageUrl={ingredient?.imageUrl || item.imageUrl}
badges={badgesFor(ingredient, item)}
selected={wide ? selected?.ingredientId === ingredient?.id : undefined}
Expand Down Expand Up @@ -288,7 +310,7 @@ export default function ListScreen({ navigation }) {
e.preventDefault();
startShopping({});
}}>
Bestelling ontvangen
{checklistMode ? 'Boodschappen gedaan' : 'Bestelling ontvangen'}
</Text>
)}
</ScrollView>
Expand Down Expand Up @@ -325,13 +347,17 @@ const styles = StyleSheet.create({
warningBadge: {
paddingHorizontal: Spacing.sm,
},
checkedText: {
color: Colors.secondaryText,
textDecorationLine: 'line-through',
},
// Room for both lines whether or not the order has arrived and whether or not
// the bonus took anything off, so the heading keeps one height and the basket
// below it never jumps once the total loads.
orderTotal: {
alignItems: 'flex-end',
justifyContent: 'center',
minHeight: Type.row.lineHeight + Type.subtitle.lineHeight,
minHeight: Type.row.lineHeight + Type.subtitle.lineHeight * 2,
},
// The delivery day rides on the total's line rather than taking one of its
// own. It is a short label and the heading already reserves two lines, so a
Expand Down
42 changes: 42 additions & 0 deletions lib/picape/checklist.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Picape.Checklist do
@moduledoc """
The self-managed shopping list Picape uses while the supermarket is
unreachable: `Settings.mode()` `"checklist"`. The list itself is the same
computed cart `Order.cart/1` builds from planned recipes and manual
ingredients — this context only stores which of its items are ticked off.
Closing an order clears the ticks; the plan rows carry over unchanged.
"""

import Ecto.Query

alias Picape.{Repo, Settings}
alias Picape.Checklist.Item

@doc """
Ticks an ingredient on the checklist (`checked` true) or unticks it.
Only writes in checklist mode: ticking a basket the supermarket owns would
record a state nothing reads.
"""
def check(ingredient_id, checked \\ true) do
if Settings.checklist?() do
%Item{}
|> Item.changeset(%{ingredient_id: ingredient_id, checked: checked})
|> Repo.insert(
on_conflict: [set: [checked: checked]],
conflict_target: :ingredient_id
)
else
{:error, "Picape is not in checklist mode"}
end
end

@doc "The ingredient ids ticked off on the current checklist."
def checked_ids() do
from(i in Item, where: i.checked == true, select: i.ingredient_id)
|> Repo.all()
|> MapSet.new()
end

@doc "Drops every tick. Called when the order they belonged to closes."
def clear(), do: Repo.delete_all(Item)
end
24 changes: 24 additions & 0 deletions lib/picape/checklist/item.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
defmodule Picape.Checklist.Item do
use Ecto.Schema
import Ecto.Changeset
alias Picape.Checklist.Item

schema "checklist_item" do
field(:checked, :boolean, default: false)

belongs_to(
:ingredient,
Picape.Recipe.Ingredient,
foreign_key: :ingredient_id
)

timestamps()
end

@doc false
def changeset(%Item{} = item, attrs) do
item
|> cast(attrs, [:ingredient_id, :checked])
|> validate_required([:ingredient_id, :checked])
end
end
Loading
Loading