first commit
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
# Otto — Project Rules
|
||||
|
||||
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.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
Follow `karpathy.md` (in this directory) for all behavioral coding guidelines.
|
||||
|
||||
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
@@ -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"]
|
||||
@@ -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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
cp .env.example .env
|
||||
# Fill GEOAPIFY_API_KEY and TICKETMASTER_API_KEY (free tiers available)
|
||||
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Otto — Travel Activity Planner",
|
||||
description: "Generate real, budget-aware itineraries using live APIs. Pick an experience mode and number of days.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+256
-58
@@ -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 (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
|
||||
<div className="max-w-3xl mx-auto px-6 py-12">
|
||||
<header className="mb-10">
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Otto</h1>
|
||||
<p className="mt-2 text-lg text-zinc-600 dark:text-zinc-400">
|
||||
Real itineraries powered by live APIs. Pick a mode and we’ll fit everything into your days and budget.
|
||||
</p>
|
||||
</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]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">Start date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
||||
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"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{mode.icon}</div>
|
||||
<div className="font-semibold">{mode.label}</div>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</main>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mt-16 text-xs text-zinc-500">
|
||||
Data from Geoapify and Ticketmaster when live. Always verify opening hours and prices directly.
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+1741
-2
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -6,21 +6,36 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"@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",
|
||||
"postgres": "^3.4.9",
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user