Specials & promotions
Time-bound menu callouts that surface in the public menu widget without editing the underlying item.
Specials live alongside menu items rather than inside them, so the same dish can carry an "Empfehlung" badge on weekend evenings and a discounted lunch price on weekdays without forking the recipe. Each special targets one menu_item and defines when it's active — a one-off date window, a recurring weekday schedule, or both.
Special shape
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| item_id | uuid | Required | — | The menu_item this special is attached to. |
| name | string | — | — | Optional display name. When omitted, the menu widget falls back to the item's name. |
| badge_text | string (≤30 chars) | — | — | Small label rendered in the widget — e.g. "Neu", "-20%", "Empfehlung". |
| starts_at / ends_at | timestamptz | — | — | Absolute window. Use for one-off events. Both NULL means "always". |
| days_of_week | number[] | — | — | ISO weekdays 1–7 (1=Mon). NULL means every day. |
| daily_from / daily_to | HH:MM | — | — | Daily window inside the date range. Use for "lunch only" or "happy hour" specials. |
| is_active | boolean | — | true | Soft kill-switch. Pausing a special doesn't delete it, so you can re-activate the same configuration next season. |
Visibility window
The widget queries currently_active_menu_specials on every page render: a row is shown when is_active=true AND the current time falls inside both the absolute window and the daily window, AND the current weekday is indays_of_week (or the field is NULL). The check is computed in the restaurant's configured timezone, not the visitor's.
Popularity score
Every item also carries a popularity_score that the menu widget can use for sort order. The score is a rolling 30-day sum of weighted events — click=3, view=1, filter=0.5 — recomputed nightly by the/api/cron/menu-popularity worker so reads stay cheap. Specials don't feed the score directly, but their boosted visibility tends to lift it for the items they highlight.
is_active=false; the row stays in the database with its full configuration. If you want to remove a special permanently, use the trash icon — the widget will stop showing the callout on the next render either way.