SkateSpots/src/lib/tripPlanner.ts

221 lines
7 KiB
TypeScript

/**
* Trip Planner — types + itinerary builder for Sasquatch.
* ─────────────────────────────────────────────────────────────────────────────
* Sasquatch orchestrates the tools it already has (spots, events, coaches,
* crews, weather) into a day-by-day, weather-aware skate itinerary. No new
* infrastructure, no fake flight/hotel bookings — everything either lives on
* Nostr (spots/events/coaches/crews you can zap or RSVP) or deep-links out to
* the real travel sites for the parts we can't (and shouldn't) do ourselves.
*/
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { SKATE_SPOT_KIND } from '@/lib/types';
// ─── Shared result shapes (mirror useCVMClient return items) ─────────────────
export interface PlannerSpot {
name: string;
description?: string;
city?: string;
difficulty?: string;
features?: string[];
url?: string;
author_pubkey?: string;
naddr?: string;
}
export interface PlannerEvent {
title: string;
location?: string;
start?: string; // ISO
url?: string;
author_pubkey?: string;
}
export interface PlannerCoach {
role: string;
pubkey: string;
bio?: string;
location?: string;
skills?: string[];
lightning_address?: string;
url?: string;
}
export interface PlannerCrew {
name: string;
location?: string;
vibes?: string[];
url?: string;
}
export interface WeatherLite {
temperature_c?: number;
condition?: string;
/** `weatherUi.condition.*` key for `condition` (UI only). */
conditionKey?: string;
weatherIcon?: string;
precipitation_mm?: number;
skateability_label?: string;
/** `weatherUi.skateability.*` key for `skateability_label` (UI only). */
skateabilityLabelKey?: string;
skateability_score?: number;
}
// ─── Itinerary model ─────────────────────────────────────────────────────────
export interface ItineraryStop {
kind: 'spot' | 'event' | 'coach';
title: string;
subtitle?: string;
url?: string;
/** For coaches: enables the "Book / Zap" action. */
coachPubkey?: string;
lightningAddress?: string;
/** For spots: the NIP-51 collection coordinate ("30818:pubkey:d"). */
spotRef?: string;
}
export interface ItineraryDay {
dayNumber: number;
label: string; // e.g. "Day 1"
stops: ItineraryStop[];
}
export interface Itinerary {
destination: string;
days: ItineraryDay[];
weather?: WeatherLite;
crews: PlannerCrew[];
/** Everything we found, for the "save all spots to a collection" action. */
allSpotRefs: string[];
/** True when we found literally nothing to plan. */
empty: boolean;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Derive a NIP-51 collection coordinate ("30818:pubkey:d") from a CVM spot. */
export function spotRefFromSpot(spot: PlannerSpot): string | null {
if (!spot.url || !spot.author_pubkey) return null;
// url looks like https://skate-spots.org/spot/{pubkey}/{d}
const m = spot.url.match(/\/spot\/([0-9a-f]{64})\/(.+)$/);
if (!m) return null;
return `${SKATE_SPOT_KIND}:${m[1]}:${m[2]}`;
}
/**
* Build a day-by-day itinerary from the raw tool results. Weather-aware:
* if it's rainy / low skateability, we surface a note and lead with a crew
* tip (locals know the covered/indoor options). Pure, no side effects.
*/
export function buildItinerary(opts: {
destination: string;
days: number;
spots: PlannerSpot[];
events: PlannerEvent[];
coaches: PlannerCoach[];
crews: PlannerCrew[];
weather?: WeatherLite;
}): Itinerary {
const dayCount = Math.max(1, Math.min(opts.days || 3, 7));
// Build a flat, prioritized pool of stops.
const spotStops: ItineraryStop[] = opts.spots.map((s) => ({
kind: 'spot' as const,
title: s.name,
subtitle: [s.difficulty, s.city].filter(Boolean).join(' · ') || undefined,
url: s.url,
spotRef: spotRefFromSpot(s) ?? undefined,
}));
const eventStops: ItineraryStop[] = opts.events.map((e) => ({
kind: 'event' as const,
title: e.title,
subtitle: e.start ? new Date(e.start).toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric' }) : e.location,
url: e.url,
}));
const coachStops: ItineraryStop[] = opts.coaches.slice(0, 3).map((c) => ({
kind: 'coach' as const,
title: c.role.charAt(0).toUpperCase() + c.role.slice(1),
subtitle: c.skills?.slice(0, 3).join(', ') || c.location,
url: c.url,
coachPubkey: c.pubkey,
lightningAddress: c.lightning_address,
}));
// Interleave: spread spots across days, sprinkle events + one coach.
const days: ItineraryDay[] = [];
const spotsPerDay = Math.max(1, Math.ceil(spotStops.length / dayCount));
for (let d = 0; d < dayCount; d++) {
const stops: ItineraryStop[] = [];
// spots for this day
stops.push(...spotStops.slice(d * spotsPerDay, d * spotsPerDay + spotsPerDay).slice(0, 2));
// an event on this day if available
if (eventStops[d]) stops.push(eventStops[d]);
// a coach on day 2 (a good "level up" day)
if (d === 1 && coachStops[0]) stops.push(coachStops[0]);
days.push({ dayNumber: d + 1, label: `Day ${d + 1}`, stops });
}
// If we had more events than days, append leftovers to the last day.
if (eventStops.length > dayCount) {
days[days.length - 1].stops.push(...eventStops.slice(dayCount));
}
// Add any remaining coaches to day 1 as options.
if (coachStops.length > 1) {
days[0].stops.push(...coachStops.slice(1, 2));
}
const allSpotRefs = spotStops
.map((s) => s.spotRef)
.filter((r): r is string => !!r);
const empty =
spotStops.length === 0 &&
eventStops.length === 0 &&
coachStops.length === 0 &&
opts.crews.length === 0;
return {
destination: opts.destination,
days,
weather: opts.weather,
crews: opts.crews.slice(0, 3),
allSpotRefs,
empty,
};
}
// ─── External travel deep-links (assist, never book) ─────────────────────────
// Sasquatch does NOT book flights/hotels. It hands you off to the real sites
// with your destination pre-filled. No KYC, no middleman, no liability.
export interface TravelLink {
label: string;
url: string;
emoji: string;
}
export function travelLinks(destination: string): TravelLink[] {
const q = encodeURIComponent(destination);
return [
{
label: 'Flights',
emoji: '✈️',
url: `https://www.google.com/travel/flights?q=${q}`,
},
{
label: 'Stays',
emoji: '🏨',
url: `https://www.google.com/travel/search?q=${q}%20hotels`,
},
{
label: 'Getting around',
emoji: '🗺️',
url: `https://www.openstreetmap.org/search?query=${q}`,
},
];
}