Assigning and Scheduling Display Content
Audience: User, Org Admin, Developer, Support Related: Overview · Remote Pi Client · Meal Planner · Overview
Assign Content supports immediate and scheduled display content, including Menu, Recipe, meal-plan views, Today & Tomorrow, Let’s Cook, Return to Default, and supported client behavior. Default content is stored per display and is restored when an assignment ends or a session completes.
Scheduled times use the organization time zone and normalized server-side timing rules. UI previews, upcoming assignments, active assignments, suspended client-mode assignments, start/end behavior, and deletion dependencies must remain consistent.
Scheduled time model
Confirmed root cause
The recipe assignment modal submits HTML datetime-local values such as 2026-07-15T08:10. Those values contain no offset. The display assignment API passed them unchanged into display_content_assignments.display_at and end_at.
The UI and scheduler then interpreted the same text differently:
- The display-list countdown parsed the naive value in Python and compared it with
datetime.now(), so it followed the web process host-local clock. - SQLite due queries explicitly compared against
datetime('now', 'localtime'), which matched a Pi configured in the organization/appliance time zone. - The PostgreSQL compatibility adapter rewrote SQLite
datetime('now', 'localtime')toCURRENT_TIMESTAMP::text. Cloud SQL/GCP commonly runs in UTC, so a local value such as08:10could be compared with a UTC value such as13:01and be considered overdue on the first one-minute scheduler pass. - The due query also retained SQLite-specific
datetime(...)parsing on the stored text, making the PostgreSQL path dependent on compatibility rewriting instead of an explicit scheduling model.
This explains why the Pi/SQLite path behaved correctly while GCP/PostgreSQL could return to default almost immediately.
Canonical model
- Browser
datetime-localvalues are interpreted as organization-local wall-clock values. - Explicit-offset values are honored as supplied.
- The assignment service converts both forms to aware UTC and stores an ISO-8601 UTC instant with
+00:00. - SQLite and PostgreSQL continue storing assignment timestamps in the existing TEXT columns; no schema migration is required.
- The scheduler loads candidate rows through the assignment repository and compares normalized aware UTC datetimes in Python.
- Legacy naive assignment rows are interpreted in the owning organization's configured time zone. Nonexistent spring-forward wall times are rejected; ambiguous fall-back values use the first occurrence (
fold=0). - Display labels and countdowns use the same normalization helper as the scheduler, then render in the organization time zone and preferred time format.
- Cron, Gunicorn, PostgreSQL session, and operating-system
TZvalues no longer determine whether an assignment is due.
Concurrency and retries
Due scheduled starts and current-assignment ends are claimed atomically before state transition. A second scheduler pass cannot claim the same row. Failed work releases the claim using the existing retry/error semantics. Claims abandoned by a terminated one-shot scheduler process are released for retry after 15 minutes; normal due decisions receive no grace period or artificial delay.
Display creation defaults
A new display must choose one concrete default view:
- Menu
- Daily Meal Plan
- Weekly Meal Plan
- Today & Tomorrow Meal Plan
- Monthly Meal Plan
Meal-plan choices map to current_meal_plan plus an explicit view/anchor_policy. After the display row is created, Blackcap creates both current and default assignments and queues the first render/push.
Regression verification
The regression harness uses a dedicated hosted receiver and splits the check into two durable steps. Phase one assigns a recipe with an end instant five minutes ahead, verifies the push/current assignment, and invokes the scheduler once to prove it is not due. Phase two runs later in the suite, records the actual default-restoration timestamp, and requires both an elapsed duration greater than two minutes and a normal five-minute window (285–420 seconds, allowing one-minute polling and modest execution tolerance). It verifies the transition source and the restored Weekly Meal Plan settings.
Deployment and validation
This is an application-only scheduling change; /etc/cron.d/blackcap and systemd unit definitions do not change. Deploy the changed files and restart Blackcap. Validate the existing scheduled task with:
sudo systemctl status inky_admin.service --no-pager -l
sudo systemctl status cron --no-pager -l
sudo grep -n "blackcap-display-refresh" /etc/cron.d/blackcap
sudo journalctl -u inky_admin.service --since "15 minutes ago" --no-pager | grep -E "Scheduled display assignment|Returned expired display assignment"
cd /opt/Blackcap
sudo -u root /opt/blackcap_env/bin/python tools/run_scheduled_task.py blackcap-display-refresh /opt/Blackcap /etc/blackcap/blackcap.env /opt/blackcap_env/bin/python /opt/Blackcap/run_display_content_refresh.py
For a controlled GCP check, assign a recipe with a ten-minute end time, confirm the Displays page shows Return to default at the expected organization-local time, run the task once immediately to prove it remains active, then confirm restoration on the first poll after the intended end.
Timed display behavior
This update adds a display-scoped timed Meal Planner behavior setting and cleans up several shopping flows that were easy to confuse during real kitchen use.
Display Rendering Settings
Display detail now uses a Timed Meal Plan Slot Behavior radio group:
- Off: timed Meal Planner slots do not automatically change the display.
- Recipe: preserves the previous “Auto-show timed meal-plan recipes” behavior.
- Let’s Cook: starts a display-backed Let’s Cook session when the timed slot starts. This option is available for every display type. Kiosk displays and touch-enabled Hosted Web Receivers use the interactive Let’s Cook UI; other display types render a display-only Let’s Cook preview.
The existing restore option is now labeled Restore display after slot and applies to both Recipe and Let’s Cook modes. Lead time keeps the same meaning: the display can react before the slot start by the configured number of minutes.
The setting is stored on displays.timed_meal_plan_behavior with values off, recipe, or lets_cook. Migration 170 maps the old boolean auto_show_meal_plan_recipe = 1 to recipe; disabled or missing values become off. The old boolean remains as a compatibility mirror for Recipe mode only. Database Admin exposes the raw field for support review. Let’s Cook timed behavior is allowed for every display type: kiosk displays and touch-enabled hosted web receivers use the interactive view, while non-touch/physical/remote display targets receive the display-only rendered Let’s Cook preview.
Let’s Cook auto-start and auto-end
When a display is set to Let’s Cook mode, the timed scheduler uses the same meal slot timing lookup as Recipe mode. It starts one Let’s Cook session for the first recipe-backed meal plan entry in the active slot and includes additional recipe-backed entries in that same slot as additional session recipes with unique step and ingredient keys. The session is rendered according to the display target: interactive for kiosks/touch hosted receivers and display-only for non-touch/physical/remote displays.
Repeated scheduler checks do not create duplicate sessions. The auto-start key includes organization, display, date, slot, entry, and slot time window. The Let’s Cook session metadata records auto_started_by = meal_plan_timing, the auto-start key, the meal slot/date, and the planned end time.
At slot end, auto-end is allowed only for sessions started by timed Meal Planner behavior and only when no timer is active or paused. Two cases are safe:
- the session is untouched: no steps, ingredients, or timers have been used;
- the session is fully completed: all applicable steps and all non-section-header ingredients are completed/used, including additional recipe-backed entries, and no timers are active or paused.
Partially completed sessions, sessions with active timers, manually started sessions, and sessions whose display/slot association changed are left active. The display is restored only after a safe auto-end.
Shop With and External Shopping Lists
Shop With now keeps skipped items at the back of the in-session queue. Got It and Already Have It remove the item from the skipped queue, while Skip records the item key and prefers never-skipped unresolved items before cycling back to skipped items.
Already Have It responses now carry the actual Kitchen Inventory storage location when known. The extension no longer falls back to Pantry for an already-on-hand badge unless Pantry is the actual known location; missing locations display as a neutral on-hand state.
External list checkoff now plans item matches once, claims matched DOM rows, and clicks from highest row index to lowest. This bottom-up order avoids provider list reindexing after each checkoff from causing the next row to be missed or the wrong row to be checked.
Shop a List checkoff stability
The Shop a List page uses the durable rapid-toggle pattern that is common for checkbox-heavy mobile UIs:
- the UI updates optimistically as soon as the shopper taps an item;
- the browser keeps only the latest desired checked state for each item/source ref;
- saves are sent through a single-flight queue so many quick taps are coalesced instead of launched as overlapping requests;
- the endpoint applies the update through the database service atomic mutation helper;
- the server reloads and verifies the requested source refs after saving, then retries once before returning an error;
- if a coalesced save fails, the browser retries still-current items individually and only reverts items that truly fail.
While a save is active, the status text may briefly show that items are saving or retrying. Shoppers should avoid refreshing while that message is visible. After the saved state finishes, a refresh should match the final checked state.
The database helper is backend-aware. SQLite uses a write-locking mutation path to avoid stale full-list JSON overwrites; future Postgres support can use row-level locking through the same service boundary without changing the route or mobile JavaScript. The longer-term scalable model remains normalized shopping-list item rows, but the current approach keeps the existing payload format stable.
Meal Planner shopping list preview
Manual meal items in the Create Shopping List popup use the same grouping helper as final shopping list generation. Duplicate manual items are displayed as clean tiles, such as 2 Bread, without canonical/internal values. The popup header close X was removed; the footer keeps the clearer ↩️ Cancel and 🛒 Create List actions.
The Meal Planner no longer shows the confusing Needs ingredient review pill on non-recipe-backed/manual items. Recipe-backed entries can still show a review status when they truly have that state.