Skip to main content
Urbicon UI
Back to Recipes

Meal Planner

A week of meals on Planner: typed entries bucketed onto their local day, ordered within each day by meal type, with an Add button that stays reachable on empty days.

Built with Planner Button Badge

Live preview

MealPlanPage.svelte

Add a meal to any day with its ghost Add button; the header arrows move the week, and clicking a day's cell selects it.
Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Mon 15
Overnight Oats
Lentil Curry
Tue 16
Ramen Bowl
Wed 17
Avocado Toast
Margherita Pizza
Thu 18
Fri 19
Tacos al Pastor
Sat 20
Sun 21
Shakshuka
<script lang="ts">
  import { Badge, Button, Planner, PlusIcon } from '@urbicon-ui/blocks';

  type MealType = 'breakfast' | 'lunch' | 'dinner';
  interface MealEntry {
    id: string;
    date: string; // local ISO date — Planner takes it verbatim, never UTC-parsed
    mealType: MealType;
    title: string;
    emoji: string;
  }

  const MEAL_ORDER: Record<MealType, number> = { breakfast: 0, lunch: 1, dinner: 2 };
  const MEAL_INTENT: Record<MealType, 'warning' | 'primary' | 'success'> = {
    breakfast: 'warning',
    lunch: 'primary',
    dinner: 'success'
  };

  // Stand-in for your data source — the demo pins one sample week and keeps it
  // local. In an app, start referenceDate from new Date() and load entries in
  // Planner's onNavigate, which hands you the visible range after every move.
  let entries = $state<MealEntry[]>([/* … meals, dated inside the sample week … */]);
  let referenceDate = $state(new Date(2026, 5, 15));
  let selectedDate = $state<Date | undefined>(new Date(2026, 5, 17));

  function addMeal(isoDate: string) {
    entries.push({
      id: crypto.randomUUID(),
      date: isoDate,
      mealType: 'lunch',
      title: 'New meal',
      emoji: '🍽️'
    });
  }
</script>

<Planner
  view="week"
  items={entries}
  getDate={(e) => e.date}
  sort={(a, b) => MEAL_ORDER[a.mealType] - MEAL_ORDER[b.mealType]}
  bind:value={referenceDate}
  bind:selectedDate
>
  {#snippet cell({ items, isoDate })}
    {#each items as meal (meal.id)}
      <div class="bg-surface-subtle flex items-center gap-2 rounded-md px-2 py-1.5">
        <span aria-hidden="true">{meal.emoji}</span>
        <span class="text-text-secondary min-w-0 truncate text-sm">{meal.title}</span>
        <Badge variant="dot" intent={MEAL_INTENT[meal.mealType]} class="ml-auto shrink-0" />
      </div>
    {/each}
    <!-- cell runs for empty days too, so Add stays reachable everywhere -->
    <Button
      variant="ghost"
      size="sm"
      class="mt-auto justify-start"
      onclick={() => addMeal(isoDate)}
    >
      <PlusIcon size={14} /> Add
    </Button>
  {/snippet}
</Planner>

Loading, timezones, empty days

Load a week at a time

The demo holds one local array. In an app, onNavigate fires after every navigation with the new reference date and the visible range: onNavigate={(_, range) => loadWeek(range.start)}. The Svelte-free @urbicon-ui/blocks/date subpath (toIso, endOfWeek) runs in a server route too, so the endpoint can compute the same week boundaries the grid shows.

Dates stay local

getDate returns the entry's date string, and Planner buckets '2026-06-16' as that calendar day wherever the user is: a plain date string is taken verbatim, never UTC-parsed, so a dinner never slides across midnight in another timezone. If your source stores UTC instants ('…T23:00:00Z') and the local day matters, return new Date(value) so the user's timezone applies.

An empty day still renders the cell

The ghost Add button lives in the cell snippet because cell runs for empty days too. Planner also takes an empty snippet for a dedicated placeholder; it replaces cell on empty days and would take the Add button with it, so this recipe leaves it out.