Pantry app blog

I'm working on a pantry app. I want to use recipes compatible with Umai, but connected with a record of what ingredients are in the house. I have a more or less functional version that I've started using in my home. It's rough around the edges and I keep thinking of new functionality that would improve its usability. But that's a key part of why I've built it the way I have - the idea of enabling user control over both their data and the interfaces used to interact with it. Rather than simply sharing my current version, this blog will break it down and built it back up, step by step.

Eventually, this blog and app will include:

Part 1: Showing a recipe

Updated 3 Mar 2025

My starting point is compatibility with the recipe app Umai. It uses schema.org Recipe objects. As presented by Practical Solid, it's already possible to simply view and add to a recipe in the PodOS browser. Because we'll be integrating with pantry data, we want to go one step further and create a custom interface.

We're going to start with PodOS elements. We create a plain HTML file, in this case hosted on a Solid pod, using a set of web components to access the recipe data. Additionally, we'll autoload some additional experimental web components that provide rough editing capabilities and allow the use of prefixes.

Starting from the recipe itself, pos-picture is used to fetch a known picture (here using schema:image) and a name is accessed using property="schema:name". The intention is for this UI data binding to work similar to Mavo.


<pos-resource
    uri="https://jg10.solidcommunity.net/pantry/blog/01/rosti.ttl#it"
    prefix="schema: https://schema.org/"
>
    <pos-picture></pos-picture>
    <h2>
        <my-editable-literal property="schema:name">
        </my-editable-literal>
    </h2>
</pos-resource>
    

Ingredients and instructions are slightly more complex. An experimental list web component is used that iterates through instructions ordered by their schema:position and displays each item using a template.


<my-list 
    property="schema:recipeInstructions" 
    sort="schema:position"
>
    <template name="item">
        <li>
          <my-editable-literal property="schema:text">
          </my-editable-literal>
        </li>
    </template>
</my-list>
    

Part 2: Browsing recipes and beyond

Updated 5 Mar 2025

In part 1, the app only opens a single resource. We want to be able to open multiple recipes but also different types of objects. To support an open world assumption, we want to be able to handle unknown types too.

We add a navigation bar, and use pos-type-router as a generic component to display arbitrary resources.

Client side routing is implemented using the PodOS pos-router component. We listen for a pod-os:route-changed event after navigating to e.g., https://jg10.solidcommunity.net/pantry/blog/02/, and update the uri on pos-resource, which causes the new uri to load - in this case showing the contents of the container.

Rather than adding complexity within the app, we can split out the recipe as a separate "pane". We use an experimental conditional rendering component to display the recipe pane for schema:Recipe objects, and display pos-type-router otherwise.


<show-if if-else>
    <template if-typeof="schema:Recipe" src="recipe.html">
    </template>
    <template else>
        <pos-type-router></pos-type-router>
    </template>
</show-if>
    

The recipe pane is dynamically loaded from recipe.html. It's the same HTML code that we used in part 1 - the file is a HTML fragment using web components that receive access to a PodOS instance and a resource via events. I've made a first attempt at a spec for PodOS-compatible panes.

An advantage of having a separate recipe pane is that we can reuse it in other contexts. This potentially includes the pod browser itself, in the form of a dynamically registered pane. Using a proof of concept registered-pane and a demo type registry, we have:

As specified by the same draft spec, panes and external apps are specified as part of a type registration. The browser app uses an experimental registered-pane component to load the pane, and open-with component to provide a menu that allows the user to open the recipe in Umai Viewer . The browser app itself doesn't know about recipes, but the type registry does, which allows it to show our custom pane.

Browser app displaying the resource using the dynamically loaded recipe pane, and with the open-with menu open.

Part 3: Going beyond the Umai data model

Published 7 Mar 2025. Last updated 12 Mar 2025
Changes

In this part, we're going to add the features:

To do this, we need to go beyond Umai's data model. We'll reuse other existing vocabularies where we can. Where new terms are needed, we'll use the pantry: prefix, mapped to https://jg10.solidcommunity.net/pantry/terms.ttl#. The general philosophy here is to not allow missing terms to get in the way of experimentation - minting new terms is trivially easy. If a set of terms turns out to be of high value, they can then be formally documented, and at that point a permanent home can be found. In the mean time, interoperability will still need to be considered. We'll return to this later.

Groceries

A grocery will be a schema:Product. We'll define a new predicate for approximate quantity of a grocery and named individuals None, Low, Some, Many .


:1638629734460861 a schema:Product; 
schema:name "Potatoes"; 
pantry:approxQuantity pantry:None.
      

We'll store all products in a pantry document. Dereferencing the pantry document will return all the known products in one go. This is primarily a performance based document box. We want to be able to load information about all groceries in a single request. When we load or navigate to the pantry document, we want to know that's what we're looking at, so we give it a class. There's no predicate linking the pantry document to individual products because it's just a view of the underlying graph. One day Solid might allow such a view to be dynamically generated from a hybrid graph.


<> a pantry:Pantry.
      

We'll create new panes for a pantry:Pantry and schema:Product. We'll keep them simple and add more features later. The same panes can be used in the data browser, so we add them in the registry too.

Pantry pane

The pantry pane offers the user the option to add a new grocery just by giving it a name, which can also be edited later. Creating the grocery involves adding several triples to a specific document, so we do this with an experimental wrapper around a form. The pantry document uri is hard-coded here. This works fine for a user creating a custom interface, but we'll need to generalise this later for anybody else to use the app.


<form
    typeof="schema:Product"
    doc="https://jg10.solidcommunity.net/pantry/blog/03/pantry.ttl"
>
    <input property="schema:name" />
    <input type="submit" value="New" />
</form>
      
Product pane

The product pane uses an experimental classifier web component inspired by SolidOS ui:Classifier. The web component dereferences pantry:ProductApproxQuantity and offers the user to select from instances of that class.


<my-classifier
      label="Quantity:"
      property="pantry:approxQuantity"
      category="pantry:ProductApproxQuantity"
></my-classifier>
      

The product pane loads recipes that use that grocery. To do this, we need to link groceries and recipes.

Annotating recipes

It's a long standing issue that schema:recipeIngredient does not support structured data, which is perhaps also understandable given the need to cover such cases as "1kg/ 2 lb potatoes (skin on weight) ( - Aus: Sebago (dirt brushed), US: Yukon Gold, Russet, UK: Maris Piper, King Edwards (Note 1))". The solution we'll pick is to use schema:supply. We can use the same label, but be specific about the grocery item and even specify the order of ingredients using schema:position.


:1741337498953
    schem:item <pantry.ttl#1638629734460861>;
    schem:name
        "1kg/ 2 lb potatoes (skin on weight) ( - Aus: Sebago (dirt brushed), US: Yukon Gold, Russet, UK: Maris Piper, King Edwards (Note 1))";
    schem:position "1".
      

But Umai (and most websites with structured recipe data) use schema:recipeIngredient and not schema:supply. What we're going to do is use both predicates and keep them in sync. We're going to start by doing it the low tech way - we're going to show both sets of data and the user will visually ensure that they match. A second solution would be to sync them using CRDTs, as already used by Umai - a change in one of the predicates would be accompanied by a change in the other. We'll explore a third solution later.

With our low tech solution, the user autocompletes ingredients from the groceries defined in the pantry document. In this example, I don't actually care what type of potatoes are needed - and if I do I can return to the recipe to check. Similarly, I'm opting to substitute normal butter instead of ghee. I haven't annotated vegetable oil, salt or pepper because I always have them in the house. I've progressively enhanced the text descriptions with structured data only for parts I care about, and I'm using a pantry defined at the granularity that I want as the point of truth rather than one provided by others.

We can now annotate recipes to note which groceries are required.

Because the pantry document provides information about all groceries at once, the recipe pane can load that document and show that I have no potatoes and lots of butter. It also provides links to those groceries to be able to take more actions that involve them. If I decide later that I do sometimes want to buy and use ghee instead of butter, I could also add it as a grocery, link it to the recipe here and see the availability of both the preferred ingredient and its potential substitute.

Autocomplete is implemented with an experimental component with a similar interface to the classifier. All loaded instances of schema:Product are suggested. If category is missing, then all known objects of schema:item are used instead.


<autocomplete-input
    property="schema:item"
    category="schema:Product"
></autocomplete-input>
      

How is this data used in the product pane? By default, the schema:supply statements would be saved in the same document as the recipe. We override this to instead save them in a supplies document. This acts as an index of recipes by ingredient to avoid needing to load all recipes. An alternative would be to use a local first approach (like Umai) and have all recipes at hand. The two approaches could co-exist as long as the index is updated when recipe documents are modified.


<my-list
    property="schema:supply"
    sort="schema:position"
    add-new
    doc="https://jg10.solidcommunity.net/pantry/blog/03/supplies.ttl"
>
          

Returning to the product pane - providing a list of recipes that use an ingredient involves loading the supplies document, iterating through the HowToSupply objects that use the ingredient, and loading the name of the corresponding recipe.

Cooperative interoperability

On the whole, this style of development is strongly tied to navigating a knowledge graph, with deliberate dereferencing of documents at key locations in the rendering process. It's unlikely to achieve interoperable serendipity, but we can still explicitly communicate the data structures that we use to enable others to interoperate.

If I'm working in a particular domain, e.g., food, and I want to interoperate with other apps, e.g., Umai, I might ask: what data structures can I use? To begin with, I might want to be more precise

Umai v0.1.8 Pantry app part 3
Works with recipes x x
Works with records of pantry contents x
Works with shopping lists

Within apps that work with recipes, we can talk about different classes and shapes.

Umai v0.1.8 Pantry app part 3
Works with schema:Recipe x x
Works with Umai v0.1.8 data shape x x
Works with schema:supply x
Works with fo:Recipe

Similar to how web browser support tables work for https://caniuse.com, developer and data power-users gradually get the feeling for which data structures are supported by the most apps or by the apps one wants to use. Efficiently organising app compatibility data is something we'll have to come back to...

Part 4: What can I cook? What do I need to buy?

Updated 17 Mar 2025

So far we've covered the first two of the four planned ways of using pantry data with recipes:

The latter are arguably more important as they allow a quick scan across recipes and ingredients. We'll also define locations of groceries to help scan them in chunks.

We introduce two new subclasses pantry:Staple and pantry:Perishable. Staples are usually available. Perishables need to be used when they are available. Groceries will be linked to a location with a new predicate pantry:contains. We adjust the product pane and pantry pane, and add new location and cookbook panes.

Product pane

The changes to the product pane are minimal. We'll allow for editing of the location of the product with a new experimental web component that also allows specifying a link and a label.


<my-editable-link rev="pantry:contains"></my-editable-link>
      

For potatoes, providing a new link and label for location yields:


:Cellar 
  rdfs:label "Cellar"; 
  pantry:contains :1638629734460861 .
      

The statements are by default inserted in the same document as the subject (potatoes), treating location information as part of the pantry itself, which suits us fine for now.

We'll also add an autocomplete-input component to select types. We haven't defined a category to use, so it will just autocomplete all known types. The first time we use it, it won't know about pantry:Perishable, but we can just paste the URI in anyway - the component is designed with an open world assumption in mind. The autocomplete is not the nicest UX to specify a type but it's easy to add to the UI and functional for a fairly infrequent task. Potatoes are marked as perishable, and butter as a staple. The latter doesn't need to be marked as perishable - it consistently gets used before its best-before date.

Pantry pane

The pantry pane gets a major upgrade. It now consists of six lists using my-list, show-if conditional rendering, and other display web components. We also add a reusable product-row.html HTML fragment.

Pantry pane

This screenshot shows that we've got (lots of) potatoes that need to be used. Following the link will show the product pane with the list of recipes. We usually have butter in the house but don't at the moment. An inline my-classifier is provided in case the data is out of date. We have links to each location (to be shown in location pane), and we still have the full list of defined groceries in the pantry. Obviously minimal effort has gone into styling - emphasis is still on prototyping a MVP at this stage.

Location pane

We're going to model locations with the SOSA (Sensor, Observation, Sample, and Actuator) ontology as sosa:FeatureOfInterest. At some point we might want to use a subclass, but given the bespoke, experimental nature of the app, for the time being we want to reduce friction to getting started. Treating a location as a feature of interest means that we'll be able to define observable properties and record changes in those properties over time. This provides a point of integration with potential digital home apps that aim to provide a digital twin of the home. We'll be able to talk about not just what is in the fridge, but also its electricity usage, maintenance and entire lifecycle as a consumer good. In contrast to ingredients, which are ephemeral, a fridge or cupboard is a continuing feature of a home. If we replace the fridge, we'd create a new sosa:FeatureOfInterest and move relevant groceries into it.

For the time being, the main effect of this decision is that we register the location pane with the sosa:FeatureOfInterest class and keep in mind that its ultimate scope may be much broader than simply listing groceries.

The location pane itself is relatively straight-forward, iterating through the pantry:contains objects and displaying them with the product-row.html.

Cookbook pane

As a first pass, the cookbook view will simply show whether the ingredients for each recipe are available. We'll split out the staples because they will appear frequently and are usually assumed to be in the house.

Cookbook pane

Implementation is again with the list and conditional rendering components, and reusing the product-row.html fragment. One difficulty arises - the panes need to know about and load the supplies document and pantry document in addition to the recipe (names) themselves. For the time being, these are hardcoded in the pane itself.

As an implementation note, Umai cookbooks are currently containers containing documents with schema:Recipe objects - they do not have their own class. Before dereferencing the recipe documents, the cookbook can be identified through its type index registration. This is supported in the app using an n3 resource path. Public recipe lists are schema:ItemList. The app supports both and the approach could be revisited in collaboration with Umai.


<template
    else
    if-n3path="^solid:instanceContainer!solid:forClass"
    eq-value="schema:Recipe"
    src="cookbook.html"
></template>
<template
    else
    if-typeof="schema:ItemList"
    src="cookbook.html"
></template>
      

Part 5: Why not just look in the fridge?

Coming soon...

Updated x Mar 2025

Who would want to update pantry data? Rather than assuming that the pantry data is a point of truth, we treat it as a partial snapshot. The user interface assumes that at any time the state information may be out of date but that the user is likely in a position to correct it from memory, by judgement or by checking. State is designed to be easy to update. In general, all data is shown as subsetting with out of date information is likely to be ineffective.

We instead use an append only event log as a point of truth. This forms a pantry history that provides provenance context for the grocery state. The user can evaluate whether the state is likely to be up to date, or what it's current state might be. If a perishable grocery was last updated a month ago, it has probably been used or is no longer usable - and the state just hasn't been updated. In general as time elapses the confidence in the current state decreases.

In addition to spot updates, there are particular times that will result in batch updates to the pantry state. Given a receipt, all new purchases can be marked as bought. As part of meal planning, updates are made to both ingredients that are and are not available. The location pane provides an opportunity to update multiple products at once after hunting for whether a specific ingredient is available. The pantry app doesn't eliminate the need to check the fridge, but it reduces it by acting as a memory aid for what was in the fridge last time it was checked or used.

To reuse an existing vocabulary, we model the pantry history as a collection of sosa:Observation objects. The grocery items are treated as a sosa:ObservableProperty of a location, consistent with their definition as a sosa:FeatureOfInterest.


     

We'll start by storing observations in history/observations.ttl. That won't scale but gives us a starting point to later use a more complex event stream structure, e.g. using LDES.

The assumption of incompleteness of pantry state is consistent with how we've approached the pantry index itself. Already when we created the pantry pane, the set of groceries was assumed to be incomplete, and adding new groceries is intended to be low friction. Numeric IDs are used instead of readable names to allow users to change the intended meaning over time. It is recognised that this may cause (usually minor) inconsistencies in historical data.

A third solution helps to understand the origin of the problem. While schema:recipeIngredient expects a text literal, it is in fact what I am calling a "resource literal" - the literal is a unique string (within the recipe) that stands in for a schema:HowToSupply. There is a 1:1 correspondence between the two, which we could exploit


<resource-literal 
    literal-property="schema: recipeIngredient" 
    literal-value="unique ingredient text" 
    property="schema:supply" 
    resource-literal-property="schema:name"
>
    <autocomplete-input property="schema:item">
</resource-literal>

Comments welcome on Mastodon, GitHub or the Solid Community Forum .