Example: plan a day

One answer built from five GenUI components, then rebuilt from the user's choices.

Ask for a sightseeing route and you get an answer you can edit: a photo strip, a map, a route you can trim, extra places to add, and three questions that reshape the day. It's built from GenUI components only, wired the way an agent would.

I'm in San Francisco for a day, plan a sightseeing route for me.

What's going on

  1. One reply, five components. Photo Mosaic, Map in itinerary mode, Itinerary, Inspiration Cards and Quick Questions are five tool calls rendered by the Renderer.
  2. The map and the list share their stops. The model sends one stop shape. Remove a stop in the list and its pin leaves the map.
  3. Adding places. Pick from the suggestions and Add to route. The new stops join the map and the list, tagged Added.
  4. Answering the questions sends a typed result. The host builds the next route from it and shows a second reply: one Stack call holding the map and the editable list, so the model composed the shape instead of sending two calls, and the list's edits reach the host as { child: "1", result }. The first reply is marked superseded so it can't be acted on by mistake.
  5. "What the agent receives" shows the answers and the note about what you changed in the first answer, so the model works from the current route.

Two ideas worth borrowing

Refs for tool-result values. The model never types an image URL. It writes { "$ref": "images.golden-gate-bridge" } and the Renderer fills in the value from the tool result. See values from tool results.

A note of what the user changed. describeUIState turns the results into a line like Removed Lombard Street that goes with the next message. See telling the model what the user changed.

Components used

Photo Mosaic, Map, Itinerary, Inspiration Cards, Quick Questions and, in the second reply, Stack.

The places are real, with approximate coordinates and plain descriptions. Times are a plan, not opening hours. Images are flat illustrations, so the page loads nothing from other sites.

Source

'use client';

import * as React from 'react';
import { motion, useInView, useReducedMotion } from 'motion/react';
import { RotateCcwIcon } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { showcases } from '@/showcase';
import { catalog } from '@/showcase/catalog';
import { GenUIRenderer, GenUIStack, type GenUICall } from '@registry/genui/renderer/genui-renderer';
import { describeUIState, type GenUIStateEntry } from '@registry/genui/renderer/genui-context';
import { AssistantMessage, UserBubble } from '@/components/chat/chat-turn';
import { sfArt } from './travel-art';
import {
  DEFAULT_PREFERENCES,
  INTEREST_LABEL,
  type Interest,
  PACE_LABEL,
  type Pace,
  placeById,
  planRoute,
  type Preferences,
  type RouteStop,
  SUGGESTION_IDS,
  timeRoute,
  TRANSPORT_LABEL,
  type Transport,
  formatDuration,
} from './travel-data';

const FRAME_MS = 110;
const PROMPT = "I'm in San Francisco for a day, plan a sightseeing route for me.";

/** The image search result the model points into with refs. */
const IMAGES = { images: sfArt };
const imageRef = (id: string) => ({ $ref: `images.${id}` });

/* ---------- The calls the "model" writes (images by ref, never retyped) ---------- */

function mapProps(stops: RouteStop[], title: string) {
  return {
    mode: 'itinerary',
    title,
    route: true,
    stops: stops.map((s) => ({
      id: s.id,
      name: s.name,
      lat: s.lat,
      lng: s.lng,
      kind: s.kind,
      category: `${s.time} · ${s.duration}`,
      image: { src: imageRef(s.id), alt: s.name },
    })),
  };
}

function itineraryProps(stops: RouteStop[], removed: string[]) {
  return {
    title: 'Your route',
    editable: true,
    removed,
    stops: stops.map((s) => ({
      id: s.id,
      name: s.name,
      time: s.time,
      duration: s.duration,
      description: s.description,
      kind: s.kind,
      lat: s.lat,
      lng: s.lng,
      image: { src: imageRef(s.id), alt: s.name },
      ...(s.added ? { added: true } : {}),
    })),
  };
}

/** Turn 2: the map over the editable list, as one call. */
function stackProps(stops: RouteStop[], removed: string[]) {
  return {
    gap: 'tight',
    items: [
      {
        component: 'show_map',
        props: mapProps(
          stops.filter((s) => !removed.includes(s.id)),
          'Your updated day',
        ),
      },
      {
        id: 'route',
        component: 'show_itinerary',
        props: itineraryProps(stops, removed),
      },
    ],
  };
}

const photosProps = {
  images: ['golden-gate-bridge', 'painted-ladies', 'cable-car'].map((id) => ({
    src: imageRef(id),
    alt: placeById.get(id)?.name ?? id,
    caption: placeById.get(id)?.name,
  })),
  tags: ['Waterfront', 'Hills', 'Cable cars'],
};

const suggestionsProps = {
  title: 'Add to your day',
  columns: 3,
  selectable: 'multiple',
  max: 3,
  submitLabel: 'Add to route',
  items: SUGGESTION_IDS.map((id) => {
    const p = placeById.get(id);
    return {
      id,
      image: { src: imageRef(id), alt: p?.name ?? id },
      title: p?.name ?? id,
      line: `+${formatDuration(p?.minutes ?? 0)}`,
    };
  }),
};

const PACE_OPTIONS: Pace[] = ['relaxed', 'balanced', 'packed'];
const INTERESTS: Interest[] = ['views', 'food', 'history', 'neighborhoods'];
const TRANSPORTS: Transport[] = ['walk', 'transit', 'car'];

const questionsProps = {
  submitLabel: 'Update my route',
  defaults: { pace: 'balanced', transport: 'walk' },
  questions: [
    {
      id: 'pace',
      label: 'How full should the day be?',
      header: 'Pace',
      type: 'single',
      options: PACE_OPTIONS.map((id) => ({ id, label: PACE_LABEL[id] })),
    },
    {
      id: 'interests',
      label: 'What are you into?',
      header: 'Into',
      type: 'multiple',
      required: false,
      options: INTERESTS.map((id) => ({ id, label: INTEREST_LABEL[id] })),
    },
    {
      id: 'transport',
      label: 'How will you get around?',
      header: 'Getting around',
      type: 'single',
      options: TRANSPORTS.map((id) => ({ id, label: TRANSPORT_LABEL[id] })),
    },
  ],
};

/* ---------- Streaming ---------- */

type Draft = { id: string; name: string; props: Record<string, unknown> };

/** Every intermediate state, one call streaming after another (like a real reply). */
function streamSteps(drafts: Draft[]): GenUICall[][] {
  const done = (d: Draft): GenUICall => ({ ...d, status: 'ready' });
  const steps: GenUICall[][] = [];
  drafts.forEach((d, i) => {
    const showcase = showcases.find((s) => s.tool === d.name);
    const frames = showcase ? showcase.frames(d.props as never) : [d.props];
    for (const f of frames as Record<string, unknown>[]) {
      steps.push([
        ...drafts.slice(0, i).map(done),
        { ...d, props: f, status: f.status === 'streaming' ? 'streaming' : 'ready' },
      ]);
    }
  });
  steps.push(drafts.map(done));
  return steps;
}

function useStream(drafts: Draft[] | null, reducedMotion: boolean) {
  const [shown, setShown] = React.useState<GenUICall[]>([]);
  const [done, setDone] = React.useState(false);
  React.useEffect(() => {
    if (!drafts) {
      setShown([]);
      setDone(false);
      return;
    }
    if (reducedMotion) {
      setShown(drafts.map((d) => ({ ...d, status: 'ready' })));
      setDone(true);
      return;
    }
    const steps = streamSteps(drafts);
    let i = 0;
    setShown(steps[0]);
    setDone(false);
    const timer = setInterval(() => {
      i += 1;
      if (i >= steps.length) {
        clearInterval(timer);
        setDone(true);
        return;
      }
      setShown(steps[i]);
    }, FRAME_MS);
    return () => clearInterval(timer);
    // Drafts are fixed per run; live props are merged in after streaming.
  }, [drafts, reducedMotion]);
  return { shown, done };
}

/* ---------- Text that fades in word by word ---------- */

function Lead({ text, reducedMotion }: { text: string; reducedMotion: boolean }) {
  const words = text.split(' ');
  return (
    <p className="text-[0.9375rem] leading-relaxed">
      {reducedMotion
        ? text
        : words.map((w, i) => (
            <motion.span
              // biome-ignore lint/suspicious/noArrayIndexKey: words of a fixed sentence
              key={i}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              transition={{ duration: 0.25, delay: i * 0.03 }}
            >
              {w}{' '}
            </motion.span>
          ))}
    </p>
  );
}

/* ---------- Preferences from the questions' result ---------- */

function toPreferences(answers: Record<string, string | string[]>): Preferences {
  const pick = <T extends string>(v: unknown, allowed: readonly T[], fallback: T): T =>
    typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
  const interests = Array.isArray(answers.interests)
    ? answers.interests.filter((v): v is Interest => (INTERESTS as string[]).includes(v))
    : [];
  return {
    pace: pick(answers.pace, PACE_OPTIONS, DEFAULT_PREFERENCES.pace),
    interests,
    transport: pick(answers.transport, TRANSPORTS, DEFAULT_PREFERENCES.transport),
  };
}

function summary(prefs: Preferences) {
  return [
    PACE_LABEL[prefs.pace],
    prefs.interests.map((i) => INTEREST_LABEL[i]).join(', '),
    TRANSPORT_LABEL[prefs.transport],
  ]
    .filter(Boolean)
    .join(' · ');
}

const nameOf = (id: string) => placeById.get(id)?.name ?? id;

const formatters = {
  show_itinerary: (r: unknown) => {
    const removed = (r as { removed?: string[] }).removed ?? [];
    return removed.length ? `Removed ${removed.map(nameOf).join(', ')}` : null;
  },
  show_inspiration: (r: unknown) => {
    const ids = (r as { ids?: string[] }).ids ?? [];
    return ids.length ? `Added ${ids.map(nameOf).join(', ')}` : null;
  },
  ask_quick_questions: () => null,
};

/* ---------- The example ---------- */

const BASE_ROUTE = planRoute(DEFAULT_PREFERENCES);

export function TravelDemo() {
  const ref = React.useRef<HTMLElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.1 });
  const reducedMotion = useReducedMotion() ?? false;

  const [run, setRun] = React.useState(0);
  // Turn 1: what the user changed in place (no model call needed).
  const [removed1, setRemoved1] = React.useState<string[]>([]);
  const [added1, setAdded1] = React.useState<string[]>([]);
  const [results, setResults] = React.useState<Record<string, unknown>>({});
  // Turn 2: the customised route.
  const [prefs, setPrefs] = React.useState<Preferences | null>(null);
  const [removed2, setRemoved2] = React.useState<string[]>([]);

  React.useEffect(() => {
    if (inView) setRun((r) => (r === 0 ? 1 : r));
  }, [inView]);

  const ids = React.useMemo(
    () => ({
      photos: `photos-${run}`,
      map1: `map1-${run}`,
      itin1: `itin1-${run}`,
      sugg: `sugg-${run}`,
      ask: `ask-${run}`,
      stack2: `stack2-${run}`,
    }),
    [run],
  );

  // Turn 1, as the model wrote it.
  const drafts1 = React.useMemo<Draft[] | null>(
    () =>
      run === 0
        ? null
        : [
            { id: ids.photos, name: 'show_photos', props: photosProps },
            {
              id: ids.map1,
              name: 'show_map',
              props: mapProps(BASE_ROUTE, 'A day in San Francisco'),
            },
            { id: ids.itin1, name: 'show_itinerary', props: itineraryProps(BASE_ROUTE, []) },
            { id: ids.sugg, name: 'show_inspiration', props: suggestionsProps },
            { id: ids.ask, name: 'ask_quick_questions', props: questionsProps },
          ],
    [run, ids],
  );
  const turn1 = useStream(drafts1, reducedMotion);

  // The user's in-place edits flow into the map and the list right away.
  const stops1 = React.useMemo(
    () =>
      added1.length
        ? timeRoute(
            [...BASE_ROUTE.map((s) => s.id), ...added1].flatMap((id) => {
              const p = placeById.get(id);
              return p ? [p] : [];
            }),
            'walk',
            added1,
          )
        : BASE_ROUTE,
    [added1],
  );

  // Turn 2 is fixed once asked: later edits to turn 1 are inert.
  // biome-ignore lint/correctness/useExhaustiveDependencies: see above
  const route2 = React.useMemo(
    () => (prefs ? planRoute(prefs, { removed: removed1, added: added1 }) : null),
    [prefs],
  );

  // Turn 2 is one tool call: a stack with the map over the editable list.
  const drafts2 = React.useMemo<Draft[] | null>(
    () =>
      route2 && prefs
        ? [{ id: ids.stack2, name: 'show_stack', props: stackProps(route2, []) }]
        : null,
    [route2, prefs, ids],
  );
  const turn2 = useStream(drafts2, reducedMotion);

  const liveProps = (call: GenUICall): GenUICall => {
    if (call.status !== 'ready') return call;
    const stored = results[call.id];
    const withResult = stored !== undefined ? { ...call, result: stored } : call;
    if (call.id === ids.map1) {
      return {
        ...withResult,
        props: mapProps(
          stops1.filter((s) => !removed1.includes(s.id)),
          'A day in San Francisco',
        ),
        supersededBy: prefs ? ids.stack2 : undefined,
      };
    }
    if (call.id === ids.itin1) {
      return {
        ...call,
        props: itineraryProps(stops1, removed1),
        supersededBy: prefs ? ids.stack2 : undefined,
      };
    }
    if (call.id === ids.stack2 && route2) {
      return { ...call, props: stackProps(route2, removed2) };
    }
    return withResult;
  };

  const onResult = (call: GenUICall, result: unknown) => {
    type Edit = {
      action?: string;
      removed?: string[];
      ids?: string[];
      answers?: Record<string, string | string[]>;
    };
    const r = result as Edit;
    // The stack's children report as { child, result }; the list is item 1.
    const inner = (result as { child?: string; result?: Edit }) ?? {};
    if (call.id === ids.itin1 && r.action === 'change') {
      setRemoved1(r.removed ?? []);
      setResults((m) => ({ ...m, [call.id]: result }));
    } else if (call.id === ids.stack2 && inner.child === '1' && inner.result?.action === 'change') {
      setRemoved2(inner.result.removed ?? []);
    } else if (call.id === ids.sugg && r.action === 'select') {
      setAdded1((a) => [...new Set([...a, ...(r.ids ?? [])])]);
      setResults((m) => ({ ...m, [call.id]: result }));
    } else if (call.id === ids.ask && r.action === 'submit' && r.answers) {
      setResults((m) => ({ ...m, [call.id]: result }));
      setPrefs(toPreferences(r.answers));
    }
  };

  // What the host adds to the next turn, so the model sees the current route.
  const sentNote = React.useMemo(() => {
    if (!prefs) return '';
    const entries: GenUIStateEntry[] = [
      { id: ids.itin1, name: 'show_itinerary', result: results[ids.itin1] },
      { id: ids.sugg, name: 'show_inspiration', result: results[ids.sugg] },
    ];
    return describeUIState(entries, { formatters });
  }, [prefs, results, ids]);

  const reset = () => {
    setRemoved1([]);
    setAdded1([]);
    setResults({});
    setPrefs(null);
    setRemoved2([]);
    setRun((r) => r + 1);
  };

  const last = route2?.at(-1);
  const lead2 = route2
    ? `Updated: ${route2.length} stops, ${TRANSPORT_LABEL[prefs?.transport ?? 'walk'].toLowerCase()}${last ? `, done around ${last.time}` : ''}.`
    : '';

  return (
    <section ref={ref} aria-label="Travel example" className="not-prose flex flex-col gap-6">
      <UserBubble>{PROMPT}</UserBubble>
      <AssistantMessage>
        <GenUIStack>
          {run > 0 && (
            <Lead
              reducedMotion={reducedMotion}
              text="A walkable day along the waterfront, from the Ferry Building to the Golden Gate Bridge."
            />
          )}
          {turn1.shown.map((call) => (
            <GenUIRenderer
              key={call.id}
              call={liveProps(call)}
              catalog={catalog}
              refs={IMAGES}
              onResult={onResult}
            />
          ))}
        </GenUIStack>
      </AssistantMessage>

      {prefs && (
        <>
          <div className="flex flex-col items-end gap-2">
            <UserBubble>{summary(prefs)}</UserBubble>
            <details className="group w-full max-w-[85%] rounded-2xl border bg-muted/30 text-sm">
              <summary className="cursor-pointer list-none px-4 py-2.5 text-xs font-medium text-muted-foreground select-none [&::-webkit-details-marker]:hidden">
                What the agent receives
              </summary>
              <pre className="overflow-x-auto border-t px-4 py-3 text-xs leading-relaxed whitespace-pre-wrap">
                {JSON.stringify(
                  (results[ids.ask] as { answers?: unknown })?.answers ?? {},
                  null,
                  2,
                )}
                {sentNote ? `\n\n${sentNote}` : ''}
              </pre>
            </details>
          </div>
          <AssistantMessage>
            <GenUIStack>
              <Lead reducedMotion={reducedMotion} text={lead2} />
              {turn2.shown.map((call) => (
                <GenUIRenderer
                  key={call.id}
                  call={liveProps(call)}
                  catalog={catalog}
                  refs={IMAGES}
                  onResult={onResult}
                />
              ))}
            </GenUIStack>
          </AssistantMessage>
        </>
      )}

      {(turn2.done || (turn1.done && !prefs)) && (
        <div className="flex justify-center">
          <Button variant="ghost" size="sm" onClick={reset}>
            <RotateCcwIcon data-icon="inline-start" />
            Start over
          </Button>
        </div>
      )}
    </section>
  );
}
/*
 * Data and a tiny route planner for the travel example. It stands in for the
 * model: same inputs (preferences, the user's edits), new route out. Real
 * places with approximate coordinates and plain facts only: no ratings,
 * prices or opening hours.
 */

import { sfArt } from './travel-art';

export type Interest = 'views' | 'food' | 'history' | 'neighborhoods';
export type Pace = 'relaxed' | 'balanced' | 'packed';
export type Transport = 'walk' | 'transit' | 'car';

export type Place = {
  id: string;
  name: string;
  lat: number;
  lng: number;
  /** Map marker icon. */
  kind: 'food' | 'viewpoint' | 'activity' | 'harbor' | 'museum' | 'forest' | 'mountain';
  /** One plain line. */
  description: string;
  /** Minutes to spend there. */
  minutes: number;
  tags: Interest[];
  /** 3 = in the default day, 1 = a suggestion. */
  priority: number;
  /** West-to-east loop position, so any subset still reads as a route. */
  order: number;
  /** Far from the waterfront walk. */
  far?: boolean;
};

export const PLACES: Place[] = [
  {
    id: 'ferry-building',
    name: 'Ferry Building',
    lat: 37.7955,
    lng: -122.3937,
    kind: 'food',
    description: 'Food hall in the 1898 terminal on the Embarcadero.',
    minutes: 60,
    tags: ['food', 'history'],
    priority: 3,
    order: 1,
  },
  {
    id: 'chinatown',
    name: 'Chinatown',
    lat: 37.7908,
    lng: -122.4058,
    kind: 'food',
    description: 'Enter at the Dragon Gate and walk up Grant Avenue.',
    minutes: 60,
    tags: ['food', 'neighborhoods', 'history'],
    priority: 2,
    order: 2,
  },
  {
    id: 'coit-tower',
    name: 'Coit Tower',
    lat: 37.8024,
    lng: -122.4058,
    kind: 'viewpoint',
    description: 'Art Deco tower on Telegraph Hill, with murals inside.',
    minutes: 60,
    tags: ['views', 'history'],
    priority: 3,
    order: 3,
  },
  {
    id: 'pier-39',
    name: 'Pier 39 sea lions',
    lat: 37.8087,
    lng: -122.4098,
    kind: 'harbor',
    description: 'Sea lions on the floating docks at K-Dock.',
    minutes: 30,
    tags: ['neighborhoods'],
    priority: 1,
    order: 4,
  },
  {
    id: 'alcatraz',
    name: 'Alcatraz Island',
    lat: 37.827,
    lng: -122.423,
    kind: 'museum',
    description: 'Former federal prison. Ferries leave from Pier 33.',
    minutes: 150,
    tags: ['history', 'views'],
    priority: 1,
    order: 4.5,
  },
  {
    id: 'fishermans-wharf',
    name: "Fisherman's Wharf",
    lat: 37.808,
    lng: -122.4177,
    kind: 'harbor',
    description: 'Waterfront piers, chowder stands and historic ships.',
    minutes: 60,
    tags: ['food', 'neighborhoods'],
    priority: 3,
    order: 5,
  },
  {
    id: 'cable-car',
    name: 'Powell–Hyde cable car',
    lat: 37.8063,
    lng: -122.4206,
    kind: 'activity',
    description: 'Ride from the Hyde Street turnaround over Russian Hill.',
    minutes: 30,
    tags: ['views', 'history'],
    priority: 1,
    order: 5.5,
  },
  {
    id: 'lombard-street',
    name: 'Lombard Street',
    lat: 37.8021,
    lng: -122.4187,
    kind: 'activity',
    description: 'The one-block stretch with eight hairpin turns.',
    minutes: 30,
    tags: ['neighborhoods', 'views'],
    priority: 3,
    order: 6,
  },
  {
    id: 'palace-of-fine-arts',
    name: 'Palace of Fine Arts',
    lat: 37.8029,
    lng: -122.4484,
    kind: 'museum',
    description: 'Rotunda and lagoon built for the 1915 exposition.',
    minutes: 45,
    tags: ['history'],
    priority: 3,
    order: 7,
  },
  {
    id: 'golden-gate-bridge',
    name: 'Golden Gate Bridge',
    lat: 37.8077,
    lng: -122.475,
    kind: 'viewpoint',
    description: 'Walk out from the Welcome Center on the east sidewalk.',
    minutes: 75,
    tags: ['views'],
    priority: 3,
    order: 8,
  },
  {
    id: 'tea-garden',
    name: 'Japanese Tea Garden',
    lat: 37.7702,
    lng: -122.4703,
    kind: 'forest',
    description: 'The oldest public Japanese garden in the US, in Golden Gate Park.',
    minutes: 60,
    tags: ['history'],
    priority: 1,
    order: 9,
    far: true,
  },
  {
    id: 'painted-ladies',
    name: 'Painted Ladies',
    lat: 37.7762,
    lng: -122.4328,
    kind: 'activity',
    description: 'Victorian row houses facing Alamo Square park.',
    minutes: 30,
    tags: ['neighborhoods', 'views'],
    priority: 3,
    order: 10,
  },
  {
    id: 'twin-peaks',
    name: 'Twin Peaks',
    lat: 37.7544,
    lng: -122.4477,
    kind: 'mountain',
    description: 'Summit with a view over the whole city and bay.',
    minutes: 45,
    tags: ['views'],
    priority: 1,
    order: 11,
    far: true,
  },
  {
    id: 'dolores-park',
    name: 'Dolores Park',
    lat: 37.7596,
    lng: -122.4269,
    kind: 'forest',
    description: 'Sloping lawn in the Mission with a skyline view.',
    minutes: 45,
    tags: ['neighborhoods', 'views'],
    priority: 1,
    order: 12,
  },
  {
    id: 'balmy-alley',
    name: 'Balmy Alley murals',
    lat: 37.7522,
    lng: -122.4123,
    kind: 'museum',
    description: 'A block of murals in the Mission, painted since the 1970s.',
    minutes: 30,
    tags: ['history', 'neighborhoods'],
    priority: 1,
    order: 13,
    far: true,
  },
];

export const placeById = new Map(PLACES.map((p) => [p.id, p]));

/** Shared by the map and the itinerary, so the same stop object drives both. */
export type RouteStop = {
  id: string;
  name: string;
  lat: number;
  lng: number;
  kind: Place['kind'];
  time: string;
  duration: string;
  description: string;
  image: { src: string; alt: string };
  added?: boolean;
};

export type Preferences = {
  pace: Pace;
  interests: Interest[];
  transport: Transport;
};

export const DEFAULT_PREFERENCES: Preferences = {
  pace: 'balanced',
  interests: [],
  transport: 'walk',
};

const STOPS_FOR: Record<Pace, number> = { relaxed: 5, balanced: 7, packed: 9 };
const GAP_FOR: Record<Transport, number> = { walk: 25, transit: 15, car: 15 };

export function formatDuration(minutes: number): string {
  if (minutes < 60) return `${minutes} min`;
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return m === 0 ? `${h} hr` : `${h} hr ${m} min`;
}

function formatClock(minutesFromMidnight: number): string {
  const h24 = Math.floor(minutesFromMidnight / 60) % 24;
  const m = minutesFromMidnight % 60;
  const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
  return `${h12}:${String(m).padStart(2, '0')} ${h24 < 12 ? 'AM' : 'PM'}`;
}

function score(p: Place, prefs: Preferences): number {
  let s = p.priority;
  for (const t of p.tags) if (prefs.interests.includes(t)) s += 2;
  if (prefs.transport === 'transit' && p.id === 'cable-car') s += 4;
  if (prefs.transport === 'car' && p.id === 'twin-peaks') s += 4;
  if (prefs.transport === 'walk' && p.far) s -= 2;
  return s;
}

/**
 * The stand-in for the model's second turn: picks and times a route from the
 * preferences, keeping what the user removed out and what they added in.
 */
export function planRoute(
  prefs: Preferences,
  { removed = [], added = [] }: { removed?: string[]; added?: string[] } = {},
): RouteStop[] {
  const count = Math.max(STOPS_FOR[prefs.pace], added.length);
  const pinned = PLACES.filter((p) => added.includes(p.id));
  const rest = PLACES.filter((p) => !added.includes(p.id) && !removed.includes(p.id))
    .map((p, i) => ({ p, s: score(p, prefs), i }))
    .sort((a, b) => b.s - a.s || a.i - b.i)
    .slice(0, Math.max(0, count - pinned.length))
    .map((x) => x.p);
  return timeRoute(
    [...pinned, ...rest].sort((a, b) => a.order - b.order),
    prefs.transport,
    added,
  );
}

/** Start at 9:00 and add each stop's time plus the hop to the next one. */
export function timeRoute(places: Place[], transport: Transport, added: string[] = []) {
  let clock = 9 * 60;
  return places.map((p): RouteStop => {
    const stop: RouteStop = {
      id: p.id,
      name: p.name,
      lat: p.lat,
      lng: p.lng,
      kind: p.kind,
      time: formatClock(clock),
      duration: formatDuration(p.minutes),
      description: p.description,
      image: { src: sfArt[p.id] ?? '', alt: p.name },
      ...(added.includes(p.id) ? { added: true } : {}),
    };
    clock += p.minutes + GAP_FOR[transport];
    return stop;
  });
}

/** Places the default day leaves out, offered as "Add to your day". */
export const SUGGESTION_IDS = ['alcatraz', 'tea-garden', 'twin-peaks'] as const;

export const INTEREST_LABEL: Record<Interest, string> = {
  views: 'Views',
  food: 'Food',
  history: 'History',
  neighborhoods: 'Neighborhoods',
};
export const PACE_LABEL: Record<Pace, string> = {
  relaxed: 'Relaxed',
  balanced: 'Balanced',
  packed: 'Packed',
};
export const TRANSPORT_LABEL: Record<Transport, string> = {
  walk: 'Walking',
  transit: 'Cable car and Muni',
  car: 'Car',
};

On this page