πŸ“„ recipe-ui-handoff.md 5,597 bytes Apr 26, 2026 πŸ“‹ Raw

πŸ“‹ Recipe Toggle UI β€” Handoff to Socrates

From: Daedalus 🎨
To: Socrates 🧠
Date: 2026-04-26
Project: Icarus Recipe Integration β€” Phase 1


Summary

Daedalus designed the Telegram inline keyboard UI for recipe-to-grocery-list toggling. Full design spec is at shared/design-tokens/recipe-toggle-ui.md. This document covers the callback handler contract you need to implement.


Callback Handlers You Need

1. toggle:{recipe_id}:{index}

@router.callback_query(lambda c: c.data.startswith("toggle:"))
async def handle_toggle(callback: CallbackQuery):
    _, recipe_id, index_str = callback.data.split(":")
    index = int(index_str)

    # 1. Flip selected state in recipe_toggles table
    # 2. Read all current states for this recipe
    # 3. Build new caption text + inline keyboard
    # 4. editMessageText()
    # 5. answerCallbackQuery()

State management:
- Recipe owner: The recipe_toggles table is keyed by (recipe_id, ingredient_index)
- On first callback, lazily create rows from the recipe's ingredient list (all selected=1)
- On toggle: flip the boolean, re-read

Caption format (after toggle):

🍝 Creamy Tomato and Spinach Pasta
From: budgetbytes.com

Tap to deselect items you don't need:

βœ… 2 cups penne pasta
❌ ~1 small onion~
βœ… 2 cloves garlic

2. commit:{recipe_id}

@router.callback_query(lambda c: c.data.startswith("commit:"))
async def handle_commit(callback: CallbackQuery):
    _, recipe_id = callback.data.split(":")

    # 1. Read all selected items from recipe_toggles
    # 2. If count == 0: answerCallbackQuery("Select at least 1 item"), return
    # 3. Insert into grocery_list_items table
    #    - Include recipe_id for provenance
    # 4. editMessageText("βœ… Added 4 ingredients to grocery list", reply_markup=None)
    # 5. answerCallbackQuery()
    # 6. Delete temp rows from recipe_toggles
    # 7. Optionally: schedule message deletion after 30s

3. cancel:{recipe_id}

@router.callback_query(lambda c: c.data.startswith("cancel:"))
async def handle_cancel(callback: CallbackQuery):
    _, recipe_id = callback.data.split(":")

    # 1. Delete temp rows from recipe_toggles for this recipe
    # 2. editMessageText("❌ Canceled β€” nothing added to list", reply_markup=None)
    # 3. answerCallbackQuery()

Keyboard Builder (you'll need this)

from telegram import InlineKeyboardButton, InlineKeyboardMarkup

def build_recipe_keyboard(recipe_id: str, ingredients: list[dict]) -> InlineKeyboardMarkup:
    """
    ingredients: list of {"index": int, "text": str, "selected": bool}
    Returns compact 4-per-row grid for ingredients + commit/cancel row.
    """
    keyboard = []
    row = []
    for ing in ingredients:
        emoji = "βœ…" if ing["selected"] else "❌"
        label = f"{emoji} #{ing['index'] + 1}"
        row.append(InlineKeyboardButton(label, callback_data=f"toggle:{recipe_id}:{ing['index']}"))
        if len(row) == 4:
            keyboard.append(row)
            row = []
    if row:
        keyboard.append(row)

    # Commit + Cancel row
    selected_count = sum(1 for ing in ingredients if ing["selected"])
    commit_label = f"βœ… Commit ({selected_count})" if selected_count > 0 else "Select at least 1 item"
    commit_cb = f"commit:{recipe_id}" if selected_count > 0 else "noop"
    keyboard.append([
        InlineKeyboardButton(commit_label, callback_data=commit_cb),
        InlineKeyboardButton("❌ Cancel", callback_data=f"cancel:{recipe_id}")
    ])

    return InlineKeyboardMarkup(keyboard)

Mobile-width constraint: Buttons should be short. βœ… Commit (4) fits in ~15 chars. Ingredient buttons are just βœ… #1 β€” the actual text lives in the message body.


DB Schemas

Temp toggle state (your table, 5-min TTL):

CREATE TABLE recipe_toggles (
    recipe_id TEXT NOT NULL,
    ingredient_index INTEGER NOT NULL,
    selected INTEGER NOT NULL DEFAULT 1,
    chat_id INTEGER NOT NULL,
    message_id INTEGER NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (recipe_id, ingredient_index)
);

Grocery list (your table):

CREATE TABLE grocery_list_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    recipe_id TEXT NOT NULL,
    ingredient_text TEXT NOT NULL,
    added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    purchased INTEGER NOT NULL DEFAULT 0,
    chat_id INTEGER NOT NULL
);

TTL Cleanup

Every toggle handler should check if 5 minutes have passed since created_at. If so:
1. Edit message: "⏰ Timed out β€” send the recipe again"
2. Remove keyboard
3. Delete temp rows
4. answerCallbackQuery("Recipe expired")


Edge Case: Recipe Too Long (>12 ingredients)

If ingredients > 12: show first 12 with toggle buttons, append to caption: …and {N-12} more. Those extra items default to selected and are included on commit.


No costco_route imports

The brief explicitly says: copy the pattern, don't import. Standalone handlers in a new file like icarus/handlers/recipe_toggle.py.


Deliverables Checklist

  • [x] shared/design-tokens/recipe-toggle-ui.md β€” Design spec (Daedalus βœ…)
  • [ ] toggle:{id}:{idx} callback handler
  • [ ] commit:{id} callback handler
  • [ ] cancel:{id} callback handler
  • [ ] recipe_toggles table migration
  • [ ] grocery_list_items table migration
  • [ ] 5-min TTL check on callback
  • [ ] >12 ingredient truncation
  • [ ] build_recipe_keyboard() helper

Daedalus 🎨