first commit

This commit is contained in:
2026-07-08 19:17:05 +02:00
parent f001b0ca63
commit 786b161dae
19 changed files with 2492 additions and 79 deletions
+19 -4
View File
@@ -1,5 +1,20 @@
<!-- BEGIN:nextjs-agent-rules --> # Otto — Project Rules
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. Follow `karpathy.md` (in this directory) for all behavioral coding guidelines.
<!-- END:nextjs-agent-rules -->
In addition:
- All user-facing text, labels, errors, README, comments and LLM prompts **must be English only**.
- Experience Modes are a first-class concept: Balanced, Cultural Explorer, Foodie, Adventurous, Relaxed, Nightlife & Vibes. The chosen mode must visibly drive candidate selection and LLM prompt weighting.
- Explicit `num_days` from the user is authoritative (use dates mainly for event filtering).
- Prefer minimal code. No premature abstractions.
- API keys stay server-side only. Never leak to client.
- Every generation must respect budget and selected days.
- When editing, trace every line directly to the plan or user request (surgical).
See the approved plan.md in the session for detailed implementation phases, verification steps, and architecture.
## Quick Commands
- `docker compose -f docker-compose.dev.yml up --build`
- `npm run dev`
- `npm run db:migrate`
+32
View File
@@ -0,0 +1,32 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/drizzle ./drizzle
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
+46 -10
View File
@@ -1,19 +1,55 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Otto — Travel Activity Planner
## Getting Started API-powered planner for things to do in your destination.
Choose dates + **number of days**, pick an **Experience Mode** (Foodie, Cultural, Adventurous...), set a budget, and get a realistic day-by-day itinerary backed by live data from Geoapify and Ticketmaster.
First, run the development server: Everything in English. Follows `karpathy.md`.
## Quick Start (Docker recommended)
```bash ```bash
npm run dev cp .env.example .env
# or # Fill GEOAPIFY_API_KEY and TICKETMASTER_API_KEY (free tiers available)
yarn dev
# or docker compose -f docker-compose.dev.yml up --build
pnpm dev
# or
bun dev
``` ```
Open http://localhost:3000
## Without Docker
```bash
npm install
npm run db:migrate # requires a running Postgres (see .env)
npm run dev
```
## Key Features (MVP)
- Explicit number of days (not just date math)
- 6 Experience Modes that bias the entire plan
- One-click adjustment buttons after generation
- Budget respected + cost estimates
- Real places + events from APIs
- Leaflet map
- Save & share by ID
See the detailed plan in the session `plan.md` for architecture and phases.
## Environment
Required keys (free tiers):
- Geoapify
- Ticketmaster
LLM key for future full generation (currently stubbed).
## Development
- Follow `karpathy.md` + `AGENTS.md`
- `npm run lint`
- `npm run build`
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const GenerateSchema = z.object({
destination: z.string().min(2),
startDate: z.string(),
numDays: z.number().int().min(1).max(14),
budget: z.number().positive(),
currency: z.string(),
experienceMode: z.string(),
preferences: z.array(z.string()),
});
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const data = GenerateSchema.parse(body);
// Stub response (Phase 3 will replace with real Geoapify + LLM)
const days = Array.from({ length: data.numDays }, (_, i) => ({
day: i + 1,
activities: [
{
time: '09:30',
title: `Highlight ${i + 1} (${data.experienceMode})`,
type: data.experienceMode === 'foodie' ? 'restaurant' : 'attraction',
cost: Math.round(data.budget / (data.numDays * 3)),
},
{
time: '13:30',
title: 'Recommended meal',
type: 'restaurant',
cost: 22,
},
],
}));
return NextResponse.json({
tripId: 'trip_' + Date.now(),
destination: data.destination,
numDays: data.numDays,
experienceMode: data.experienceMode,
summary: `Stub plan for ${data.destination}${data.numDays} days in ${data.experienceMode} mode.`,
days,
estimatedCost: Math.round(data.budget * 0.82),
});
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
export async function GET() {
const envCheck = {
hasGeoapify: !!process.env.GEOAPIFY_API_KEY,
hasTicketmaster: !!process.env.TICKETMASTER_API_KEY,
hasLLM: !!process.env.LLM_API_KEY,
hasDb: !!process.env.DATABASE_URL,
};
return NextResponse.json({
status: 'ok',
env: envCheck,
timestamp: new Date().toISOString(),
});
}
+2 -2
View File
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Otto — Travel Activity Planner",
description: "Generated by create next app", description: "Generate real, budget-aware itineraries using live APIs. Pick an experience mode and number of days.",
}; };
export default function RootLayout({ export default function RootLayout({
+252 -54
View File
@@ -1,65 +1,263 @@
import Image from "next/image"; 'use client';
import { useState } from 'react';
import { EXPERIENCE_MODES, type ExperienceMode } from '@/lib/modes';
interface FormData {
destination: string;
startDate: string;
numDays: number;
budget: number;
currency: string;
experienceMode: ExperienceMode;
preferences: string[];
}
const PREFERENCE_OPTIONS = [
'Culture & History',
'Gastronomy',
'Bars & Nightlife',
'Concerts & Music',
'Theater & Shows',
'Parks & Nature',
'Art & Museums',
];
export default function OttoPlanner() {
const [form, setForm] = useState<FormData>({
destination: '',
startDate: '',
numDays: 3,
budget: 200,
currency: 'EUR',
experienceMode: 'balanced',
preferences: [],
});
const [isSubmitting, setIsSubmitting] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [result, setResult] = useState<any>(null); // TODO: proper Trip type after Phase 3 (stub)
const togglePreference = (pref: string) => {
setForm((prev) => ({
...prev,
preferences: prev.preferences.includes(pref)
? prev.preferences.filter((p) => p !== pref)
: [...prev.preferences, pref],
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.destination || !form.startDate) {
alert('Please provide destination and start date');
return;
}
setIsSubmitting(true);
try {
// TODO Phase 3: call real /api/generate
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error('Generation failed');
const data = await res.json();
setResult(data);
} catch {
// Stub response for early development (will be replaced)
setResult({
tripId: 'stub-' + Date.now(),
destination: form.destination,
numDays: form.numDays,
experienceMode: form.experienceMode,
summary: `Stub itinerary for ${form.destination} (${form.numDays} days, ${form.experienceMode} mode). Budget: ${form.budget} ${form.currency}.`,
days: Array.from({ length: form.numDays }, (_, i) => ({
day: i + 1,
activities: [
{ time: '09:30', title: 'Top cultural sight', type: 'attraction', cost: 12 },
{ time: '13:00', title: 'Local lunch', type: 'restaurant', cost: 25 },
],
})),
estimatedCost: Math.round(form.budget * 0.85),
});
} finally {
setIsSubmitting(false);
}
};
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <div className="max-w-3xl mx-auto px-6 py-12">
<Image <header className="mb-10">
className="dark:invert" <h1 className="text-4xl font-semibold tracking-tight">Otto</h1>
src="/next.svg" <p className="mt-2 text-lg text-zinc-600 dark:text-zinc-400">
alt="Next.js logo" Real itineraries powered by live APIs. Pick a mode and well fit everything into your days and budget.
width={100} </p>
height={20} </header>
priority
<form onSubmit={handleSubmit} className="space-y-8">
{/* Destination + Dates + Days */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1.5">Destination</label>
<input
type="text"
value={form.destination}
onChange={(e) => setForm({ ...form, destination: e.target.value })}
placeholder="Barcelona, London, Mexico City..."
className="w-full rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
required
/> />
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> </div>
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <div className="grid grid-cols-2 gap-4">
To get started, edit the page.tsx file. <div>
</h1> <label className="block text-sm font-medium mb-1.5">Start date</label>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> <input
Looking for a starting point or more instructions? Head over to{" "} type="date"
<a value={form.startDate}
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="font-medium text-zinc-950 dark:text-zinc-50" className="w-full rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">Number of days</label>
<input
type="number"
min={1}
max={14}
value={form.numDays}
onChange={(e) => setForm({ ...form, numDays: parseInt(e.target.value) || 1 })}
className="w-full rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
/>
</div>
</div>
</div>
{/* Budget */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1.5">Total budget for activities & meals</label>
<div className="flex gap-3">
<input
type="number"
value={form.budget}
onChange={(e) => setForm({ ...form, budget: parseInt(e.target.value) || 0 })}
className="flex-1 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
/>
<select
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
className="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
> >
Templates <option value="EUR">EUR</option>
</a>{" "} <option value="USD">USD</option>
or the{" "} <option value="GBP">GBP</option>
<a </select>
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" </div>
className="font-medium text-zinc-950 dark:text-zinc-50" </div>
</div>
{/* Experience Mode */}
<div>
<label className="block text-sm font-medium mb-3">Experience Mode</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{EXPERIENCE_MODES.map((mode) => (
<button
type="button"
key={mode.id}
onClick={() => setForm({ ...form, experienceMode: mode.id as ExperienceMode })}
className={`rounded-2xl border p-4 text-left transition-all ${
form.experienceMode === mode.id
? 'border-black dark:border-white bg-zinc-100 dark:bg-zinc-900 ring-1 ring-black/10'
: 'border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-900'
}`}
> >
Learning <div className="text-2xl mb-1">{mode.icon}</div>
</a>{" "} <div className="font-semibold">{mode.label}</div>
center. <div className="text-sm text-zinc-600 dark:text-zinc-400 mt-0.5">{mode.description}</div>
</button>
))}
</div>
</div>
{/* Preferences */}
<div>
<label className="block text-sm font-medium mb-2">Additional interests (optional)</label>
<div className="flex flex-wrap gap-2">
{PREFERENCE_OPTIONS.map((pref) => {
const active = form.preferences.includes(pref);
return (
<button
type="button"
key={pref}
onClick={() => togglePreference(pref)}
className={`px-4 py-1.5 rounded-full text-sm border transition ${
active
? 'bg-black text-white border-black dark:bg-white dark:text-black'
: 'border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-900'
}`}
>
{pref}
</button>
);
})}
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full md:w-auto rounded-2xl bg-black text-white dark:bg-white dark:text-black px-8 py-4 font-medium disabled:opacity-60"
>
{isSubmitting ? 'Generating your itinerary…' : 'Generate itinerary'}
</button>
</form>
{/* Stub / Result area */}
{result && (
<div className="mt-12 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-8 bg-white dark:bg-zinc-900">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-sm text-zinc-500">Itinerary for</div>
<div className="text-2xl font-semibold">{result.destination}</div>
</div>
<div className="text-right text-sm">
{result.numDays} days {result.experienceMode} mode<br />
~{result.estimatedCost} {form.currency}
</div>
</div>
<div className="space-y-6">
{result.days?.map((day: any, idx: number) => ( // eslint-disable-line @typescript-eslint/no-explicit-any
<div key={idx} className="border-l-2 border-zinc-200 dark:border-zinc-800 pl-4">
<div className="font-semibold mb-2">Day {day.day}</div>
<ul className="space-y-2 text-sm">
{day.activities.map((a: any, i: number) => ( // eslint-disable-line @typescript-eslint/no-explicit-any
<li key={i} className="flex justify-between">
<span>{a.time} {a.title}</span>
<span className="text-zinc-500">{a.cost} {form.currency}</span>
</li>
))}
</ul>
</div>
))}
</div>
<p className="mt-6 text-xs text-zinc-500">
This is a development stub. Real API + LLM generation coming in Phase 3.
</p> </p>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> )}
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <footer className="mt-16 text-xs text-zinc-500">
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" Data from Geoapify and Ticketmaster when live. Always verify opening hours and prices directly.
target="_blank" </footer>
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main>
</div> </div>
); );
} }
+48
View File
@@ -0,0 +1,48 @@
# Development Docker Compose (self-contained).
# Usage:
# docker compose -f docker-compose.dev.yml up --build
#
# After first start the database migrations run automatically.
# Stop with: docker compose -f docker-compose.dev.yml down
services:
app:
build:
context: .
target: deps
command: sh -c "npm run db:migrate && npm run dev"
working_dir: /app
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://otto:otto@db:5432/otto
GEOAPIFY_API_KEY: ${GEOAPIFY_API_KEY}
TICKETMASTER_API_KEY: ${TICKETMASTER_API_KEY}
LLM_API_KEY: ${LLM_API_KEY}
LLM_BASE_URL: ${LLM_BASE_URL:-https://openrouter.ai/api/v1}
LLM_MODEL: ${LLM_MODEL:-openai/gpt-4o-mini}
WATCHPACK_POLLING: "true"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: otto
POSTGRES_PASSWORD: otto
POSTGRES_DB: otto
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U otto -d otto"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
+35
View File
@@ -0,0 +1,35 @@
# Production-like compose
services:
app:
build:
context: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://otto:otto@db:5432/otto
GEOAPIFY_API_KEY: ${GEOAPIFY_API_KEY}
TICKETMASTER_API_KEY: ${TICKETMASTER_API_KEY}
LLM_API_KEY: ${LLM_API_KEY}
LLM_BASE_URL: ${LLM_BASE_URL:-https://openrouter.ai/api/v1}
LLM_MODEL: ${LLM_MODEL:-openai/gpt-4o-mini}
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: otto
POSTGRES_PASSWORD: otto
POSTGRES_DB: otto
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U otto -d otto"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './lib/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://otto:otto@localhost:5432/otto',
},
verbose: true,
strict: true,
});
+38
View File
@@ -0,0 +1,38 @@
-- Initial schema for otto trips and activities
CREATE TABLE IF NOT EXISTS "trips" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"destination" text NOT NULL,
"lat" numeric,
"lng" numeric,
"start_date" timestamptz,
"num_days" integer NOT NULL,
"total_budget" numeric,
"currency" text DEFAULT 'EUR',
"preferences" jsonb DEFAULT '[]'::jsonb,
"experience_mode" text DEFAULT 'balanced',
"estimated_total_cost" numeric,
"created_at" timestamptz DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "activities" (
"id" serial PRIMARY KEY NOT NULL,
"trip_id" uuid,
"day_number" integer NOT NULL,
"start_time" text,
"title" text NOT NULL,
"type" text NOT NULL,
"estimated_cost" numeric,
"address" text,
"lat" numeric,
"lng" numeric,
"rating" numeric,
"source" text,
"source_url" text,
"notes" text,
"order_in_day" integer DEFAULT 0,
CONSTRAINT "activities_trip_id_fkey" FOREIGN KEY ("trip_id") REFERENCES "trips"("id") ON DELETE cascade
);
-- Helpful indexes
CREATE INDEX IF NOT EXISTS "trips_created_at_idx" ON "trips" ("created_at");
+59
View File
@@ -0,0 +1,59 @@
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+16
View File
@@ -0,0 +1,16 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL!;
if (!connectionString) {
throw new Error('DATABASE_URL is required');
}
// For query client (server)
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
export * from './schema';
+39
View File
@@ -0,0 +1,39 @@
import { pgTable, serial, text, timestamp, numeric, integer, uuid, jsonb } from 'drizzle-orm/pg-core';
export const trips = pgTable('trips', {
id: uuid('id').primaryKey().defaultRandom(),
destination: text('destination').notNull(),
lat: numeric('lat'),
lng: numeric('lng'),
startDate: timestamp('start_date', { withTimezone: true }),
numDays: integer('num_days').notNull(),
totalBudget: numeric('total_budget'),
currency: text('currency').default('EUR'),
preferences: jsonb('preferences').$type<string[]>().default([]),
experienceMode: text('experience_mode').default('balanced'),
estimatedTotalCost: numeric('estimated_total_cost'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const activities = pgTable('activities', {
id: serial('id').primaryKey(),
tripId: uuid('trip_id').references(() => trips.id, { onDelete: 'cascade' }),
dayNumber: integer('day_number').notNull(),
startTime: text('start_time'),
title: text('title').notNull(),
type: text('type').notNull(), // attraction | restaurant | bar | event | meal | other
estimatedCost: numeric('estimated_cost'),
address: text('address'),
lat: numeric('lat'),
lng: numeric('lng'),
rating: numeric('rating'),
source: text('source'),
sourceUrl: text('source_url'),
notes: text('notes'),
orderInDay: integer('order_in_day').default(0),
});
export type Trip = typeof trips.$inferSelect;
export type NewTrip = typeof trips.$inferInsert;
export type Activity = typeof activities.$inferSelect;
export type NewActivity = typeof activities.$inferInsert;
+40
View File
@@ -0,0 +1,40 @@
export const EXPERIENCE_MODES = [
{
id: 'balanced',
label: 'Balanced',
description: 'A good mix of everything',
icon: '⚖️',
},
{
id: 'cultural',
label: 'Cultural Explorer',
description: 'Museums, history, landmarks, heritage',
icon: '🏛️',
},
{
id: 'foodie',
label: 'Foodie',
description: 'Restaurants, local eats, markets, cafes',
icon: '🍽️',
},
{
id: 'adventurous',
label: 'Adventurous / Bold',
description: 'Unique spots, active, hidden gems, evening fun',
icon: '🧭',
},
{
id: 'relaxed',
label: 'Relaxed Local',
description: 'Slower pace, parks, free time, fewer stops',
icon: '🌳',
},
{
id: 'nightlife',
label: 'Nightlife & Vibes',
description: 'Bars, concerts, theater, late experiences',
icon: '🌙',
},
] as const;
export type ExperienceMode = typeof EXPERIENCE_MODES[number]['id'];
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: 'standalone',
}; };
export default nextConfig; export default nextConfig;
+1741 -2
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -6,21 +6,36 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts || node --loader tsx scripts/migrate.ts || npx tsx scripts/migrate.ts",
"db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"leaflet": "^1.9.4",
"lucide-react": "^1.23.0",
"next": "16.2.10", "next": "16.2.10",
"postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-hook-form": "^7.81.0",
"react-leaflet": "^5.0.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/leaflet": "^1.9.21",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.10", "eslint-config-next": "16.2.10",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.0",
"typescript": "^5" "typescript": "^5"
} }
} }
+24
View File
@@ -0,0 +1,24 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
const runMigrations = async () => {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is not set');
}
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql);
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migrations complete!');
await sql.end();
};
runMigrations().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});