A visual travel planning application that lets you design itineraries using three synchronized interfaces: a custom DSL code editor, a Notion-style block editor, and an interactive canvas. Built with Next.js, React Flow, and AI-powered suggestions.
Nami is a travel planning tool designed for people who think visually but want the precision of code. Whether you're planning a weekend getaway or a multi-week adventure, Nami gives you multiple ways to view and edit your itineraryβall kept perfectly in sync.
- Three editing modes: Code (DSL), Blocks (Notion-style), Canvas (visual timeline)
- Real-time synchronization between all editors
- Drag-and-drop canvas with day cards, activities, and route alternatives
- AI-powered suggestions via Gemini 2.5 Flash
- Route swim lanes for representing alternative plans (e.g., "Hike OR Museum")
- Resizable timeline with zoom, pan, and drag-to-reorder
- Early 2000s aesthetic with Geocities-inspired modals
Traditional travel planning tools force you into a single paradigm:
- Spreadsheets are precise but hard to visualize temporal relationships
- Google Docs become messy when representing alternative options
- Visual planners lack the expressiveness to capture complex itineraries
- Most tools can't handle "fork in the road" scenarios (Route A OR Route B)
When planning trips with multiple optionsβlike "beach day OR museum day"βyou end up with multiple documents or confusing notation. You lose the ability to see your entire trip at a glance while maintaining the flexibility to explore alternatives.
- No structured alternatives: Hard to represent "Option A vs Option B" for the same time slot
- Poor visual hierarchy: Can't see the flow of days at a glance
- Single-mode editing: Switching between "visual thinking" and "detail editing" requires different tools
- No AI integration: Manual planning for unfamiliar destinations is time-consuming
Nami solves this by introducing a three-way synchronized editing experience powered by a custom DSL (Domain Specific Language) designed specifically for travel itineraries.
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β DSL Editor ββββββΆβ AST Store βββββββ Canvas β
β (Code) β β (Source of β β (Visual) β
βββββββββββββββ β Truth) β βββββββββββββββ
ββββββββ¬βββββββ
β
ββββββββΌβββββββ
β Block β
β Editor β
βββββββββββββββ
All three interfaces operate on the same underlying Abstract Syntax Tree (AST) representation. When you edit in any interface, the change propagates through the AST to all other views.
Rather than using JSON or YAML, Nami uses a custom DSL that:
- Reads like natural language but has strict structure
- Prevents invalid states (you can't accidentally nest incompatible types)
- Is compact and scannable (no verbose JSON boilerplate)
- Maps cleanly to visual representation (each block = one visual node)
Day "Kyoto Temples" {
Travel "Shinkansen to Kyoto" {
time: "8:00 AM"
endTime: "10:30 AM"
from: "Tokyo Station"
to: "Kyoto Station"
duration: "2h 30m"
}
Activity "Fushimi Inari Shrine" {
time: "11:00 AM"
endTime: "1:00 PM"
location: "68 Fukakusa Yabunouchicho, Fushimi Ward"
note: "Hike the thousand torii gates. Wear comfortable shoes!"
}
Eat "Nishiki Market Lunch" {
time: "1:30 PM"
endTime: "3:00 PM"
location: "Nishiki Market, Nakagyo Ward"
note: "Try fresh sashimi and matcha ice cream"
}
Route "Temple Option A" {
Activity "Kinkaku-ji (Golden Pavilion)" {
time: "3:30 PM"
endTime: "5:00 PM"
location: "1 Kinkakujicho, Kita Ward"
note: "Best photos in afternoon light"
}
}
Route "Temple Option B" {
Activity "Kiyomizu-dera" {
time: "3:30 PM"
endTime: "5:30 PM"
location: "1-294 Kiyomizu, Higashiyama Ward"
note: "Amazing sunset views from the terrace"
}
}
Stay "Ryokan Yoshida-Sanso" {
time: "6:00 PM"
location: "Sakyo Ward"
note: "Check-in, traditional kaiseki dinner at 7 PM"
}
}| Keyword | Purpose | Required Fields |
|---|---|---|
Day |
Top-level container for a day's itinerary | label (name) |
Activity |
A generic event or activity | time, location |
Travel |
Transportation between locations | time, from, to |
Eat |
A meal or food experience | time, location |
Stay |
Accommodation | time, location |
Note |
Freeform text note | note |
Route |
Alternative option (creates a swim lane) | label (name) |
timeβ Start time (e.g., "10:00 AM")endTimeβ End time (optional)locationβ Address or area namefrom/toβ Origin and destination (for Travel)durationβ Time span (for Travel)noteβ Additional details, tips, or remindersreservationβ Booking confirmation info
DSL String β Lexer β Tokens β Parser β AST β Layout Engine β React Flow Nodes
The DSL string is broken into tokens using regular expressions:
const TOKEN_RE = new RegExp(
`(\\b(?:Day|Activity|Travel|Eat|Stay|Note|Route)\\b)` + // Keywords
`|(\\b\\w+(?=\\s*:))` + // Metadata keys
`|("(?:[^"\\\\]|\\\\.)*")` + // Quoted strings
`|([{}])` + // Braces
`|([^"{}\\s]+|\\s+)`, // Everything else
"g"
);The parser (parseDsl in lib/dsl-parser.ts) builds a tree structure:
interface ParsedDay {
id: string;
name: string;
items: ParsedItem[];
}
interface ParsedItem {
id: string;
type: ItemType; // "activity" | "travel" | "eat" | "stay" | "note" | "route"
label: string;
metadata: Record<string, string>;
children?: ParsedItem[]; // For Route nodes
}Key parsing logic:
- Stack-based tracking for nested blocks
- Automatic ID generation for each node
- Metadata accumulation within blocks
- Special handling for
Routeblocks (children become alternatives)
The buildFlow function converts AST to React Flow nodes with:
- Spatial positioning based on time and hierarchy
- Size calculation for day cards and activity nodes
- Route grouping (alternatives rendered as vertical swim lanes)
- Edge generation (connecting days with decorative rope edges)
function buildFlow(days: ParsedDay[], config: LayoutConfig): {
nodes: Node[];
edges: Edge[];
} {
// For each day:
// 1. Create day card node (parent container)
// 2. Position activity nodes inside day card
// 3. Group Route nodes as swim lanes
// 4. Calculate card height based on content
// 5. Position next day card with gap
// Generate edges connecting day cards
}Layout modes:
horizontal: Days flow left-to-right (timeline-style)vertical: Days stack top-to-bottom (document-style)
DayNode (Container)
βββ ActivityNode (Direct child)
βββ ActivityNode (Direct child)
βββ RouteGroupNode (Visual grouping wrapper)
β βββ RouteNode (Swim lane 1)
β β βββ ActivityNode
β βββ RouteNode (Swim lane 2)
β βββ ActivityNode
βββ ActivityNode (Direct child)
Nami implements a custom drag-and-drop system with two modes:
When dragging an activity within a day:
- System identifies "entities" (individual activities or route blocks)
- Calculates target insertion position based on drag cursor
- Visually shifts sibling nodes to show drop location
- On drop, updates AST and re-serializes DSL
// Sort state tracks entities and their visual positions
type SortEntity = {
entityId: string;
nodeIds: string[]; // Nodes in this entity
offsets: Record<string, number>; // Relative positions
pos: number; // Current position on sort axis
size: number; // Total span
};When dragging an activity to a different day:
- System detects hover over target day card
- Inserts a ghost node at insertion point
- Shifts target day's children to show gap
- Expands target day card to accommodate new content
- On drop, moves AST node from source day to target day
// Ghost node shows drop location
const ghostNode: Node = {
id: "__cross-day-ghost__",
type: "activityNode",
position: { x: ghostPos, y: ghostCrossPos },
style: {
opacity: 0.3,
border: "2.5px dashed rgba(196,98,45,0.6)",
background: "rgba(196,98,45,0.06)",
},
draggable: false,
};Double-clicking any activity node opens an inline editor modal:
const onNodeDoubleClick = (node: Node) => {
if (node.type === "activityNode") {
setEditData({
nodeId: node.id,
kind: "activity",
type: node.data.type, // activity | travel | eat | stay
label: node.data.label,
metadata: node.data.metadata,
});
}
};The editor modal allows changing:
- Item type (e.g., Activity β Eat)
- Label text
- All metadata fields (time, location, note, etc.)
On save, the AST is updated and DSL is regenerated.
All three interfaces (DSL editor, Block editor, Canvas) maintain the same source of truth: the DSL string stored in React state.
// PlanPage.tsx - Single source of truth
const [dsl, setDsl] = useState<string>(initialDsl);
// All editors receive and update this state
<DSLEditor value={dsl} onChange={setDsl} />
<WidgetEditor value={dsl} onChange={setDsl} />
<TravelCanvas dsl={dsl} onDslChange={setDsl} />- User types in DSL editor
onChangefires with new DSL stringsetDsl(newDsl)updates state- Canvas
useEffecttriggers (depends ondsl) - Canvas parses DSL and rebuilds nodes
Nami uses Google Gemini 2.5/3.0 Flash for intelligent travel suggestions.
- Day-specific planning: Click the AI button on any day card
- Full trip generation: Use the chat sidebar to plan from scratch
- Context-aware suggestions: AI sees your current itinerary
- DSL generation: AI outputs valid DSL that can be inserted directly
The AI is given:
- Full DSL syntax specification
- Current itinerary DSL (for editing suggestions)
- Destination context
- User preferences (budget, travel style, interests)
const systemPrompt = `
You are Nami, a travel planning AI. You output itineraries in DSL format.
CURRENT ITINERARY:
${currentDsl}
When the user asks to modify Day 3, output the COMPLETE rewritten day block.
NEVER append duplicatesβalways replace the full day.
`;AI responses are parsed for DSL code blocks:
const extractDsl = (aiResponse: string): string | undefined => {
const match = aiResponse.match(/```dsl\n([\s\S]*?)```/);
return match?.[1]?.trim();
};Users see:
- Visual preview of the AI's suggestion (rendered as mini day cards)
- Raw DSL in a code tab
- Reasoning explanation in a separate tab
- Insert button to merge into itinerary
- Next.js 16.2 β React framework with App Router
- React 19 β UI library
- TypeScript 5.7 β Type safety
- React Flow 12.3 β Canvas and node system
- Framer Motion 12.0 β Animations
- Tailwind CSS 4.0 β Utility-first styling
- Zustand β Lightweight state management
- React Markdown β Markdown rendering in chat
- React Joyride β Guided tour system
- Lucide React β Icon system
- Google Gemini API β 2.5 Flash for suggestions
- OpenAI API β (Optional) Alternative LLM backend
nami/
βββ src/
β βββ app/ # Next.js App Router
β β βββ [plan]/ # Dynamic route for plan pages
β β β βββ page.tsx # Plan view page
β β β βββ not-found.tsx # 404 for invalid plans
β β βββ page.tsx # Landing page
β β
β βββ components/
β β βββ PlanPage.tsx # Main planning interface
β β βββ TravelCanvas.tsx # React Flow canvas
β β βββ DSLEditor.tsx # Code editor with syntax highlighting
β β βββ WidgetEditor.tsx # Block-style editor
β β βββ ChatPanel.tsx # AI chat interface
β β βββ TopNavBar.tsx # Top navigation and modals
β β βββ GuidedTour.tsx # Onboarding tour
β β β
β β βββ nodes/ # React Flow node components
β β β βββ DayNode.tsx # Day card (container)
β β β βββ ActivityNode.tsx # Individual activity
β β β βββ RouteNode.tsx # Route swim lane
β β β βββ RouteGroupNode.tsx # Route visual wrapper
β β β βββ RouteSectionNode.tsx # Route section divider
β β β
β β βββ edges/
β β βββ RopeEdge.tsx # Decorative rope connecting days
β β
β βββ lib/
β βββ dsl-parser.ts # DSL β AST parser
β βββ storage.ts # LocalStorage utilities
β
βββ public/
β βββ nami_logo.png # Logo
β βββ shaun.jpg # About modal image
β βββ about-music.mp3 # Easter egg audio
β
βββ styles/
βββ globals.css # Global styles and theme
Nami's design is inspired by:
- Early 2000s web aesthetics (Geocities, MySpace)
- Warm, earthy tones (travel journal vibes)
- Clear information hierarchy
- Playful but functional
/* Day cards */
--day-bg: #f7f3ec;
--day-border: #c8bda8;
--day-accent: #e8a050;
/* Activity types */
--travel: #5a9ec0; /* Blue */
--eat: #d48030; /* Orange */
--activity: #5aaa68; /* Green */
--stay: #8a6ab8; /* Purple */
--note: #b8a898; /* Gray */
--route: #a08060; /* Brown */- Headings: System font stack (San Francisco, Segoe UI)
- Code: SF Mono, Fira Code, Consolas
- Body: -apple-system fallback chain
Shaun Lee
- Built with vibes in 2026
Happy Planning!
