π 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_togglestable migration - [ ]
grocery_list_itemstable migration - [ ] 5-min TTL check on callback
- [ ] >12 ingredient truncation
- [ ]
build_recipe_keyboard()helper
Daedalus π¨