DEV Community

Cover image for Voyage Canvas: I Built a Full-Stack AI Travel Planner for Two College Subjects at Once
Harshwardhan S. Ranvir
Harshwardhan S. Ranvir

Posted on

Voyage Canvas: I Built a Full-Stack AI Travel Planner for Two College Subjects at Once

Flask + Groq API + Supabase + OpenWeatherMap. Multi-page web app. Solo build. Here's how it works and what I learned.


Travel planning is fragmented. You open five tabs — flights on one, hotels on another, things-to-do on a third, budget calculator somewhere else. By the time you have a rough plan, you've spent two hours and you're not even sure the budget holds.

I wanted one thing: enter a destination, set a budget, get a complete day-by-day plan. No switching tabs. No mental math.

So I built Voyage Canvas.

This was a college project — two subjects, one deadline. I architected it to satisfy both: a full-stack web application for my main project and a cloud computing project requiring SaaS, DBaaS, and PaaS components.

GitHub: github.com/Ranvir2028/Voyage-Canvas


What It Does

Voyage Canvas is an AI-powered travel planner. You give it a destination, travel dates, budget, interests, group type, and accommodation preference. It returns a complete, day-by-day itinerary with morning/afternoon/evening activities, real cost estimates, local tips, weather, and a budget breakdown.

Core features:

  • AI-generated itineraries (Groq API — Llama 3.3 70B with fallback chain)
  • Smart budget allocation across accommodation, food, transport, activities
  • Live weather + 5-day forecast via OpenWeatherMap
  • Auto local currency detection for 80+ destinations
  • User accounts with saved itinerary history
  • Destination explorer with AI-powered search
  • PDF export and print
  • Deployed on Render with Supabase PostgreSQL as the database

What it does NOT do:

  • No real flight/hotel booking (it's a planner, not a booking engine)
  • No live pricing — cost estimates are AI-generated approximations
  • Currency rates are hardcoded (not live exchange rates)

Architecture

voyage-canvas/
├── backend/
│   ├── app.py              ← Flask server + all API routes
│   ├── ai_service.py       ← Groq API integration + fallback chain
│   ├── weather_service.py  ← OpenWeatherMap integration
│   ├── budget_service.py   ← Budget allocation logic
│   ├── currency_service.py ← Local currency detection
│   ├── database.py         ← PostgreSQL via Supabase
│   └── settings.py         ← Config + env vars
│
└── frontend/
    ├── pages/              ← 6 HTML pages
    ├── css/                ← 5 stylesheets
    └── js/                 ← 6 JS modules
Enter fullscreen mode Exit fullscreen mode

Stack:

Layer Technology
Backend Python 3.12, Flask, Flask-CORS
AI Groq API (Llama 3.3 70B)
Database PostgreSQL via Supabase
Weather OpenWeatherMap API
Auth Token-based (SHA-256 + secrets)
Deployment Render (web service) + Supabase (DBaaS)
Frontend Vanilla HTML/CSS/JS — no framework

Flask serves both the API and the frontend pages from a single server. No React, no Vue — just clean vanilla JS with a modular file structure.


Deep Dive: How Each Component Works

1. AI Itinerary Generation — ai_service.py

This is the core of the project. The generate_itinerary() function takes user inputs and builds a structured prompt that tells the model exactly what JSON to return.

The prompt enforces strict rules:

prompt = f"""You are a professional travel planner. Generate a complete {days}-day itinerary for {destination}.

CRITICAL RULES:
1. You MUST generate EXACTLY {days} day objects in the "days" array
2. Return ONLY valid JSON — no markdown, no explanation, no extra text
3. Every field must have a real value — no nulls, no empty strings
4. Budget numbers must be realistic for {currency_sym}{budget} total over {days} days
"""
Enter fullscreen mode Exit fullscreen mode

The response schema is detailed — each day object has morning, afternoon, and evening slots, each with time, title, description, location, cost, and an insider tip. Plus top-level fields for summary, best time to visit, local cuisine, packing list, weather, and budget breakdown.

The fallback chain:

LLMs hit rate limits. Groq's free tier is especially aggressive. So ai_service.py doesn't just call one model — it tries a chain:

FALLBACK_MODELS = [
    "llama-3.3-70b-versatile",   # Primary
    "llama-3.1-8b-instant",      # Fallback 1
    "mixtral-8x7b-32768",        # Fallback 2
    "gemma2-9b-it",              # Fallback 3
]
Enter fullscreen mode Exit fullscreen mode

If the primary model hits a 429, it waits (up to 20 seconds, per the Retry-After header) and tries the next model. This makes the app resilient without requiring a paid API plan.

The day count problem:

AI models don't always return the exact number of days you ask for. A 7-day request might come back with 5 days. The function detects this and fills the gap programmatically:

existing_days = result.get("days", [])
if len(existing_days) < days:
    for i in range(len(existing_days) + 1, days + 1):
        base = existing_days[-1].copy()
        base.update({
            "day": i,
            "date": f"Day {i}",
            "theme": f"Day {i} — Exploration",
            ...
        })
        existing_days.append(base)
Enter fullscreen mode Exit fullscreen mode

Not elegant, but it guarantees the frontend never breaks because an itinerary is missing days.


2. Database — database.py

Supabase provides the managed PostgreSQL instance — the schema itself is still mine to define. init_db() runs this on startup:

Three tables: users, sessions, itineraries.

CREATE TABLE IF NOT EXISTS users (
    id           SERIAL PRIMARY KEY,
    username     TEXT UNIQUE NOT NULL,
    email        TEXT UNIQUE NOT NULL,
    password     TEXT NOT NULL,
    full_name    TEXT NOT NULL,
    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS sessions (
    token      TEXT PRIMARY KEY,
    user_id    INTEGER NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS itineraries (
    id          SERIAL PRIMARY KEY,
    user_id     INTEGER NOT NULL,
    destination TEXT NOT NULL,
    duration    INTEGER NOT NULL,
    budget      REAL NOT NULL,
    data        TEXT NOT NULL,   -- Full JSON blob
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

Auth is token-based. On login, a 64-character hex token is generated with secrets.token_hex(32) and stored in the sessions table. Every protected API request checks this token via the Authorization: Bearer <token> header.

Passwords are SHA-256 hashed with a salt before storage. Not bcrypt — a tradeoff made for a deadline-bound college project, not something I'd ship to production as-is.

Generated itineraries are stored as JSON blobs in the data column. The full AI response goes in as a string, retrieved and parsed on demand. Simple, and it works.


3. Budget Service — budget_service.py

The budget allocator splits the total budget across six categories using percentage weights:

pct = {
    "accommodation": 0.35,
    "food":          0.25,
    "transport":     0.15,
    "activities":    0.15,
    "shopping":      0.05,
    "misc":          0.05
}
Enter fullscreen mode Exit fullscreen mode

The weights adjust based on selected interests. Shopping-heavy trips shift 5% from misc to shopping. Adventure/outdoor trips shift 5% from food to activities. The allocation is also broken down per day, so users see both the total and the daily allowance for each category.


4. Currency Service — currency_service.py

80+ destinations mapped to their local currencies with hardcoded exchange rates. The function does keyword matching — if "tokyo" or "japan" appears in the destination string, it returns JPY with rate 149.5.

def get_currency(destination: str) -> dict:
    dest = destination.lower().strip()
    for key, currency in CURRENCY_MAP.items():
        if key in dest or dest in key:
            return {**currency, "detected": True}
    return {"code": "USD", "symbol": "$", "name": "US Dollar",
            "rate": 1.0, "detected": False}
Enter fullscreen mode Exit fullscreen mode

The limitation is obvious: exchange rates change and these don't. A live rates API would fix this. I didn't have time to wire one in before the deadline.


5. Flask Backend — app.py

Flask serves both the frontend pages and the REST API from a single process. Page routes serve HTML files directly:

@app.route('/planner')
def planner():
    return send_from_directory(PAGES_DIR, 'planner.html')
Enter fullscreen mode Exit fullscreen mode

API routes handle data:

@app.route("/api/generate-itinerary", methods=["POST"])
def generate():
    data = request.get_json()
    result = generate_itinerary(data)
    # Auto-save if user is logged in
    user = get_current_user()
    if user:
        save_itinerary(user_id=user['id'], ...)
    return jsonify({"success": True, "itinerary": result})
Enter fullscreen mode Exit fullscreen mode

Generated itineraries auto-save to the database if the user is logged in. No extra step required.

For deployment, the host is set to 0.0.0.0 — required for Render, which injects the $PORT environment variable dynamically. gunicorn handles production serving.


6. Frontend Architecture

Six pages, no framework. Each page has its own JS module:

  • main.js — global utilities, API helper, toast notifications, date formatting
  • auth.js — login, register, logout, auth guard
  • planner.js — 4-step form with progress indicator
  • itinerary.js — render the full AI response, budget pie chart, maps iframe
  • explore.js — destination cards, modal, AI-powered search
  • dashboard.js — user stats, saved itineraries, profile

The API helper in main.js automatically attaches the auth token to every request:

async function apiCall(endpoint, method = "GET", body = null) {
    const token = localStorage.getItem("vc_token");
    const headers = { "Content-Type": "application/json" };
    if (token) headers["Authorization"] = `Bearer ${token}`;
    ...
}
Enter fullscreen mode Exit fullscreen mode

The design is a dark luxury travel magazine aesthetic — deep navy, gold accents, Cormorant Garamond for display text, Bebas Neue for stamps. CSS variables handle the full design system.


The Planner Flow

Four steps, one page, no reloads:

Step 1 — Destination & Dates: City name, start date, number of days

Step 2 — Budget: Slider with USD/INR toggle. Budget updates in real time. Currency auto-detects based on destination.

Step 3 — Preferences: Interest tags (Culture, Food, Adventure, Nightlife, etc.), group type (Solo/Couple/Family/Friends), accommodation tier (1-star to 5-star, Airbnb)

Step 4 — Summary & Generate: Review all inputs, then hit Generate. The AI call takes 10-20 seconds. A loading overlay with animated text keeps the user informed.

On success, the full itinerary JSON is saved to localStorage and the user is redirected to /itinerary.


Cloud Architecture (How It Satisfied Two Subjects)

This is the part I planned deliberately.

The project had to satisfy a cloud computing subject's requirements for SaaS, DBaaS, and PaaS components alongside the main web project. Instead of building two separate projects, I designed Voyage Canvas to cover both:

  • SaaS — Voyage Canvas itself. A web application delivered as a service, accessible from any browser
  • DBaaS — Supabase. Managed PostgreSQL. No server to configure, no backups to manage manually
  • PaaS — Render. The Flask application deploys from a render.yaml config. Push to GitHub, it deploys
  • AI as a Service — Groq API. The AI capability is consumed as an external API, not self-hosted

One codebase. Two subjects. One deadline. That's the decision I'm most satisfied with in this project.


Sample Output

Itinerary generation request:

{
  "destination": "Tokyo",
  "days": 5,
  "budget": 800,
  "currency": "USD",
  "interests": ["Culture", "Food", "Shopping"],
  "groupType": "Solo",
  "accommodation": "3-star"
}
Enter fullscreen mode Exit fullscreen mode

What comes back (abbreviated):

{
  "destination": "Tokyo",
  "totalDays": 5,
  "summary": "A 5-day solo journey through Tokyo...",
  "days": [
    {
      "day": 1,
      "theme": "Arrival & First Impressions",
      "morning": {
        "title": "Senso-ji Temple, Asakusa",
        "cost": 0,
        "tips": "Arrive before 8am to avoid crowds"
      },
      ...
    }
  ],
  "budgetBreakdown": {
    "accommodation": 280,
    "food": 200,
    "transport": 120,
    "activities": 120,
    "shopping": 40,
    "misc": 40
  }
}
Enter fullscreen mode Exit fullscreen mode

Voyage Canvas's Home Page Screenshot:

Voyage Canvas's Home Page


Key Engineering Decisions

Why Flask over Django or FastAPI?

Scope and speed. Flask gave me exactly what I needed — routing, JSON responses, static file serving — without the overhead of learning Django's ORM or FastAPI's async patterns under a deadline. For a project this size, Flask is the right call.

Why no frontend framework?

React or Vue would have added a build step, npm dependency management, and component architecture overhead. The app has six pages and predictable state. Vanilla JS with modular files handled it cleanly. The choice kept the project deployable without a separate frontend build process.

Why Supabase over SQLite?

The cloud computing requirement pushed me toward a managed database. But even without that constraint, SQLite breaks the moment you deploy to a stateless server — Render's filesystem resets on every deploy. Supabase gave me a persistent PostgreSQL instance with a free tier that covers the project's needs.

Why store itineraries as JSON blobs?

The AI response structure is nested and variable — different trip lengths produce different shapes of data. Normalizing it into relational tables would have required 5+ tables and complex joins. Storing the full JSON blob and parsing it on retrieval is simpler, faster to build, and easier to change. The tradeoff is you can't query inside the itinerary data — acceptable for this use case.

Why a fallback model chain?

Groq's free tier has aggressive rate limits. A single model would fail under any real load. The fallback chain — Llama 3.3 70B → Llama 3.1 8B → Mixtral → Gemma — means the app degrades gracefully instead of returning a 429 error to the user. The user might get a slightly less capable model, but they get a response.


What I Learned

1. Prompt engineering is real engineering

Getting the AI to return valid, consistently structured JSON every time took more iteration than any other part of the project. Vague prompts produce vague output. The final prompt is explicit to the point of being verbose — it restates the day count requirement three times, includes an example JSON structure, and lists critical rules as numbered items.

The model still sometimes returns fewer days than requested. Hence the programmatic fallback.

2. Stateless servers break assumptions

First deployment on Render: the app worked locally, failed in production. The issue was localhost vs 0.0.0.0 for the Flask host and SQLite file paths that assumed persistent storage. Switching to 0.0.0.0 and Supabase fixed both. Local development assumptions don't always hold on cloud platforms.

3. Rate limits require architecture decisions, not just error handling

A try-catch around an API call handles the error. A fallback model chain handles the user experience. The difference between the two is the difference between an app that crashes gracefully and one that keeps working.

4. Currency and cost data ages badly

The currency rates in currency_service.py are hardcoded to values from when I built this. They're already stale. For anything meant to run long-term, external rate APIs are not optional — they're infrastructure.

5. Designing for two constraints produces better architecture

Having to satisfy both a web project rubric and a cloud computing rubric forced decisions I wouldn't have made otherwise — Supabase instead of SQLite, Render instead of running it locally, separating services cleanly so each had a clear cloud equivalent. Constraints pushed the architecture toward something more production-realistic.


Limitations

  • Currency rates are hardcoded — they drift from real rates over time
  • Password hashing uses SHA-256, not bcrypt — fine for a college project, not for production
  • No real-time pricing — cost estimates are AI approximations, not scraped data
  • The Supabase free tier pauses after inactivity — cold start delay on first request
  • No mobile-responsive CSS — the frontend is desktop-only

Where It Stands

Voyage Canvas is done. It works, it's deployed, and it satisfied both subjects it was built for. I'm not actively extending it — there are other projects ahead of it in the queue.
The codebase is on GitHub. It's a working full-stack app — clone it, set up your own API keys and database, and it runs locally.


Quick Start

git clone https://github.com/Ranvir2028/Voyage-Canvas.git
cd Voyage-Canvas/backend
pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Create backend/.env:

VISION_KEY=your_groq_api_key
OPENWEATHER_API_KEY=your_openweather_key
DATABASE_URL=your_supabase_connection_string
Enter fullscreen mode Exit fullscreen mode

Run:

python app.py
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:5000.

You'll need a Groq API key (free at console.groq.com) and an OpenWeatherMap key (free tier works). Supabase is optional if you modify database.py to use SQLite for local testing.


GitHub: github.com/Ranvir2028/Voyage-Canvas

Portfolio: harshwardhan-ranvir.vercel.app

LinkedIn: linkedin.com/in/harshwardhan-s-ranvir-20a328378


Top comments (0)