Browse Source

Merge branch 'tags'

Daniel Sheffield 1 year ago
parent
commit
d7a6811308

+ 1 - 1
app/activities/NewProduct.py

@@ -20,7 +20,7 @@ from urwid import (
     Text,
 )
 from . import ActivityManager
-from ..db_utils import NON_IDENTIFIER_COLUMNS, QueryManager, get_insert_product_statement
+from ..data.QueryManager import NON_IDENTIFIER_COLUMNS, QueryManager, get_insert_product_statement
 from ..widgets import AutoCompleteEdit, AutoCompletePopUp
 
 class NewProduct(Overlay):

+ 7 - 2
app/activities/Plot.py

@@ -1,5 +1,10 @@
-from app.db_utils import QueryManager
-from app.db_utils import QueryManager, display_mapper
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from app.data.QueryManager import QueryManager, display_mapper
 from datetime import date, datetime
 import seaborn as sns
 import pandas as pd

+ 7 - 9
app/activities/PriceCheck.py

@@ -31,7 +31,7 @@ from ..widgets import (
     NoTabCheckBox,
     FlowBarGraphWithVScale,
 )
-from ..db_utils import QueryManager
+from ..data.QueryManager import QueryManager
 from . import ActivityManager, show_or_exit
 from .Rating import Rating
 
@@ -330,14 +330,12 @@ class PriceCheck(FocusWidget):
         ])
         widget = Filler(widget, 'top')
         widget = AttrMap(widget, 'bg')
-        super().__init__(widget, map(
-            lambda x: next(w[1] for w in chain(
+        super().__init__(widget, [
+                'product', 'organic', 'unit', 'quantity', 'price',
+                'clear', 'exit', 'sort_price', 'sort_date',
+            ], lambda: chain(
                 self.buttons.items(),
                 self.edit_fields.items(),
                 self.checkboxes.items(),
-            ) if x == w[0]),
-            [
-                'product', 'organic', 'unit', 'quantity', 'price',
-                'clear', 'exit', 'sort_price', 'sort_date',
-            ]
-        ))
+            )
+        )

+ 5 - 5
app/activities/Rating.py

@@ -15,7 +15,7 @@ class Rating(object):
             return ''
 
         return df.drop(labels=[
-            'id', 'avg', 'min', 'max', 'price', 'quantity', 'ts_raw', 'product', 'category', 'group'
+            'id', 'last', 'avg', 'min', 'max', 'price', 'quantity', 'ts_raw', 'product', 'category', 'group'
         ], axis=1).to_string(header=[
             'Date', 'Store', '$/unit', 'Org',
         ], justify='justify-all', max_colwidth=16, index=False)
@@ -39,15 +39,15 @@ class Rating(object):
                 p = 'badge_bad'
         else:
             p = 'badge_neutral'
-        
+
         for idx, (e, a) in enumerate(zip(ls, ls[1:])):
             if e <= _avg <= a:
                 for c, (_idx,_) in zip(''.join(list(f'{_avg:>5.2f}')[:idx+2]), filter(lambda x: idx-2 < x[0] and x[0]>0, enumerate(chars))):
                     chars[_idx] = (p, c)
-            
+
             if current is not None and e <= current < a:
                 rating[idx] = '^'
-        
+
         chars[0] = '|'
         chars[-1] = '|'
 
@@ -66,4 +66,4 @@ class Rating(object):
         else:
             self.text_fields['spread'].set_text('')
             self.text_fields['marker'].set_text('')
-        
+

+ 537 - 0
app/activities/RecipeEditor.py

@@ -0,0 +1,537 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from xml.etree.ElementTree import fromstring, ParseError
+from markdown import markdown
+from itertools import chain, product
+from decimal import Decimal, InvalidOperation
+from collections import OrderedDict
+from typing import List, Tuple, Union, Iterable, Callable
+from urwid import (
+    connect_signal,
+    AttrMap,
+    Button,
+    Columns,
+    Divider,
+    Edit,
+    Filler,
+    LineBox,
+    Padding,
+    Pile,
+    RadioButton,
+    Text,
+)
+from urwid.numedit import FloatEdit
+
+from .. import COPYRIGHT
+from ..widgets import (
+    AutoCompleteEdit,
+    AutoCompleteFloatEdit,
+    FocusWidget,
+    AutoCompletePopUp,
+    NoTabCheckBox,
+    FlowBarGraphWithVScale,
+)
+from ..data.QueryManager import QueryManager
+from . import ActivityManager, show_or_exit
+from .Rating import Rating
+import yaml
+def change_style(style, representer):
+    def new_representer(dumper, data):
+        scalar = representer(dumper, data)
+        scalar.style = style
+        return scalar
+    return new_representer
+
+import yaml
+from yaml.representer import SafeRepresenter
+class folded_str(str): pass
+
+class literal_str(str): pass
+
+# represent_str does handle some corner cases, so use that
+# instead of calling represent_scalar directly
+represent_folded_str = change_style('>', SafeRepresenter.represent_str)
+represent_literal_str = change_style('|', SafeRepresenter.represent_str)
+yaml.add_representer(folded_str, represent_folded_str)
+yaml.add_representer(literal_str, represent_literal_str)
+def depth_first_elements(tree):
+    for e in tree:
+        for y in depth_first_elements(e):
+            yield y
+    yield tree
+
+def get_products_from_xhtml(md: str):
+    try:
+        xhtml = fromstring(
+f"""<root>
+{md}
+</root>
+""")
+    except ParseError:
+        return
+    for e in filter(lambda x: x.tag == 'strong', depth_first_elements(xhtml)):
+        yield e.text
+
+def to_numbered_field(x):
+    if len(x[0].split('#', 1)) > 1:
+        name, idx = x[0].split('#', 1)
+        idx = int(idx)
+    else:
+        name, idx = x[0], 0
+
+    return (name, int(idx)), x[1]
+
+def to_unnumbered_field(x):
+    return x[0][0], x[1]
+
+def in_same_row(name):
+    if len(name.split('#', 1)) > 1:
+        _, row = name.split('#', 1)
+    return lambda x: x[0][1] == int(row)
+
+def unzip(iter: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]]) -> Tuple[
+    List[AutoCompleteEdit], List[FloatEdit], List[AutoCompleteEdit]
+]:
+    return zip(*iter)
+
+def extract_values(x: Union[List[AutoCompleteEdit], List[FloatEdit]]) -> Iterable[str]:
+    if isinstance(x, list) or isinstance(x, tuple):
+        if len(x) == 0:
+            return []
+        return ( v.get_edit_text() for v in x )
+    raise Exception(f"Unsupported type: {type(x)}")
+
+def to_named_value(name: str) -> Callable[[str], Tuple[str,str]]:
+    return lambda e: (f'{name}#{e[0]}', e[1])
+
+def blank_ingredients_row(idx: int) -> Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]:
+    return (
+        AutoCompleteEdit(('bg', f'product#{idx}')),
+        FloatEdit(('bg', f'')),
+        AutoCompleteEdit(('bg', f'unit#{idx}'))
+    )
+
+class RecipeEditor(FocusWidget):
+
+    def keypress(self, size, key):
+        if isinstance(key, tuple):
+            return
+
+        if getattr(self._w.original_widget, 'original_widget', None) is None:
+            return super().keypress(size, key)
+
+        if key == 'tab':
+            self.advance_focus()
+        elif key == 'shift tab':
+            self.advance_focus(reverse=True)
+        elif key == 'ctrl delete':
+            self.clear()
+        elif key == 'ctrl w':
+            self.save()
+        else:
+            return super().keypress(size, key)
+
+    def apply_choice(self, name, value):
+        self.apply_changes(name, value)
+
+        data = dict(filter(
+            in_same_row(name),
+            map(to_numbered_field, self.data.items())
+        ))
+
+        for k,v in data.items():
+            if f'{k[0]}#{k[1]}' == name or v:
+                continue
+            _data = dict(map(lambda x: (x[0][0], x[1]), data.items()))
+            options = self.query_manager.unique_suggestions(k[0], **_data)
+            if len(options) == 1:
+                self.apply_changes(f'{k[0]}#{k[1]}', list(options)[0])
+
+    def apply_changes(self, name, value):
+        self.data = {
+            name: value if name != 'organic' else {
+                'yes': True, 'no': False,
+                True: True, False: False,
+                'mixed': '',
+            }[value],
+        }
+
+    @property
+    def data(self):
+        zipped = zip(
+            ['product', 'quantity', 'unit'],
+            map(extract_values, unzip(self.ingredients)),
+        )
+        ret = dict(chain(
+            *[ map(to_named_value(n), enumerate(l)) for n,l in zipped ],
+            [ ('organic', self.organic.state) ]
+        ))
+        return ret
+
+    @data.setter
+    def data(self, _data: dict):
+        for k,v in _data.items():
+            if len(k.split('#')) > 1:
+                name, idx = k.split('#', 1)
+                w = self.ingredients[int(idx)][ next(( pos for pos, n in zip(
+                    [0, 1, 2],
+                    ['product', 'quantity', 'unit']
+                ) if n == name ))]
+                w.set_edit_text(v)
+            if k == 'organic':
+                self.organic.set_state(v)
+    @property
+    def components(self):
+        return self._components
+
+    @components.setter
+    def components(self, _data: dict):
+        self._components = _data
+
+    def clear(self):
+        self.ingredients = []
+        self.add_ingredient()
+        self.organic.set_state('mixed')
+        self.instructions.set_edit_text('')
+        self.fname.set_edit_text('')
+        self.notice.set_text('')
+        return self.update()
+
+    def init_ingredients(self):
+        right_pane = LineBox(Pile([AttrMap(
+            AutoCompletePopUp(
+                ingredient[0],
+                self.apply_choice,
+                lambda: self.activity_manager.show(self.update(ingredient[0]))
+            ), 'streak') for ingredient in self.ingredients]),
+            title='Product',
+            title_align='left'
+        )
+        left_pane = LineBox(
+            Pile([AttrMap(
+                ingredient[1],
+                'streak'
+            ) for ingredient in self.ingredients]),
+            title='Quantity',
+            title_align='left'
+        )
+        middle_pane = LineBox(
+            Pile([AttrMap(AutoCompletePopUp(
+                ingredient[2],
+                self.apply_choice,
+                lambda: self.activity_manager.show(self.update(ingredient[2]))
+            ), 'streak') for ingredient in self.ingredients]),
+            title='Unit',
+            title_align='left'
+        )
+        gutter = Pile([
+            *[ Divider() for _ in product(
+                range(1), self.ingredients[:-1]
+            )],
+            Divider(),
+            Divider(),
+            self.buttons['add'],
+        ])
+        return left_pane, middle_pane, right_pane, gutter
+
+    def add_ingredient(self):
+        self.ingredients.append(
+            blank_ingredients_row(len(self.ingredients))
+        )
+        l, m, r, gutter = self.init_ingredients()
+        self.components['left_pane'][1].original_widget.contents = list(l.original_widget.contents)
+        self.components['middle_pane'][1].original_widget.contents = list(m.original_widget.contents)
+        self.components['right_pane'].original_widget.contents = list(r.original_widget.contents)
+        self.components['gutter'][1].contents = list(gutter.contents)
+        for idx, widget in enumerate(self.ingredients):
+            connect_signal(widget[0], 'postchange', lambda w,_: self.update(w))
+            connect_signal(widget[0], 'apply', lambda w, name: self.autocomplete_callback(
+                w, self.autocomplete_options(name, dict(map(
+                    to_unnumbered_field,
+                    filter(in_same_row(name), map(to_numbered_field, self.data.items())
+                ))))
+            ))
+            connect_signal(widget[1], 'postchange', lambda w,_: self.update(w))
+            connect_signal(widget[2], 'postchange', lambda w,_: self.update(w))
+            connect_signal(widget[2], 'apply', lambda w, name: self.autocomplete_callback(
+                w, self.autocomplete_options(name, dict(map(
+                    to_unnumbered_field,
+                    filter(in_same_row(name), map(to_numbered_field, self.data.items())
+                ))))
+            ))
+
+    def save(self):
+        yml = dict()
+        yml['ingredients'] = list(map(lambda x: ' '.join(x), filter(
+            lambda x: None not in map(lambda x: x or None, x), [
+                list(map(lambda x: x.get_edit_text(), x)) for x in self.ingredients
+        ])))
+        if self.feeds:
+            n, d = self.feeds.as_integer_ratio()
+            yml['feeds'] = float(self.feeds) if d != 1 else n
+        else:
+            yml['feeds'] = None
+        yml['instructions'] = literal_str('\n'.join(map(
+			lambda x: x.strip(),
+			self.instructions.get_text()[0].splitlines()
+		)).strip())
+        fname = self.fname.get_edit_text()
+        if not fname:
+            return
+        with open(fname, 'w') as f:
+            yaml.dump(yml, f)
+
+
+    def update(self, widget = None):
+        data = self.data
+        organic = None if data['organic'] == 'mixed' else data['organic']
+        sort = 'ts'
+        self.notices[None] = set()
+        for r in filter(
+            lambda x: next(filter(lambda x: x is widget or widget is None, x), None),
+            self.ingredients
+        ):
+            product, quantity, unit = map(lambda x: x.get_edit_text(), r)
+            if product not in self.notices:
+                self.notices[product] = set()
+            self.found[product] = '>'
+
+            try:
+                quantity = Decimal(quantity)
+            except InvalidOperation:
+                quantity = None
+
+            if None in (sort or None, product or None, unit or None, quantity or None):
+                continue
+
+            df = self.query_manager.get_historic_prices_data(unit, sort=sort, product=product, organic=organic)
+            if not df.empty:
+                distinct = df['quantity'].unique()
+                if len(distinct) == 1 and None in distinct:
+                    self.notices[product].add(f"Product '{product}' has no conversion to '{unit}'")
+                    continue
+                df = df.dropna()
+                self.found[product] = '='
+
+            else:
+                if organic is None:
+                    self.notices[product].add(f"Product '{product}' not found in database")
+                    continue
+
+                df = self.query_manager.get_historic_prices_data(unit, sort=sort, product=product, organic=None)
+                if df.empty:
+                    self.notices[product].add(f"Product '{product}' not found in database")
+                    continue
+                distinct = df['quantity'].unique()
+                if len(distinct) == 1 and None in distinct:
+                    self.notices[product].add(f"Product '{product}' has no conversion to '{unit}'")
+                    continue
+                df = df.dropna()
+                self.notices[product].add(f"Using {'non-' if organic else ''}organic {product}")
+                self.found[product] = '~'
+
+            assert len(df['avg'].unique()) == 1, f"There should be only one average price: {df['avg'].unique()}"
+
+            _last, _avg, _min, _max = list(
+                map(Decimal,df[['last', 'avg','min','max']].iloc[0])
+            )
+            self.prices[product] = [
+                i*quantity for i in (_last, _min, _avg, _max)
+            ]
+
+        for k in list(self.notices):
+            if k not in [
+                *map(lambda x: x[0].get_edit_text(), self.ingredients),
+                None
+            ]:
+                del self.notices[k]
+        for k in list(self.prices):
+            if k not in map(lambda x: x[0].get_edit_text(), self.ingredients):
+                del self.prices[k]
+        for k in list(self.found):
+            if k not in [
+                *map(lambda x: x[0].get_edit_text(), self.ingredients),
+                ''
+            ]:
+                del self.found[k]
+
+        price = [
+            sum([self.prices[p][i] for p in self.prices]) for i in range(4)
+        ]
+        price_as = next(filter(
+            lambda x: self.price_as[x[1]].state,
+            enumerate(self.price_as)
+        ))[0]
+        not_found = next(
+            filter(lambda x: x == '>', self.found.values()), next(
+                filter(lambda x: x == '~', self.found.values()), next(
+                    filter(lambda x: x == '=', self.found.values()),
+                    None,
+                )
+            )
+        )
+        self.price.set_text(
+            f'Cost: {not_found}{price[price_as]}'
+            f', Per Serve: {not_found}{price[price_as]/self.feeds}'
+        )
+        ingredients = list(filter(lambda x: x, map(lambda x: x[0].get_edit_text(), self.ingredients)))
+        parsed_products = list(get_products_from_xhtml(markdown(self.instructions.get_edit_text())))
+        fname = self.fname.get_edit_text()
+        if not fname:
+            self.notice.set_text('No file name set')
+            return self
+        if not parsed_products:
+            self.notice.set_text('Failed to parse recipe instructions')
+            return self
+        products = set(parsed_products)
+        for product in products - set(ingredients):
+            if product not in self.notices:
+                self.notices[product] = set()
+            self.notices[product].add(f"Product '{product}' not found in list of ingredients")
+        for ingredient in set(ingredients) - products:
+            if product not in self.notices:
+                self.notices[product] = set()
+            self.notices[ingredient].add(f"Ingredient '{ingredient}' is not used")
+        if len(set(ingredients)) != len(ingredients):
+            self.notices[None].add(f"Some ingredients listed more than once")
+
+        self.notice.set_text('\n'.join(sorted(filter(lambda x: x, [
+            '\n'.join(sorted(v)) for v in self.notices.values()
+        ]))) or 'None')
+
+        return self
+
+    def __init__(self,
+        activity_manager: ActivityManager,
+        query_manager: QueryManager,
+        fname: str,
+        recipe: dict,
+    ):
+        self.notices = dict()
+        button_group = []
+        self.price_as = OrderedDict([
+          ('last', RadioButton(button_group, ('streak', 'Last'), state="first True")),
+          ('min', RadioButton(button_group, ('streak', 'Min'), state="first True")),
+          ('avg', RadioButton(button_group, ('streak', 'Avg'), state="first True")),
+          ('max', RadioButton(button_group, ('streak', 'Max'), state="first True")),
+        ])
+        for b in self.price_as.values():
+            connect_signal(b, 'postchange', lambda w,_: self.update(w))
+        self.fname = Edit('', fname)
+        self.prices = dict()
+        self.found = dict()
+        self.components = dict()
+        self.buttons = {
+          'clear': Button(('streak', 'Clear')),
+          'exit': Button(('streak', 'Exit')),
+          'add': Button(('streak', 'Add')),
+          'save': Button(('streak', 'Save')),
+        }
+        self.ingredients: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]] = [
+            (
+                AutoCompleteEdit(('bg', f'product#{idx}'), edit_text=ingredient[0]),
+                FloatEdit(('bg', f''), default=ingredient[1]),
+                AutoCompleteEdit(('bg', f'unit#{idx}'), edit_text=ingredient[2]),
+            ) for idx, ingredient in enumerate(recipe['ingredients'])
+        ] if len(recipe['ingredients']) else [ ]
+
+        self.organic = NoTabCheckBox(('bg', "Organic"), state='mixed')
+        self.instructions = Edit('', edit_text=recipe['instructions'] or u'', multiline=True, allow_tab=True)
+        self.feeds = Decimal(recipe['feeds'])
+        self.price = Text('')
+        self.notice = Text('')
+        feeds = FloatEdit(f'Serves: ', self.feeds)
+
+        bottom_pane = [
+            self.organic,
+            LineBox(self.instructions, title=f'Instructions'),
+            feeds,
+            Columns([('weight', 2, self.price), *[(9, w) for _,w in self.price_as.items()], Divider()]),
+            LineBox(self.notice, title='Errors', title_align='left'),
+        ]
+        self.activity_manager = activity_manager
+        self.query_manager = query_manager
+        self.autocomplete_options = lambda name, data: self.query_manager.unique_suggestions(name.split('#', 1)[0], **data)
+        self.autocomplete_callback = lambda widget, options: len(options) > 0 and widget._emit('open', options)
+        connect_signal(self.organic, 'postchange', lambda *_: self.update())
+
+        connect_signal(self.buttons['save'], 'click', lambda _: self.save())
+        connect_signal(self.buttons['add'], 'click', lambda _: self.add_ingredient())
+        connect_signal(self.buttons['clear'], 'click', lambda _: self.clear())
+        connect_signal(self.buttons['exit'], 'click', lambda _: show_or_exit('esc'))
+        connect_signal(self.instructions, 'postchange', lambda w,_: self.update(w))
+
+        header = Text(u'Recipe Editor', 'center')
+        _copyright = Text(COPYRIGHT, 'center')
+
+        banner = Pile([
+            Padding(header, 'center', width=('relative', 100)),
+            Padding(_copyright, 'center', width=('relative', 100)),
+        ])
+        banner = AttrMap(banner, 'banner')
+
+        left_pane, middle_pane, right_pane, gutter = self.init_ingredients()
+
+
+        self.components = {
+            'top_pane': Columns([
+                (9, Pile([
+                    Divider(),
+                    AttrMap(self.buttons['clear'], 'streak'),
+                    Divider(),
+                ])),
+                Divider(),
+                (60, LineBox(Columns([
+                    self.fname, (8, self.buttons['save'])
+                ]), title='Recipe')),
+                Divider(),
+                (9, Pile([
+                    Divider(),
+                    AttrMap(self.buttons['exit'], 'streak'),
+                    Divider(),
+                ]))
+            ], dividechars=1),
+            'bottom_pane': Pile(bottom_pane),
+            'right_pane': right_pane,
+            'middle_pane': (15, middle_pane),
+            'left_pane': (12, left_pane),
+            'gutter': (8, gutter)
+        }
+        self.add_ingredient()
+
+        widget = Pile([
+            banner,
+            Divider(),
+            self.components['top_pane'],
+            Columns([
+                self.components['left_pane'],
+                self.components['middle_pane'],
+                self.components['right_pane'],
+                (1,Divider()),
+                self.components['gutter'],
+            ], dividechars=0),
+            self.components['bottom_pane'],
+        ])
+        widget = Filler(widget, 'top')
+        widget = AttrMap(widget, 'bg')
+        super().__init__(widget, [
+                'instructions',
+                'ingredients', #'quantity', 'units',
+                'add',
+                'organic',
+                'clear', 'fname', 'save', 'exit',
+            ], lambda: chain(
+                self.buttons.items(),
+                chain(*[product(['ingredients',], [*x[1:],x[0]]) for x in self.ingredients]),
+                [
+                    ('fname', self.fname),
+                    ('instructions', self.instructions),
+                    ('organic', self.organic)
+                ],
+            )
+        )
+        self.update()

+ 177 - 30
app/activities/TransactionEditor.py

@@ -9,13 +9,20 @@ from dateutil.parser import parse as parse_time
 from dateutil.parser._parser import ParserError
 from decimal import Decimal, InvalidOperation
 from itertools import chain
-from typing import Callable, Union
+from typing import (
+    Callable,
+    Union,
+    Tuple,
+    List,
+    Iterable,
+)
 from urwid import (
     connect_signal,
     AttrMap,
     Button,
     Columns,
     Divider,
+    Edit,
     Filler,
     LineBox,
     Padding,
@@ -24,7 +31,7 @@ from urwid import (
 )
 
 from .. import COPYRIGHT
-from ..db_utils import QueryManager
+from ..data.QueryManager import QueryManager
 from ..widgets import (
     AutoCompleteEdit,
     AutoCompleteFloatEdit,
@@ -37,6 +44,46 @@ from . import ActivityManager
 from .Rating import Rating
 from .NewProduct import NewProduct
 
+def to_numbered_field(x):
+    if len(x[0].split('#', 1)) > 1:
+        name, idx = x[0].split('#', 1)
+        idx = int(idx)
+    else:
+        name, idx = x[0], 0
+
+    return (name, int(idx)), x[1]
+
+def to_unnumbered_field(x):
+    return x[0][0], x[1]
+
+def in_same_row(name):
+    if len(name.split('#', 1)) > 1:
+        _, row = name.split('#', 1)
+    else:
+        row = 0
+    return lambda x: x[0][1] == int(row)
+
+def unzip(_iter: List[Tuple[AutoCompleteEdit, Edit]]) -> Tuple[
+    List[AutoCompleteEdit], List[Edit]
+]:
+    return zip(*_iter)
+
+def extract_values(x: Union[List[AutoCompleteEdit], List[Edit]]) -> Iterable[str]:
+    if isinstance(x, list) or isinstance(x, tuple):
+        if len(x) == 0:
+            return []
+        return ( v.get_edit_text() for v in x )
+    raise Exception(f"Unsupported type: {type(x)}")
+
+def to_named_value(name: str) -> Callable[[str], Tuple[str,str]]:
+    return lambda e: (f'{name}#{e[0]}', e[1])
+
+def blank_tags_row(idx: int) -> Tuple[AutoCompleteEdit, Edit]:
+    return (
+        AutoCompleteEdit(('bg',f'tags#{idx}')), Edit(('bg', f'')),
+    )
+
+
 class TransactionEditor(FocusWidget):
 
     def keypress(self, size, key):
@@ -51,6 +98,8 @@ class TransactionEditor(FocusWidget):
         elif key == 'shift tab':
             self.advance_focus(reverse=True)
         elif key == 'ctrl delete':
+            print(self.data)
+            input()
             self.clear()
             self.focus_on(self.edit_fields['product'])
         elif key == 'insert':
@@ -62,12 +111,22 @@ class TransactionEditor(FocusWidget):
 
     def apply_choice(self, name, value):
         self.apply_changes(name, value)
-        for k,v in self.data.items():
-            if k == name or v:
+        data = dict(#filter(
+        #    in_same_row(name),
+            map(to_numbered_field, self.data.items())
+        )#)
+        for k,v in data.items():
+            if f'{k[0]}#{k[1]}' == name or v:
                 continue
-            options = self.query_manager.unique_suggestions(k, **self.data)
+
+            _data = dict(filter(
+                lambda x: x[0] not in ('tags','description'),
+                map(lambda x: (x[0][0], x[1]), data.items())
+            ))
+            options = self.query_manager.unique_suggestions(k[0], **_data)
+
             if len(options) == 1 and k != 'ts':
-                self.apply_changes(k, list(options)[0])
+                self.apply_changes(f'{k[0]}#{k[1]}', list(options)[0])
 
     def apply_changes(self, name, value):
         self.data = {
@@ -80,7 +139,13 @@ class TransactionEditor(FocusWidget):
 
     @property
     def data(self):
+        zipped = zip(
+            ['tags',],
+            #['tags', 'descriptions'],
+            map(extract_values, unzip(self._tags)),
+        )
         ret = dict(itertools.chain(
+            *[ map(to_named_value(n), enumerate(l)) for n,l in zipped ],
             [(k, v.get_edit_text()) for k,v in self.edit_fields.items()],
             [(k, v.state) for k,v in self.checkboxes.items()]
         ))
@@ -89,12 +154,70 @@ class TransactionEditor(FocusWidget):
     @data.setter
     def data(self, _data: dict):
         for k,v in _data.items():
+            _name = to_unnumbered_field(to_numbered_field((k,None)))
+            if _name[0] not in [
+                'tags', 'descriptions'
+            ]:
+                k = _name[0]
             if k in self.edit_fields and v != self.edit_fields[k].get_edit_text():
                 self.edit_fields[k].set_edit_text(v)
-            if k in self.checkboxes and v != self.checkboxes[k].state:
+            elif k in self.checkboxes and v != self.checkboxes[k].state:
                 self.checkboxes[k].set_state(v)
+            elif len(k.split('#')) > 1:
+                name, idx = k.split('#', 1)
+                w = self._tags[int(idx)][ next(( pos for pos, n in zip(
+                    [0, 1],
+                    ['tags', 'descriptions']
+                ) if n == name ))]
+                w.set_edit_text(v)
+
+
+    def init_tags(self):
+        #_tags = LineBox(Pile([AttrMap(
+        _tags = Pile([AttrMap(
+            AutoCompletePopUp(
+                tag[0],
+                self.apply_choice,
+                lambda: self.activity_manager.show(self.update())
+            ), 'streak') for tag in self._tags])
+        #    title=f'Tags',
+        #    title_align='left'
+        #)
+        gutter = Pile([
+            *[ Divider() for _ in itertools.product(
+                range(1), self._tags[:-1]
+            )],
+            Divider(),
+            Divider(),
+            self.buttons['add'],
+        ])
+        return _tags, gutter
+
+
+    def add_tag(self):
+        self._tags.append(
+            blank_tags_row(len(self._tags))
+        )
+        _tags, gutter = self.init_tags()
+        #self.components['tags'][1].original_widget.contents = list(_tags.original_widget.contents)
+        self.components['tags'].contents = list(_tags.contents)
+        self.components['gutter'][1].contents = list(gutter.contents)
+        for idx, widget in enumerate(self._tags):
+            connect_signal(widget[0], 'postchange', lambda w,_: self.update())
+            connect_signal(widget[0], 'apply', lambda w, name: self.autocomplete_callback(
+                w, name, self.autocomplete_options(name, dict(map(
+                    to_unnumbered_field,
+                    #filter(
+                    #    in_same_row(name),
+                        map(to_numbered_field, self.data.items()
+                    #)
+                ))))
+            ))
+
 
     def clear(self):
+        self._tags = []
+        self.add_tag()
         for (k, ef) in self.edit_fields.items():
             if k in ('ts', 'store',):
                 continue
@@ -107,7 +230,7 @@ class TransactionEditor(FocusWidget):
         self.graph.set_data([],0)
         return self.update()
 
-    def update(self):
+    def update(self, w=None):
         data = self.data
         date, store = data['ts'], data['store']
         try:
@@ -223,11 +346,19 @@ class TransactionEditor(FocusWidget):
         activity_manager: ActivityManager,
         query_manager: QueryManager,
     ):
+        self.autocomplete_options = lambda name, data: self.query_manager.unique_suggestions(name.split('#', 1)[0], **data)
         self.activity_manager = activity_manager
         self.query_manager = query_manager
         self.buttons = {
             'done': Button(('streak', u'Done')),
             'clear': Button(('streak', u'Clear')),
+            'add': Button(('streak', 'Add')),
+        }
+        self._tags = []
+        _tags, gutter = self.init_tags()
+        self.components = {
+            'tags': _tags,
+            'gutter': (8,gutter),
         }
         self.edit_fields = {
             'ts': AutoCompleteEdit(('bg', 'ts')),
@@ -279,7 +410,7 @@ class TransactionEditor(FocusWidget):
             'spread',
             'marker',
         ]
-        self.clear()
+        #self.clear()
         _widgets = dict(itertools.chain(*[
             [(k, v) for k,v in x] for x in map(lambda x: x.items(), [
                 self.edit_fields, self.text_fields, self.checkboxes
@@ -295,7 +426,13 @@ class TransactionEditor(FocusWidget):
         for (k, ef) in self.edit_fields.items():
             connect_signal(ef, 'postchange', lambda *_: self.update())
             connect_signal(ef, 'apply', lambda w, name: self.autocomplete_callback(
-                w, name, query_manager.unique_suggestions(name, **self.data)
+                w, name, self.autocomplete_options(name, dict(map(
+                    to_unnumbered_field,
+                    #filter(
+                    #    in_same_row(name),
+                        map(to_numbered_field, self.data.items()
+                    #)
+                ))))
             ))
 
         _widgets.update(dict([
@@ -310,7 +447,7 @@ class TransactionEditor(FocusWidget):
         header = Text(u'Fill Transaction', 'center')
         _copyright = Text(COPYRIGHT, 'center')
 
-        components = {
+        self.components.update({
             'bottom_button_bar': Columns(
                 [(8, self.buttons['done']), Divider(), (9, self.buttons['clear'])]
             ),
@@ -318,8 +455,8 @@ class TransactionEditor(FocusWidget):
                 lambda x: _widgets[x] if x is not None else Divider,
                 badge
             )),
-        }
-        components.update({
+        })
+        self.components.update({
             'bottom_pane': Columns([
                 Pile(map(
                     lambda x: _widgets[x] if x is not None else Divider(),
@@ -327,7 +464,7 @@ class TransactionEditor(FocusWidget):
                 )),
                 (self.graph.total_width+2, Pile([
                     LineBox(
-                        AttrMap(components['badge'], 'badge'),
+                        AttrMap(self.components['badge'], 'badge'),
                         title="Current Price", title_align='left',
                     ),
                     LineBox(
@@ -339,6 +476,7 @@ class TransactionEditor(FocusWidget):
         })
         connect_signal(self.buttons['done'], 'click', lambda _: self.save_and_clear_callback())
         connect_signal(self.buttons['clear'], 'click', lambda _: self.clear())
+        connect_signal(self.buttons['add'], 'click', lambda _: self.add_tag())
 
         banner = Pile([
             Padding(header, 'center', width=('relative', 100)),
@@ -356,10 +494,10 @@ class TransactionEditor(FocusWidget):
             ], dividechars=2), title='Product', title_align='left')
         })
 
-        components['side_pane'] = (12, Pile([
+        self.components['side_pane'] = (12, Pile([
             _widgets[r] if r is not None else Divider() for r in side_pane
         ]))
-        components['main_pane'] = []
+        self.components['main_pane'] = []
         for _, r in enumerate(layout):
             col = []
             for c in r:
@@ -369,33 +507,42 @@ class TransactionEditor(FocusWidget):
                     col.append(_widgets[c])
                 else:
                     col.append(Divider())
-            components['main_pane'].append(Columns(col))
+            self.components['main_pane'].append(Columns(col))
 
-        components['main_pane'] = Pile(components['main_pane'])
+        self.components['main_pane'] = Pile(self.components['main_pane'])
+        self.add_tag()
 
         widget = Pile([
             banner,
             Divider(),
             Columns([
-                components['main_pane'], components['side_pane']
+                self.components['main_pane'],
+                self.components['side_pane'],
+                (18,LineBox(
+                    self.components['tags'],
+                    title=f'Tags',
+                    title_align='left'
+                )),
+                self.components['gutter'],
             ], dividechars=2),
-            components['bottom_pane'],
+            self.components['bottom_pane'],
             Divider(),
-            components['bottom_button_bar']
+            self.components['bottom_button_bar']
         ])
         widget = Filler(widget, 'top')
         widget = AttrMap(widget, 'bg')
-        super().__init__(widget, map(
-            lambda x: next(w[1] for w in chain(
-                self.buttons.items(),
-                self.edit_fields.items(),
-                self.checkboxes.items()
-            ) if x == w[0]),
-            [
+        super().__init__(widget, [
                 'product', 'organic',
                 'unit', 'quantity', 'price',
-                'description',
+                'description', 'tags', 'add',
                 'done', 'clear',
                 'category', 'group',
                 'ts', 'store'
-            ]))
+            ], lambda: chain(
+                (('tags', w[0]) for w in self._tags),
+                self.buttons.items(),
+                self.edit_fields.items(),
+                self.checkboxes.items()
+            )
+        )
+        self.update()

+ 68 - 84
app/price_view.py → app/data/PriceView.py

@@ -1,21 +1,69 @@
 #
-# Copyright (c) Daniel Sheffield 2021 - 2022
+# Copyright (c) Daniel Sheffield 2021 - 2023
 #
 # All rights reserved
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from collections import (
+    OrderedDict,
+)
+from typing import Tuple
 from psycopg.sql import (
     Identifier,
     SQL,
     Literal,
-    Placeholder,
-    Composed,
-)
-from collections import (
-    OrderedDict,
+    Composable,
 )
+from .util import get_select, get_from
 
-def get_where(unit, product=None, category=None, group=None, organic=None, limit='90 days'):
+def get_selectors(
+    unit: str,
+    product: str,
+    window: SQL
+) -> OrderedDict[Tuple[str, Composable]]:
+    return  OrderedDict([
+        ('id', Identifier('transactions', 'id')),
+        ('ts_raw', SQL("""(transactions.ts AT TIME ZONE 'UTC')::timestamp without time zone""")),
+        ('%d/%m/%y %_I%P', SQL("""(transactions.ts AT TIME ZONE 'UTC')::timestamp without time zone""")),
+        ('code', Identifier('stores', 'code')),
+        ('$/unit', SQL("""TRUNC(
+    price / quantity / convert_unit(units.name, {unit}, {product}), 4
+)""").format(unit=Literal(unit), product=Literal(product))),
+        ('last', SQL(f"""TRUNC(last_value(
+    price / quantity / convert_unit(units.name, {{unit}}, {{product}})
+) OVER {window}, 4)
+""").format(unit=Literal(unit), product=Literal(product))),
+        ('avg', SQL(f"""TRUNC(sum(price) OVER {window} / sum(
+    quantity * convert_unit(units.name, {{unit}}, {{product}})
+) OVER {window}, 4)
+""").format(unit=Literal(unit), product=Literal(product))),
+        ('min', SQL(f"""TRUNC(min(
+    price / quantity / convert_unit(units.name, {{unit}}, {{product}})
+) OVER {window}, 4)
+""").format(unit=Literal(unit), product=Literal(product))),
+        ('max', SQL(f"""TRUNC(max(
+    price / quantity / convert_unit(units.name, {{unit}}, {{product}})
+) OVER {window}, 4)
+""").format(unit=Literal(unit), product=Literal(product))),
+        ('price', SQL("""TRUNC(price, 4)""")),
+        ('quantity', SQL("""TRUNC(
+    quantity * convert_unit(units.name, {unit}, {product}), 4
+)""").format(unit=Literal(unit), product=Literal(product))),
+        ('product', Identifier('products', 'name')),
+        ('category', Identifier('categories', 'name')),
+        ('group', Identifier('groups', 'name')),
+        ('organic', Identifier('organic')),
+    ])
+
+JOINS = OrderedDict([
+    ('units', ('id', 'unit_id')),
+    ('stores', ('id', 'store_id')),
+    ('products', ('id', 'product_id')),
+    ('categories', ('id', 'category_id')),
+    ('groups', ('id', 'group_id')),
+])
+
+def get_where(product=None, category=None, group=None, organic=None, limit='90 days'):
     where = [ ]
     if product is not None:
         where.append(SQL(' ').join([
@@ -48,9 +96,6 @@ def get_where(unit, product=None, category=None, group=None, organic=None, limit
                 interval=SQL("{literal}::interval").format(literal=Literal(limit))
             )
         )
-    where.append(SQL(
-        'convert_unit(units.name, {unit}, {product}) IS NOT NULL'
-    ).format(unit=Literal(unit), product=Literal(product)))
     return SQL('').join([
         SQL("WHERE"
             "\n      "),
@@ -58,89 +103,28 @@ def get_where(unit, product=None, category=None, group=None, organic=None, limit
     ])
 
 def get_historic_prices_statement(unit, sort=None, product=None, category=None, group=None, organic=None, limit='90 days'):
-    partition = f"(PARTITION BY {'organic,' if organic is not None else ''} product_id)"
+    window = f"""(
+PARTITION BY {'organic,' if organic is not None else ''} product_id
+ORDER BY ts
+ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
+)"""
     organic_sort = f"{'organic,' if organic is not None else ''}"
     sort_sql = SQL('').join([
         SQL('{sort} {direction},').format(
             sort=Identifier(f'{sort}'),
-            direction = SQL('DESC' if sort == 'ts' else 'ASC')
+            direction=SQL('DESC' if sort == 'ts' else 'ASC')
         ),
     ]) if sort is not None else SQL('')
 
-    select = OrderedDict([
-        ('id', Identifier('transactions','id')),
-        ('ts_raw', SQL("""(transactions.ts AT TIME ZONE 'UTC')::timestamp without time zone""")),
-        ('%d/%m/%y %_I%P', SQL("""(transactions.ts AT TIME ZONE 'UTC')::timestamp without time zone""")),
-        ('code', Identifier('stores', 'code')),
-        ('$/unit', SQL("""TRUNC(
-    price / quantity / convert_unit(units.name, {unit}, {product}), 4
-)""").format(unit=Literal(unit), product=Literal(product))),
-        ('avg', SQL(f"""TRUNC(sum(price) OVER {partition} / sum(
-    quantity * convert_unit(units.name, {{unit}}, {{product}})
-) OVER {partition}, 4)
-""").format(unit=Literal(unit), product=Literal(product))),
-        ('min', SQL(f"""TRUNC(min(
-    price / quantity / convert_unit(units.name, {{unit}}, {{product}})
-) OVER {partition}, 4)
-""").format(unit=Literal(unit), product=Literal(product))),
-        ('max', SQL(f"""TRUNC(max(
-    price / quantity / convert_unit(units.name, {{unit}}, {{product}})
-) OVER {partition}, 4)
-""").format(unit=Literal(unit), product=Literal(product))),
-        ('price', SQL("""TRUNC(price, 4)""")),
-        ('quantity', SQL("""TRUNC(
-    quantity * convert_unit(units.name, {unit}, {product}), 4
-)""").format(unit=Literal(unit), product=Literal(product))),
-        ('product', Identifier('products','name')),
-        ('category', Identifier('categories', 'name')),
-        ('group', Identifier('groups', 'name')),
-        ('organic', Identifier('organic')),
-    ])
     statement = SQL('\n').join([
-        SQL('').join([
-            SQL("SELECT"
-                "\n  "),
-            SQL(','
-                "\n  ").join([
-                SQL(' ').join([v, SQL('AS'), Identifier(f'{k}')]) for k, v in select.items()
-            ])
-        ]),
-        SQL('').join([
-            SQL("FROM"
-                "\n       "),
-            SQL("\n  JOIN ").join([
-                SQL("transactions"),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('units'),
-                    key=Identifier('id'),
-                    index=Identifier('unit_id'),
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('stores'),
-                    key=Identifier('id'),
-                    index=Identifier('store_id')
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('products'),
-                    key=Identifier('id'),
-                    index=Identifier('product_id')
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('categories'),
-                    key=Identifier('id'),
-                    index=Identifier('category_id')
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('groups'),
-                    key=Identifier('id'),
-                    index=Identifier('group_id')
-                ),
-            ]),
-        ]),
-        get_where(unit, product=product, category=category, group=group, organic=organic, limit=limit),
-        SQL('ORDER BY {organic_sort} {sort} code, product, category, "group", "$/unit" ASC, ts DESC').format(
+        get_select(get_selectors(unit, product, window)),
+        get_from("transactions", JOINS),
+        get_where(product=product, category=category, group=group, organic=organic, limit=limit),
+        SQL("""
+ORDER BY {organic_sort} {sort} code, product, category, "group", "$/unit" ASC, ts DESC
+""").format(
             sort=sort_sql,
             organic_sort=SQL(organic_sort),
-        ),
+       ),
     ])
     return statement

+ 11 - 17
app/db_utils.py → app/data/QueryManager.py

@@ -1,5 +1,5 @@
 #
-# Copyright (c) Daniel Sheffield 2021 - 2022
+# Copyright (c) Daniel Sheffield 2021 - 2023
 #
 # All rights reserved
 #
@@ -7,31 +7,24 @@
 from sqlite3 import Cursor
 import time
 from typing import Any, Callable
-from .txn_view import (
+from .TransactionView import (
     get_table_statement,
     get_transactions_statement,
     get_session_transactions_statement,
+    NON_IDENTIFIER_COLUMNS,
 )
-from .price_view import(
+from .PriceView import(
     get_historic_prices_statement,
 )
 from dateutil.parser import parse as parse_time
 import pandas as pd
-NON_IDENTIFIER_COLUMNS = [
-    'ts',
-    'store',
-    'quantity',
-    'unit',
-    'price',
-    'organic',
-]
-
 display_map = {
     'ts': lambda x: f"{time.strftime('%Y-%m-%d %H:%M', (x.year, x.month, x.day, x.hour, x.minute, 0, 0, 0, 0))}",
     '%d/%m/%y %_I%P': lambda x: f"{time.strftime('%d/%m/%y %_I%P', (x.year, x.month, x.day, x.hour, x.minute, 0, 0, 0, 0))}",
     'price': lambda x: f'{x:.4f}',
-    'quantity': lambda x: f'{x:.2f}',
+    'quantity': lambda x: f'{x:.2f}' if x is not None else None,
     'organic': lambda x: 'yes' if x else 'no',
+    'tags': lambda x: '' if not x else x,
 }
 display_mapper: Callable[
     [Any, str], str
@@ -61,10 +54,10 @@ def get_session_transactions(cursor, statement, display):
     if df.empty:
         return ''
     return df.drop(labels=[
-        'id', 'ts', 'store', 'code',
+        'id', 'ts', 'store', 'code', 'quantity',
     ], axis=1).to_string(header=[
         'Description', 'Volume', 'Unit', 'Price', '$/unit', 'Total',
-        'Group', 'Category', 'Product', 'Organic',
+        'Group', 'Category', 'Product', 'Organic', 'Tags'
     ], justify='justify-all', max_colwidth=60, index=False)
 
 
@@ -99,11 +92,12 @@ def unique_suggestions(cur, statement, name, display, exclude=NON_IDENTIFIER_COL
         'group',
         'unit',
         'store',
+        'tags',
     }
     if len(ret) > 0 or name not in tables:
         return ret
 
-    items = (i for i in filter(lambda x: record_matches(x, **{ name: kwargs[name] }),
+    items = (i for i in filter(lambda x: record_matches(x, **{ name: kwargs[name] }) if name in kwargs else True,
         get_data(cur, get_table_statement(name), display)))
     ret = sorted(set(map(lambda x: x[name], items)))
     return ret
@@ -145,7 +139,7 @@ class QueryManager(object):
         return get_session_transactions(self.cursor, statement, self.display)
 
     def unique_suggestions(self, name, **kwargs):
-        statement = get_transactions_statement()
+        statement = get_transactions_statement(name, **kwargs)
         return unique_suggestions(self.cursor, statement, name, self.display, **kwargs)
 
     def insert_new_product(self, product, category, group):

+ 161 - 0
app/data/TransactionView.py

@@ -0,0 +1,161 @@
+#
+# Copyright (c) Daniel Sheffield 2021 - 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from collections import (
+    OrderedDict,
+)
+from psycopg.sql import (
+    Composable,
+    Identifier,
+    SQL,
+    Literal,
+)
+from .util import get_select, get_from, get_groupby
+ALIAS_TO_TABLE = {
+    'product': 'products',
+    'category': 'categories',
+    'group': 'groups',
+    'tags': 'tags',
+    'unit': 'units',
+    'store': 'stores',
+}
+NON_IDENTIFIER_COLUMNS = [
+    'ts',
+    'store',
+    'quantity',
+    'unit',
+    'price',
+    'organic',
+    'tags',
+]
+
+def get_table_statement(alias):
+    tables = ALIAS_TO_TABLE
+    return SQL("SELECT {column} AS {alias} FROM {table}").format(
+        column=Identifier(tables[alias], 'name'),
+        table=Identifier(tables[alias]),
+        alias=Identifier(alias),
+    )
+
+def get_transactions_statement(name, **kwargs):
+    where = []
+    for k,v in kwargs.items():
+        where.append(SQL('{key} {condition} {value}').format(
+            key=Identifier(ALIAS_TO_TABLE[k], 'name'),
+            condition=SQL('ILIKE' if k == name else '='),
+            value=Literal(f'%{v}%' if k == name else v),
+        ) if k in ALIAS_TO_TABLE and (k not in NON_IDENTIFIER_COLUMNS or k == name) and v else SQL('TRUE'))
+    statement = SQL('\n').join([
+        get_select(dict([('tags', Identifier('tags','name')), *[
+            (k,v) for k,v in SELECT.items() if k != 'tags'
+        ]])),
+        get_from("transactions", JOINS),
+        SQL('').join([SQL('WHERE '), SQL('\n    AND ').join(where)]),
+    ])
+    return statement
+
+SELECT = OrderedDict([
+    ('id', Identifier('transactions', 'id')),
+    ('ts', Identifier('transactions', 'ts')),
+    ('store', Identifier('stores', 'name')),
+    ('code', Identifier('stores', 'code')),
+    ('description', Identifier('transactions', 'description')),
+    ('volume', Identifier('quantity')),
+    ('unit', Identifier('units', 'name')),
+    ('price', Identifier('price')),
+    ('quantity', Identifier('quantity')),
+    ('$/unit', SQL("""TRUNC(price/quantity,4)""")),
+    ('total', SQL("""sum(transactions.price)
+OVER (
+    PARTITION BY transactions.ts::date
+    ORDER BY transactions.id
+    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
+)""")),
+    ('group', Identifier('groups', 'name')),
+    ('category', Identifier('categories', 'name')),
+    ('product', Identifier('products', 'name')),
+    ('organic', Identifier('organic')),
+    ('tags', SQL(
+        """array_agg({tag_name}) FILTER (WHERE {tag_name} IS NOT NULL)"""
+    ).format(
+        tag_name=Identifier('tags','name'),
+    ))
+
+])
+
+GROUPBY = OrderedDict([
+    ('id', Identifier('transactions', 'id')),
+    ('ts', Identifier('transactions', 'ts')),
+    ('store', Identifier('stores', 'name')),
+    ('code', Identifier('stores', 'code')),
+    ('description', Identifier('transactions', 'description')),
+    ('volume', Identifier('quantity')),
+    ('unit', Identifier('units', 'name')),
+    ('price', Identifier('price')),
+    ('quantity', Identifier('quantity')),
+    ('group', Identifier('groups', 'name')),
+    ('category', Identifier('categories', 'name')),
+    ('product', Identifier('products', 'name')),
+    ('organic', Identifier('organic')),
+])
+
+
+
+JOINS = OrderedDict([
+    ('units', ('id', 'unit_id')),
+    ('stores', ('id', 'store_id')),
+    ('products', ('id', 'product_id')),
+    ('categories', ('id', 'category_id')),
+    ('groups', ('id', 'group_id')),
+    ('tags_map', ('transaction_id', ('transactions','id'))),
+    ('tags', ('id', ('tags_map','tag_id'))),
+])
+
+def get_where(date, store, full_name=False, exact_time=False):
+    where = []
+    if store is not None:
+        where.append(SQL(' ').join([
+            Identifier('stores', 'name' if full_name else 'code'),
+            SQL('='),
+            Literal(store)
+        ]))
+    where.append(
+        SQL("{ts} at time zone 'utc' BETWEEN {date}::date AND {date}::date + {interval}::interval").format(
+            ts=Identifier('ts'),
+            date=Literal(str(date)),
+            interval=Literal('23 hours 59 minutes 59 seconds'),
+        ) if not exact_time else SQL("{ts} at time zone 'utc' = {date}").format(
+            ts=Identifier('ts'),
+            date=Literal(str(date)),
+        )
+    )
+    return SQL('').join([
+        SQL("WHERE"
+            "\n      "),
+        SQL("\n  AND ").join(where),
+    ])
+
+def get_sort() -> Composable:
+    return SQL('ORDER BY {_id} DESC').format(
+        _id=Identifier('transactions', 'id')
+    )
+
+def get_session_transactions_statement(date, store, full_name=False, exact_time=False):
+    statement = SQL('\n').join([
+        get_select(SELECT),
+        get_from("transactions", JOINS),
+        get_where(
+            date if exact_time else date.replace(
+                hour=0, minute=0, second=0, microsecond=0
+            ),
+            store,
+            full_name=full_name,
+            exact_time=exact_time
+        ),
+        get_groupby(GROUPBY),
+        get_sort(),
+    ])
+    return statement

+ 6 - 0
app/data/__init__.py

@@ -0,0 +1,6 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY

+ 45 - 0
app/data/util.py

@@ -0,0 +1,45 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from typing import Tuple
+from psycopg.sql import (
+    Identifier,
+    SQL,
+    Composable,
+)
+
+def get_select(alias_to_sql: dict[str,Composable]) -> Composable:
+    select = SQL(""",
+    """).join([
+        SQL(' ').join([
+            v, SQL('AS'), Identifier(k)
+        ]) for k, v in alias_to_sql.items()
+    ])
+    return SQL("""
+    """).join([SQL("SELECT"), *select])
+
+def get_from(
+    base: str,
+    table_to_join_on: dict[Tuple[str, Tuple[str,str]]]
+) -> Composable:
+    joins = [
+        SQL("{table} ON {table_column} = {other_column}").format(
+            table=Identifier(table),
+            table_column=Identifier(table, table_column),
+            other_column=Identifier(*other_column) if isinstance(other_column,tuple) else Identifier(other_column)
+        ) for table, (table_column, other_column) in table_to_join_on.items()
+    ]
+    return SQL('').join([SQL("""FROM {base}
+LEFT JOIN """).format(base=Identifier(base)),
+        SQL("""
+LEFT JOIN """).join(joins)])
+
+def get_groupby(alias_to_sql: dict[str, Composable]) -> Composable:
+    groupby = SQL(""",
+    """).join([ v for k, v in alias_to_sql.items() if k != 'tags'])
+    return SQL("""
+    """).join([SQL("GROUP BY"), *groupby])
+

+ 34 - 0
app/parse_recipe.py

@@ -0,0 +1,34 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+import yaml
+from yaml.loader import SafeLoader
+from .data.QueryManager import QueryManager
+import re
+
+def parse_recipe(fh, query_manager: QueryManager):
+    contents = yaml.load(fh, Loader=SafeLoader)
+    recipe = {
+        'ingredients': [],
+        'feeds': None,
+        'instructions': None,
+    }
+    for ingredient in contents['ingredients']:
+        match = re.search(
+            r'(?P<product>.*) (?P<quantity>[0-9\.]+) ?(?P<unit>.*)', ingredient
+        )
+        assert match is not None, f'Could parse {ingredient}'
+        units = query_manager.unique_suggestions('unit', unit=match.group('unit'))
+        assert match.group('unit') in units, f"Unknown unit: '{match.group('unit')}'"
+        recipe['ingredients'].append(
+            [match.group('product').strip(), match.group('quantity'), match.group('unit')]
+        )
+    for k in set(recipe.keys()) - set(['ingredients']):
+        if k in contents:
+            recipe[k] = contents[k]
+    return recipe
+
+

+ 0 - 123
app/txn_view.py

@@ -1,123 +0,0 @@
-#
-# Copyright (c) Daniel Sheffield 2021 - 2022
-#
-# All rights reserved
-#
-# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from psycopg.sql import (
-    Identifier,
-    SQL,
-    Literal,
-    Placeholder,
-    Composed,
-)
-from collections import (
-    OrderedDict,
-)
-
-def get_table_statement(alias):
-    tables = {
-        'product': 'products',
-        'category': 'categories',
-        'group': 'groups',
-        'unit': 'units',
-        'store': 'stores',
-    }
-    return SQL("SELECT {column} AS {alias} FROM {table}").format(
-        column=Identifier(tables[alias], 'name'),
-        table=Identifier(tables[alias]),
-        alias=Identifier(alias),
-    )
-
-def get_transactions_statement():
-    return SQL("SELECT * FROM transaction_view")
-
-select = OrderedDict([
-    ('id', Identifier('transactions','id')),
-    ('ts', Identifier('transactions', 'ts')),
-    ('store', Identifier('stores', 'name')),
-    ('code', Identifier('stores', 'code')),
-    ('description', Identifier('transactions','description')),
-    ('volume', Identifier('quantity')),
-    ('unit', Identifier('units', 'name')),
-    ('price', Identifier('price')),
-    ('$/unit', SQL("""TRUNC(price/quantity,4)""")),
-    ('total', SQL("""sum(transactions.price) OVER (PARTITION BY transactions.ts::date ORDER BY transactions.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)""")),
-    ('group', Identifier('groups','name')),
-    ('category', Identifier('categories','name')),
-    ('product', Identifier('products','name')),
-    ('organic', Identifier('organic')),
-])
-
-def get_where(date, store, full_name=False, exact_time=False):
-    where = [ ]
-    if store is not None:
-        where.append(SQL(' ').join([
-            Identifier('stores', 'name' if full_name else 'code'),
-            SQL('='),
-            Literal(store)
-        ]))
-    where.append(
-        SQL("{ts} at time zone 'utc' BETWEEN {date}::date AND {date}::date + {interval}::interval").format(
-            ts=Identifier('ts'),
-            date=Literal(str(date)),
-            interval=Literal('23 hours 59 minutes 59 seconds'),
-        ) if not exact_time else SQL("{ts} at time zone 'utc' = {date}").format(
-            ts=Identifier('ts'),
-            date=Literal(str(date)),
-        )
-    )
-    return SQL('').join([
-        SQL("WHERE"
-            "\n      "),
-        SQL("\n  AND ").join(where),
-    ])
-
-def get_session_transactions_statement(date, store, full_name=False, exact_time=False):
-    statement = SQL('\n').join([
-        SQL('').join([
-            SQL("SELECT"
-                "\n  "),
-            SQL(','
-                "\n  ").join([
-                SQL(' ').join([v, SQL('AS'), Identifier(f'{k}')]) for k, v in select.items()
-            ])
-        ]),
-        SQL('').join([
-            SQL("FROM"
-                "\n       "),
-            SQL("\n  JOIN ").join([
-                SQL("transactions"),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('units'),
-                    key=Identifier('id'),
-                    index=Identifier('unit_id'),
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('stores'),
-                    key=Identifier('id'),
-                    index=Identifier('store_id')
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('products'),
-                    key=Identifier('id'),
-                    index=Identifier('product_id')
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('categories'),
-                    key=Identifier('id'),
-                    index=Identifier('category_id')
-                ),
-                SQL("{table} ON {table}.{key} = {index}").format(
-                    table=Identifier('groups'),
-                    key=Identifier('id'),
-                    index=Identifier('group_id')
-                ),
-            ]),
-        ]),
-        get_where(date if exact_time else date.replace(
-            hour=0, minute=0, second=0, microsecond=0
-        ), store, full_name=full_name, exact_time=exact_time),
-        SQL('ORDER BY {_id} DESC').format(_id=Identifier('transactions','id')),
-    ])
-    return statement

+ 16 - 14
app/widgets.py

@@ -128,21 +128,21 @@ class NoTabCheckBox(urwid.CheckBox):
 
 class FocusWidget(urwid.WidgetWrap):
     ignore_focus = True
-    def __init__(self, widget, focus_widgets):
+    def __init__(self, widget, focus_order, focus_widgets):
         super().__init__(widget)
-        self._focus_widgets = [ w for w in focus_widgets ]
-        assert len(self._focus_widgets) > 0
-        for w, p in self.iter_focus_paths():
-            if w is self._focus_widgets[0]:
-                self.container.set_focus_path(p)
+        self._focus_order = focus_order
+        self._focus_widgets = focus_widgets
+        p = next(p for (k,w), p in self.iter_focus_paths() if k == focus_order[0])
+        self.container.set_focus_path(p)
 
     @property
     def container(self):
         return self._w.original_widget.original_widget
 
     def find_widget(self, widget=None):
-        if widget in self._focus_widgets:
-            return widget
+        found = next((x for x in self._focus_widgets() if x[1] is widget), None)
+        if found is not None:
+            return found
         _w1 = getattr(widget, 'original_widget', None)
         _w2 = getattr(widget, 'contents', None)
         if _w2 is not None:
@@ -166,7 +166,7 @@ class FocusWidget(urwid.WidgetWrap):
         yield from chain(*_chain)
 
     def focus_on(self, w):
-        _, p = next(filter(lambda x: x[0] is w, self.iter_focus_paths()))
+        _, p = next(filter(lambda x: x[0][1] is w, self.iter_focus_paths()))
         path = self.container.get_focus_path()
         while path != p:
             self.advance_focus()
@@ -175,14 +175,16 @@ class FocusWidget(urwid.WidgetWrap):
     def iter_focus_paths(self):
         for c, path in self.iter_containers():
             for idx, w in enumerate(c.contents):
-                yield self.find_widget(widget=w[0]), [*path, idx]
+                r = self.find_widget(widget=w[0])
+                if r is not None:
+                    yield r, [*path, idx]
 
     def advance_focus(self, reverse=False):
         _c = self.container.get_focus_path()
-        _p = [ x[1] for w,x in product(self._focus_widgets, [x for x in filter(
-            lambda x: x[0] is not None,
-            self.iter_focus_paths()
-        )]) if x[0] is w ]
+        _p = [ next((x[1] for x in self.iter_focus_paths() if x[0][1] is w)) for k,(n,w) in product(
+            self._focus_order,
+            self._focus_widgets()
+        ) if k == n]
 
         for _prev, _cur, _next in zip([_p[-1], *_p[:-1]], _p, [*_p[1:], _p[0]]):
             if list(_c) == list(_cur):

+ 6 - 2
grocery_transactions.py

@@ -10,7 +10,7 @@ from psycopg import Cursor
 from urwid import raw_display, WidgetPlaceholder, SolidFill, MainLoop
 from app.activities import ActivityManager, show_or_exit
 from app.activities.TransactionEditor import TransactionEditor
-from app.db_utils import QueryManager, display_mapper
+from app.data.QueryManager import QueryManager, display_mapper
 from app.palette import solarized
 
 try:
@@ -92,10 +92,14 @@ class GroceryTransactionEditor(WidgetPlaceholder):
         price = data['price']
         product = data['product']
         organic = 'true' if data['organic'] is True else 'false'
+        tags = ', '.join([
+            f'$quot${v}$quot$' for t,v in data.items() if 'tags' in t and v
+        ])
+        tags = f", ARRAY[{tags}]" if tags else ''
         statement = \
             f"CALL insert_transaction('{ts}', $store${store}$store$, " \
             f"$descr${description}$descr$, {quantity}, $unit${unit}$unit$, " \
-            f"{price}, $produ${product}$produ$, {organic});\n"
+            f"{price}, $produ${product}$produ$, {organic}{tags});\n"
         self.log.write(statement)
         self.log.flush()
         self.cur.execute(statement)

+ 1 - 1
price_check.py

@@ -9,7 +9,7 @@ from psycopg import Cursor
 from urwid import raw_display, WidgetPlaceholder, SolidFill, MainLoop
 from app.activities import ActivityManager, show_or_exit
 from app.activities.PriceCheck import PriceCheck
-from app.db_utils import QueryManager, display_mapper
+from app.data.QueryManager import QueryManager, display_mapper
 from app.palette import high_contrast
 
 try:

+ 108 - 0
recipe.py

@@ -0,0 +1,108 @@
+#!/usr/bin/python3
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from psycopg import Cursor
+from urwid import raw_display, WidgetPlaceholder, SolidFill, MainLoop
+from app.activities import ActivityManager, show_or_exit
+from app.activities.RecipeEditor import RecipeEditor
+from app.data.QueryManager import QueryManager, display_mapper
+from app.palette import high_contrast
+from app import parse_recipe
+import pandas as pd
+import sys
+
+try:
+    from db_credentials import HOST, PASSWORD
+    host = f'host={HOST}'
+    password = f'password={PASSWORD}'
+except:
+    host = ''
+    password = ''
+
+try:
+    import os
+
+    import psycopg
+    user = os.getenv('USER')
+    conn = psycopg.connect(f"{host} dbname=grocery user={user} {password}")
+    cur: Cursor = conn.cursor()
+except:
+    print('Failed to set up db connection. Entering Mock mode')
+    exit(1)
+    #from mock import *
+
+class Recipe(WidgetPlaceholder):
+    def __init__(self, activity_manager):
+        super().__init__(SolidFill(u'/'))
+        self.activity_manager = activity_manager
+        recipe = self.activity_manager.get('recipe_editor')
+
+        self.activity_manager.show(self)
+        self.activity_manager.show(recipe)
+
+cur.execute("BEGIN;")
+with open('units.sql') as f:
+    to_exec = ''
+    quote = False
+    conn.add_notice_handler(lambda x: print(f"[{x.severity}] {x.message_primary}"))
+    for line in filter(lambda x: x.strip() not in ('BEGIN;','ROLLBACK;'), f):
+        if line.startswith("""$$"""):
+            quote = True if not quote else False
+        to_exec += line
+        if not line.strip().endswith(';'):
+            continue
+        if quote:
+            continue
+        cur.execute(to_exec)
+        try:
+            data = pd.DataFrame(cur.fetchall(), columns=[
+                x.name for x in cur.description
+            ])
+            if not data.empty:
+                print()
+                print(cur._last_query)
+            print(data.to_string(index=False))
+            
+        except psycopg.ProgrammingError:
+            pass
+        to_exec = ''
+    
+    input("Press enter to continue")
+
+activity_manager = ActivityManager()
+query_manager = QueryManager(cur, display_mapper)
+
+fname = sys.argv[1]
+with open(fname) as f:
+    recipe = parse_recipe.parse_recipe(f, query_manager)
+
+activity_manager.create(RecipeEditor,
+    'recipe_editor',
+    activity_manager, query_manager,
+    fname, recipe,
+)
+
+app = Recipe(activity_manager)
+
+def iter_palettes():
+    palettes = [ v for k,v in high_contrast.theme.items() ]
+    while True:
+        p = palettes.pop(0)
+        palettes.append(p)
+        yield p
+
+palettes = iter_palettes()
+
+screen = raw_display.Screen()
+loop = MainLoop(app, next(palettes), screen=screen,
+        unhandled_input=lambda k: show_or_exit(k, screen=screen, palettes=palettes),
+        pop_ups=True)
+loop.run()
+
+cur.execute("ROLLBACK")
+cur.close()
+conn.close()

+ 4 - 0
recipes/Blank.yaml

@@ -0,0 +1,4 @@
+feeds: 1
+ingredients: []
+instructions: |-
+

+ 14 - 0
recipes/Gelatin_Bars.yaml

@@ -0,0 +1,14 @@
+feeds: 16
+ingredients:
+- Gelatin 5.5 Tbsp (metric)
+- Honey 1 Tbsp (metric)
+- Stevia 0.125 tsp (metric)
+- Pukka Tea 2 Bags
+instructions: |-
+  Heat 2 cups water or nettle tea on stove and add 2 tea bags licorice cinamon **Pukka Tea**.
+  Once it comes to a simmer, let steep 10 to 15 minutes.
+  Meanwhile, combine 2/3 cup water with 5 to 5.5 Tbsp **Gelatin** powder, one spoon at a time.
+  Mix 1 to 2 Tbsp **Honey** and 1/8 tsp **Stevia** powder with hot tea mixture.
+  Combine Gelled cool liquid with hot liquid and dissolve.
+  Pour the warm liquid into two rectangular glass containers and place in refrigerator to set.
+  Cut into bars. Keeps almost a week in the fridge.

+ 15 - 0
recipes/Ginger_Syrup.yaml

@@ -0,0 +1,15 @@
+---
+ingredients:
+  - White Sugar 200g #1 cup
+  - Ginger  115g #1 cup
+    # 3/4 cup water
+
+instructions: |
+  Combine **White Sugar** and water on stove. Stir.
+  Add **Ginger** . 
+  Simmer 15 minutes. 
+  Let sit 1 hour. 
+  Strain.
+  Yeilds about 1 cup ginger syrup.
+
+feeds: 16

+ 12 - 0
recipes/Matzo.yaml

@@ -0,0 +1,12 @@
+feeds: 16
+ingredients:
+- Wheat Berries 241 g
+- Olive Oil 0.25 Cup (metric)
+- Salt 1 tsp (metric)
+instructions: |-
+  Soak **Wheat Berries** and sprout. Dehydrate in oven. Grind into flour.
+  Preheat oven to 200C.
+  Combine 150 grams of warm water with **Salt**.
+  Mix **Olive Oil** and flour and water into a dough.
+  Roll out into an oval. Score.
+  Bake at 200C for 5 minutes, flip and repeat.

+ 25 - 0
recipes/Rajma_Masala.yaml

@@ -0,0 +1,25 @@
+feeds: 5
+ingredients:
+- Red Lentils 1 Cup (metric)
+- Coconut Oil 2 Tbsp (metric)
+- Onions 1 Pieces
+- Canned Tomatoes 800 g
+- Ginger 75 g
+- Garlic 75 g
+- Jar of Chillis 25 g
+- Salt 5 g
+- Garam Masala 1.4 g
+- Tumeric 1 g
+- Kassori Methi 1 g
+- Coconut Cream 6 Tbsp (metric)
+- Brown Rice 2 Cup (metric)
+instructions: |-
+  Soak **Brown Rice** overnight.
+  Soak **Red Lentils** in water for 30 minutes or so.
+  Sautee **Cumin Seeds** in **Coconut Oil** until browned.
+  Sautee choppped **Onions** until golden.
+  Sautee minced  **Ginger**, **Garlic**, and 4 chillies from **Jar of Chillis** for 30 seconds.
+  Add 2 cans of  **Canned Tomatoes** and simmer a minute.Tbsp
+  Add **Salt**, **Coriander**, **Chili Powder**, **Garam Masala**, **Tumeric**
+  Drain Red Lentils and add to simmer 5 or 8 minutes.
+  Add **Coconut Cream** and **Kassori Methi** and turn off heat.

+ 15 - 0
recipes/Sourdough_Crackers.yaml

@@ -0,0 +1,15 @@
+feeds: 16
+ingredients:
+- Wheat Berries 125 g
+- Butter .25 Cup (US)
+- Ryecorn 114 g
+- Salt 1 tsp (metric)
+- Olive Oil 1 Tbsp (metric)
+instructions: |-
+  Grind **Wheat Berries** into flour.
+  Melt **Butter**
+  **Ryecorn** will be in the form of 227g of 1:1 sourdough starter discard. (~ 1 cup)
+  Mix all of above with **Salt** to form a dough. Let rest in refrigerator up to 24 hrs.
+  After shaping and cutting into crackers, drizzle with **Olive Oil**
+  Bake at 350F for 25 to 30 minutes.
+  Allow to cool fully on rack, without parchment paper.

+ 3 - 3
reconcile.py

@@ -1,6 +1,6 @@
 #!/usr/bin/python3
 #
-# Copyright (c) Daniel Sheffield 2021
+# Copyright (c) Daniel Sheffield 2021 - 2023
 #
 # All rights reserved
 #
@@ -15,8 +15,8 @@ import gnucash
 import sys
 import os
 import psycopg
-from app.db_utils import cursor_as_dict
-from app.txn_view import get_session_transactions_statement as get_statement
+from app.data.QueryManager import cursor_as_dict
+from app.data.TransactionView import get_session_transactions_statement as get_statement
 
 try:
     from db_credentials import HOST, PASSWORD

+ 2 - 0
requirements.txt

@@ -1,9 +1,11 @@
 #numpy: debian package libatlas3-base required on raspberrypi
 #psycopg: debian package libpq5 required
 additional-urwid-widgets
+markdown
 git+https://github.com/djyotta/urwid.git@31cd6b518a803f146ba149461bd7f64234289453
 psycopg
 faker
 pandas
+pyyaml
 numpy
 seaborn

+ 105 - 0
units.sql

@@ -0,0 +1,105 @@
+BEGIN;
+CALL insert_unit ('fl. oz. (US)');
+CALL insert_unit ('Pint (US)');
+CALL insert_unit ('Tbsp (US)');
+CALL insert_unit ('tsp (US)');
+CALL insert_unit ('Cup (US)');
+CALL insert_unit ('Cup (US Legal)');
+CALL insert_unit ('Quart (US)');
+CALL insert_unit ('Gallon (US)');
+CALL insert_unit ('tsp (metric)');
+CALL insert_unit ('Tbsp (metric)');
+CALL insert_unit ('Cup (metric)');
+SELECT * FROM units;
+-- using customary cup as per https://en.wikipedia.org/wiki/Cup_(unit)
+CALL insert_unit_conversion ('tsp (metric)', 'mL', 5);
+CALL insert_unit_conversion ('Tbsp (metric)', 'mL', 15);
+CALL insert_unit_conversion ('Cup (metric)', 'mL', 250);
+CALL insert_unit_conversion ('Cup (US)', 'mL', 236.5882365);
+CALL insert_unit_conversion ('Cup (US Legal)', 'mL', 240);
+CALL insert_unit_conversion ('Cup (US)', 'Tbsp (US)', 16);
+CALL insert_unit_conversion ('Cup (US)', 'tsp (US)',  48);
+CALL insert_unit_conversion ('Cup (US)', 'fl. oz. (US)', 8);
+CALL insert_unit_conversion ('Pint (US)', 'Cup (US)', 2);
+CALL insert_unit_conversion ('Quart (US)', 'Cup (US)', 4);
+CALL insert_unit_conversion ('Gallon (US)', 'Cup (US)', 16);
+CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Whole Oats', 0.214/2);
+CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Puffed Rice', .03);
+CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Coconut Desiccated', .071*2);
+-- CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Peanut butter', .24);
+CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Honey', .68);
+CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Brown Sugar', .055*4);
+CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Red Lentils', .199);
+CALL insert_unit_conversion ('L', 'kg', 'Coconut Oil', .95);
+CALL insert_unit_conversion ('Cup (metric)', 'kg', 'Brown Rice', .19);
+SELECT * FROM conversions;
+DO
+$$
+DECLARE
+  item record;
+  msg text;
+  expected numeric(20,10);
+  result numeric(20,10);
+BEGIN
+  FOR item IN SELECT * FROM (VALUES
+      ('L', 'mL', '', 1000), -- *
+      ('mL', 'L', '', 1.0/1000),
+      ('tsp (metric)', 'mL', '', 5), -- *
+      ('Tbsp (metric)', 'mL', '', 15), -- *
+      ('Cup (metric)', 'mL', '', 250), -- *
+      ('Cup (US)', 'tsp (metric)', '', 236.5882365/5),
+      ('Cup (US)', 'Tbsp (metric)', '', 236.5882365/15),
+      ('Cup (US Legal)', 'mL', '', 240), -- *
+      ('Cup (US Legal)', 'Tbsp (metric)', '', 16),
+      ('L', 'Cup (metric)', '', 4),
+      ('Cup (US)', 'Cup (metric)', '', 236.5882365/250),
+      ('Cup (US)', 'mL', '', 236.5882365),
+      ('mL', 'Cup (US)', '', 1/236.5882365), -- *
+      ('L', 'Cup (US)', '', 1000/236.5882365),
+      ('Cup (US)', 'Tbsp (US)', '', 16), -- *
+      ('mL', 'Tbsp (US)', '', 16/236.5882365),
+      ('Tbsp (US)', 'tsp (US)', '', 3), -- *
+      ('mL', 'tsp (US)', '', 16*3/236.5882365),
+      ('Pint (US)', 'fl. oz. (US)', '', 16), -- *
+      ('Pint (US)', 'Cup (US)', '', 2), -- *
+      ('Cup (US)', 'fl. oz. (US)', '', 8),
+      ('fl. oz. (US)', 'Tbsp (US)', '', 16*2/16),
+      ('fl. oz. (US)', 'tsp (US)', '', 16*2*3/16),
+      ('Quart (US)', 'fl. oz. (US)', '', 32), -- *
+      ('fl. oz. (US)', 'Quart (US)', '', 1.0/32),
+      ('Gallon (US)', 'fl. oz. (US)', '', 128),
+      ('Gallon (US)', 'Pint (US)', '', 8),
+      ('Gallon (US)', 'Quart (US)', '', 4), -- *
+      ('Gallon (US)', 'mL', '', 4*32*236.5882365/8),
+      ('Cup (metric)', 'kg', 'Whole Oats', 0.214/2), -- *
+      ('Cup (metric)', 'kg', 'Puffed Rice', .03), -- *
+      ('Cup (metric)', 'kg', 'Coconut Desiccated', .071*2), -- *
+      -- ('Cup (metric)', 'kg', 'Peanut butter', .24),
+      ('Cup (metric)', 'kg', 'Honey', .68), -- *
+      ('kg', 'mL', 'Honey', 250/.68),
+      ('Cup (metric)', 'kg', 'Brown Sugar', .055*4), -- *
+      ('g', 'mL', 'Brown Sugar', 250/.055/4/1000),
+      ('Cup (metric)', 'kg', 'Red Lentils', .199), -- *
+      ('L', 'kg', 'Coconut Oil', .95), -- *
+      ('Cup (metric)', 'g', 'Red Lentils', 199),
+      ('mL', 'g', 'Coconut Oil', 950.0/1000),
+      ('Cup (metric)', 'kg', 'Brown Rice', .19) -- *
+    ) AS tests(f,t,p,e) LOOP
+    BEGIN
+      SELECT convert_unit(item.f, item.t, item.p) INTO result;
+      SELECT item.e INTO expected;
+      ASSERT result = expected, 'convert_unit('''||item.f||''', '''||item.t||''', '''||item.p||''') = '||result||' != '||expected;
+      RAISE NOTICE ' convert_unit(''%'', ''%'', ''%'') = %', item.f, item.t, item.p, result;
+      EXCEPTION
+        WHEN assert_failure THEN
+        BEGIN
+          GET STACKED DIAGNOSTICS msg = MESSAGE_TEXT;
+          RAISE WARNING '%', msg;
+        END;
+    END;
+  END LOOP;
+
+END;
+$$ LANGUAGE plpgsql;
+ROLLBACK;
+