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.
Live preview
MealPlanPage.svelte
Add button; the header arrows move the week, and clicking a day's cell selects it.<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.