Skip to main content

The Screen That Actually Matters: Building the Weekly Menu Editor

·1214 words·6 mins
Morten Jensen
Author
Morten Jensen
Former chef with over 20 years in professional kitchens, now studying computer science
MiseOS Development - This article is part of a series.
Part 16: This Article

Every feature built so far — dishes, allergens, stations, suggestions — existed to serve this one. The weekly menu is the reason MiseOS exists.

This week it finally came together.


What a menu actually is
#

A weekly menu is not a flat list of dishes. It has slots — each one the intersection of a weekday and a station:

WeeklyMenu (week 22, 2026, DRAFT)
  └── MONDAY × VARMT    → Butter Chicken
  └── MONDAY × SALAT    → Caesar Salad
  └── TUESDAY × VARMT   → Stegt flæsk
  ...up to 25 slots (5 days × 5 stations)

From the frontend this breaks into two experiences. Admins get an editor — navigate by week, add dishes to slots, remove them, translate, publish. Kitchen staff and the public get a read-only display of the current published menu.

This is what the menu editor looks like in production:

The weekly menu editor in production

The nested component hierarchy
#

The admin editor was the first feature in the project with real component depth — not a list or a form, but an interactive grid where every cell is its own component with its own actions:

MenuEditorPage
  └── MenuEditorGrid
        └── MenuEditorDayColumn   (×5 days)
              └── MenuEditorSlotCard  (×5 stations per day)

Starting at the top and working down was the right approach. What data does the page need? A menu with its slots. What can happen? Slots can be added, edited, removed. The menu can be translated and published. The component tree followed naturally from those questions.


CSS grid — the key to five columns
#

The grid is column-based: one column per weekday, slot cards stacked inside. At full width, five columns sit side by side. The layout is entirely responsive:

.menuGrid {
  display: grid;
  gap: var(--spacing-xs);
  grid-template-columns: 1fr;
}

@media (min-width: 768px)  { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 1024px) { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 1280px) { grid-template-columns: repeat(5, 1fr); }

On a canteen management screen at 1280px+, all five days are visible at once. On a tablet, two or three columns stack. The same component tree works across screen sizes — only the column count changes.


State ownership and callbacks
#

The most important decision was where state lives. With four levels it’s tempting to let each level manage its own piece. The problem is they’re all the same thing: the current state of the menu.

All state lives in MenuEditorPage. Every component below receives data and fires callbacks upward. The delete flow across four levels illustrates how this works:

MenuEditorPage      handleRemoveSlot = async () => { ...API call, setMenu(...) }
  ↓ onRemove={handleConfirmRemove}
MenuEditorGrid
  ↓ onRemove={onRemove}
MenuEditorDayColumn
  ↓ onRemove={onRemove}
MenuEditorSlotCard  → calls onRemove(menuSlot.menuSlotId)

MenuEditorSlotCard never calls the API. It fires onRemove(menuSlot.menuSlotId) and the signal travels back up to MenuEditorPage, which sets a confirm dialog, then calls the service and updates state on confirm.

In MenuEditorDayColumn, each card gets its own handler bound to its slot via a closure:

{menuSlots?.map((menuSlot) => (
  <MenuEditorSlotCard
    key={menuSlot.menuSlotId}
    menuSlot={menuSlot}
    onRemove={onRemove}
    onEdit={onEdit}
    ...
  />
))}

() => onRemove(menuSlot.menuSlotId) captures the correct ID at the point of creation. Each card in the loop gets its own version — this is a closure. When the card fires the callback, the correct slot ID is already baked in.

One interesting detail: stations is not passed as a prop through the tree. MenuEditorDayColumn reaches directly into the layout context:

const { stations } = useOutletContext();

A component three levels deep fetching from AdminLayout’s context directly — rather than drilling the prop through MenuEditorPage and MenuEditorGrid. It works cleanly here because stations are stable shared data, not something MenuEditorPage owns.


The dish selector modal
#

Adding a dish to a slot opens a modal. modalConfig tracks which slot is being filled:

const [modalConfig, setModalConfig] = useState(null);
// { day: 'MONDAY', station: { id, name }, slotId: null | id }

null means closed. A non-null config means a specific slot is active. The same state handles both adding a new dish and replacing an existing one — if slotId is set, it’s an update; if not, it’s a new slot.

Inside the modal, available dishes are split into two categories per station:

const thisWeekDishes = availableDishes?.thisWeekDishes?.[stationName] || [];
const fromBankDishes = availableDishes?.fromDishBank?.[stationName] || [];

“This week’s dishes” are suggestions approved for that week. “From the dish bank” are all approved dishes ever created. The split gives admins a fast path to what’s already planned, with the full bank as a fallback.


The translation flow
#

Every dish slot needs an English translation before the menu can be published. MiseOS uses DeepL via the backend — either per slot or all at once.

The publish button is disabled until all conditions are met:

const untranslatedSlots = menu?.menuSlots?.filter(
  (s) => s.menuDish && !s.menuDish.hasTranslation
) ?? [];
const hasUntranslated = untranslatedSlots.length > 0;
const menuIsEmpty = !menu?.menuSlots?.some((s) => s.menuDish);

<Button
  name={isPublishing ? 'Udgiver...' : 'Udgiv menu'}
  disabled={isPublishing || menuIsEmpty || hasUntranslated}
  title={
    menuIsEmpty     ? 'Tilføj retter til menuen inden udgivelse' :
    hasUntranslated ? 'Oversæt alle retter til engelsk inden udgivelse' :
    undefined
  }
/>

hasUntranslated and menuIsEmpty are derived state — calculated from the menu on every render, not stored separately. The warning badge counts exactly how many slots are missing a translation. The “Oversæt alle” button calls menuService.translateAll() and the returned updated menu replaces state in one step.

One CSS detail that mattered: pointer-events: none on :disabled prevented the native title tooltip from appearing on the disabled publish button. The fix was removing it and using :hover:not(:disabled) for hover styles instead.


A component that emerged from two contexts
#

Halfway through building the slot cards, allergen display was needed in two forms: compact (icon only, small grid) in the editor, and full (icon + name as a pill) in the public menu. Two components were started, then merged into one AllergenList with a variant prop:

<AllergenList allergens={dish.allergens} variant="compact" />
<AllergenList allergens={dish.allergens} variant="full" language={language} />

Any future change to allergen display happens once. Both contexts update.


Prop drilling — knowing the name for the trade-off
#

With four levels of nesting, callbacks from MenuEditorPage pass through MenuEditorGrid and MenuEditorDayColumn on their way to MenuEditorSlotCard. The middle components carry props they have no use for — they just forward them.

This is called prop drilling. For a tree this size it’s manageable. For anything deeper, a React context or a dedicated state solution reduces the noise. It’s worth knowing the name for the trade-off rather than just feeling vaguely uncomfortable about the extra props.


Reflections
#

The menu required more upfront planning than any previous feature. Deciding the component tree and where state would live before writing any JSX meant fewer rewrites. The pattern held: MenuEditorPage owns everything, nothing below it fetches its own data (except the stations from layout context — a pragmatic exception that avoided unnecessary prop drilling).

The translation requirement being enforced at the UI level — not just validated on submit — made a real difference to how the editor feels. The warning count, the per-slot translate icon, and the disabled publish button all tell the same story without a single error message.

The canteen menu is live. Everything else in the app was preparation for this screen.


This is part 16 of the MiseOS development log. Follow along as I build a management tool for professional kitchens, one commit at a time.

MiseOS Development - This article is part of a series.
Part 16: This Article