diff --git a/.env.example b/.env.example
index 13ac59f..6c8896c 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,3 @@
MAPBOX_ACCESS_TOKEN=
-RNMAPBOX_MAPS_DOWNLOAD_TOKEN=
\ No newline at end of file
+RNMAPBOX_MAPS_DOWNLOAD_TOKEN=
+BIRDPLAN_DOMAIN=https://api.birdplan.app
\ No newline at end of file
diff --git a/app.config.js b/app.config.js
index f6767dd..4c7c791 100644
--- a/app.config.js
+++ b/app.config.js
@@ -4,7 +4,7 @@ module.exports = ({ config }) => ({
...(config.expo || {}),
name: "OpenBirding",
slug: "OpenBirding",
- version: "1.7.2",
+ version: "1.8.0",
orientation: "portrait",
icon: "./assets/images/logo.png",
scheme: "openbirding",
@@ -64,6 +64,7 @@ module.exports = ({ config }) => ({
router: {},
eas: { projectId: "2944a151-98b6-4d2a-9104-65facf9def35" },
MAPBOX_ACCESS_TOKEN: process.env.MAPBOX_ACCESS_TOKEN,
+ BIRDPLAN_DOMAIN: process.env.BIRDPLAN_DOMAIN,
},
updates: {
url: "https://u.expo.dev/2944a151-98b6-4d2a-9104-65facf9def35",
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 4088c26..0747e93 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -131,6 +131,24 @@ export default function RootLayout() {
headerShadowVisible: false,
}}
/>
+
+
+ {children}
+
+ );
+ }
+ return {children};
+}
+
+export default function BirdPlanImportPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [code, setCode] = useState("");
+ const [isSaving, setIsSaving] = useState(false);
+ const firstSlotRef = useRef(null);
+
+ const invalidate = useCallback(() => {
+ queryClient.invalidateQueries({ queryKey: ["trips"] });
+ queryClient.invalidateQueries({ queryKey: ["savedHotspots"] });
+ queryClient.invalidateQueries({ queryKey: ["savedPlaces"] });
+ queryClient.invalidateQueries({ queryKey: ["hotspots"] });
+ }, [queryClient]);
+
+ const performImport = useCallback(
+ async (data: BirdPlanTripData) => {
+ try {
+ setIsSaving(true);
+ await importTrip(data);
+ invalidate();
+ Toast.show({ type: "success", text1: `Imported “${data.name}”` });
+ router.back();
+ } catch {
+ Toast.show({ type: "error", text1: "Failed to save trip" });
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [invalidate, router]
+ );
+
+ const importMutation = useMutation({
+ mutationFn: async (codeValue: string) => {
+ const data = await fetchBirdPlanTrip(codeValue);
+ const existing = await getTripById(data.id);
+ return { data, existing };
+ },
+ onSuccess: async ({ data, existing }) => {
+ const proceed = () => {
+ void performImport(data);
+ };
+
+ if (existing) {
+ Alert.alert(
+ "Replace Trip Content?",
+ `This code is for “${existing.name}”, which you've already imported. All hotspots, pins, and notes from the previous import will be replaced.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ { text: "Replace", style: "destructive", onPress: proceed },
+ ]
+ );
+ } else {
+ proceed();
+ }
+ },
+ onError: (error: unknown) => {
+ Toast.show({ type: "error", text1: codeErrorMessage(error) });
+ setCode("");
+ setTimeout(() => firstSlotRef.current?.focus(), 100);
+ },
+ });
+
+ const handleSubmit = useCallback(
+ (codeValue: string) => {
+ if (codeValue.length !== CODE_LENGTH || importMutation.isPending || isSaving) return;
+ importMutation.mutate(codeValue);
+ },
+ [importMutation, isSaving]
+ );
+
+ useEffect(() => {
+ const timer = setTimeout(() => firstSlotRef.current?.focus(), 250);
+ return () => clearTimeout(timer);
+ }, []);
+
+ const hasCompleteCode = code.length === CODE_LENGTH;
+ const busy = importMutation.isPending || isSaving;
+
+ return (
+
+ Enter Code
+
+
+
+ Generate a one-time code in BirdPlan.app and enter it below.
+
+
+ handleSubmit(code)}
+ disabled={!hasCompleteCode || busy}
+ style={({ pressed }) => [
+ tw`rounded-full py-3 mt-5 items-center justify-center flex-row`,
+ hasCompleteCode && !busy ? tw`bg-blue-600` : tw`bg-gray-200`,
+ pressed && hasCompleteCode && !busy ? tw`opacity-80` : null,
+ ]}
+ >
+ {busy ? (
+
+ ) : (
+
+ Import
+
+ )}
+
+
+
+
+ );
+}
+
+type CodeInputProps = {
+ value: string;
+ onChange: (value: string) => void;
+ onComplete: (value: string) => void;
+ disabled?: boolean;
+ firstSlotRef: React.MutableRefObject;
+};
+
+function CodeInput({ value, onChange, onComplete, disabled, firstSlotRef }: CodeInputProps) {
+ const refs = useRef<(TextInput | null)[]>([]);
+
+ const handleChange = (index: number, text: string) => {
+ const digit = text.replace(/\D/g, "").slice(-1);
+ const next = value.split("");
+ next[index] = digit;
+ const joined = next.join("").slice(0, CODE_LENGTH);
+ onChange(joined);
+
+ if (digit && index < CODE_LENGTH - 1) {
+ refs.current[index + 1]?.focus();
+ }
+ if (digit && joined.length === CODE_LENGTH) {
+ Keyboard.dismiss();
+ onComplete(joined);
+ }
+ };
+
+ const handleKeyPress = (index: number, e: NativeSyntheticEvent) => {
+ if (e.nativeEvent.key === "Backspace" && !value[index] && index > 0) {
+ const next = value.split("");
+ next[index - 1] = "";
+ onChange(next.join(""));
+ refs.current[index - 1]?.focus();
+ }
+ };
+
+ const renderSlot = (index: number) => {
+ const digit = value[index] ?? "";
+ const focused = value.length === index;
+ const filled = !!digit;
+ return (
+ {
+ refs.current[index] = el;
+ if (index === 0) firstSlotRef.current = el;
+ }}
+ value={digit}
+ onChangeText={(text) => handleChange(index, text)}
+ onKeyPress={(e) => handleKeyPress(index, e)}
+ keyboardType="number-pad"
+ inputMode="numeric"
+ maxLength={1}
+ selectTextOnFocus
+ editable={!disabled}
+ textContentType="oneTimeCode"
+ style={[
+ tw`w-11 h-14 text-center bg-white rounded-lg border mx-1`,
+ {
+ fontSize: 22,
+ fontWeight: "600",
+ color: tw.color("gray-900"),
+ },
+ focused ? tw`border-blue-500` : filled ? tw`border-gray-300` : tw`border-gray-200`,
+ ]}
+ />
+ );
+ };
+
+ return (
+
+ {[0, 1, 2].map(renderSlot)}
+
+ {[3, 4, 5].map(renderSlot)}
+
+ );
+}
diff --git a/app/settings-birdplan.tsx b/app/settings-birdplan.tsx
new file mode 100644
index 0000000..cd562e0
--- /dev/null
+++ b/app/settings-birdplan.tsx
@@ -0,0 +1,249 @@
+import { BirdPlanError, fetchBirdPlanTrip } from "@/lib/birdplan";
+import { deleteTrip, getTrips, importTrip } from "@/lib/database";
+import tw from "@/lib/tw";
+import { Trip } from "@/lib/types";
+import { Ionicons } from "@expo/vector-icons";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
+import { Href, useRouter } from "expo-router";
+import React, { useCallback, useRef, useState } from "react";
+import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, Text, TouchableOpacity, View, ViewStyle } from "react-native";
+import Swipeable, { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
+import Toast from "react-native-toast-message";
+
+function Card({ children }: { children: React.ReactNode }) {
+ const useGlass = Platform.OS === "ios" && isLiquidGlassAvailable();
+ const cardStyle: ViewStyle = { borderRadius: 12, overflow: "hidden" };
+
+ if (useGlass) {
+ return (
+
+ {children}
+
+ );
+ }
+ return {children};
+}
+
+function formatMonthRange(start: number | null, end: number | null): string | null {
+ if (start == null || end == null) return null;
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+ const s = months[start - 1];
+ const e = months[end - 1];
+ if (!s || !e) return null;
+ return s === e ? s : `${s}–${e}`;
+}
+
+type TripRowProps = {
+ trip: Trip;
+ onUpdate: (trip: Trip) => void;
+ onDelete: (trip: Trip) => void;
+ isBusy: boolean;
+};
+
+function TripRow({ trip, onUpdate, onDelete, isBusy }: TripRowProps) {
+ const swipeableRef = useRef(null);
+ const monthRange = formatMonthRange(trip.start_month, trip.end_month);
+
+ const handleDelete = () => {
+ swipeableRef.current?.close();
+ onDelete(trip);
+ };
+
+ const renderRightActions = () => (
+
+
+ Delete
+
+
+ );
+
+ const content = (
+
+
+ {trip.name}
+
+ {trip.hotspot_count} hotspot{trip.hotspot_count === 1 ? "" : "s"} · {trip.marker_count} pin
+ {trip.marker_count === 1 ? "" : "s"}
+ {monthRange ? ` · ${monthRange}` : ""}
+
+
+ {isBusy ? (
+
+ ) : (
+ onUpdate(trip)}
+ style={tw`py-2 rounded-xl border border-blue-500 bg-blue-500`}
+ >
+ Update
+
+ )}
+
+ );
+
+ return (
+
+ {content}
+
+ );
+}
+
+export default function BirdPlanSettingsPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [busyTripId, setBusyTripId] = useState(null);
+
+ const { data: trips = [], isLoading } = useQuery({ queryKey: ["trips"], queryFn: getTrips });
+
+ const invalidate = useCallback(() => {
+ queryClient.invalidateQueries({ queryKey: ["trips"] });
+ queryClient.invalidateQueries({ queryKey: ["savedHotspots"] });
+ queryClient.invalidateQueries({ queryKey: ["savedPlaces"] });
+ queryClient.invalidateQueries({ queryKey: ["hotspots"] });
+ }, [queryClient]);
+
+ const deleteMutation = useMutation({
+ mutationFn: async (trip: Trip) => {
+ setBusyTripId(trip.id);
+ await deleteTrip(trip.id);
+ return trip;
+ },
+ onSuccess: (trip) => {
+ invalidate();
+ Toast.show({ type: "success", text1: `Removed "${trip.name}"` });
+ },
+ onError: (error: Error) => {
+ Toast.show({ type: "error", text1: error.message || "Delete failed" });
+ },
+ onSettled: () => setBusyTripId(null),
+ });
+
+ const refreshMutation = useMutation({
+ mutationFn: async (trip: Trip) => {
+ if (!trip.update_token) throw new Error("NO_TOKEN");
+ setBusyTripId(trip.id);
+ const data = await fetchBirdPlanTrip(trip.update_token);
+ await importTrip(data);
+ return data;
+ },
+ onSuccess: (data) => {
+ invalidate();
+ Toast.show({ type: "success", text1: `Updated "${data.name}"` });
+ },
+ onError: (error: unknown, trip) => {
+ if (error instanceof BirdPlanError && error.status === 404) {
+ Alert.alert(
+ "Trip Unavailable",
+ `"${trip.name}" is no longer available on BirdPlan.app. You can delete it, or re-import with a fresh code.`
+ );
+ return;
+ }
+ if (error instanceof BirdPlanError) {
+ Toast.show({ type: "error", text1: error.message });
+ return;
+ }
+ Toast.show({ type: "error", text1: "Update failed" });
+ },
+ onSettled: () => setBusyTripId(null),
+ });
+
+ const goToImport = () => router.push("/settings-birdplan-import" as Href);
+
+ const handleUpdate = (trip: Trip) => {
+ if (!trip.update_token) {
+ Alert.alert(
+ "Update Trip",
+ `"${trip.name}" was imported before automatic updates were supported. Generate a fresh code in BirdPlan.app to re-import.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ { text: "Enter Code", onPress: goToImport },
+ ]
+ );
+ return;
+ }
+ Alert.alert(
+ "Update Trip",
+ `Fetch the latest data for "${trip.name}"? Existing hotspots, pins, and notes from this trip will be replaced.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Update",
+ style: "destructive",
+ onPress: () => refreshMutation.mutate(trip),
+ },
+ ]
+ );
+ };
+
+ const handleDelete = (trip: Trip) => {
+ Alert.alert(
+ "Delete Trip",
+ `Remove "${trip.name}" and all hotspots, pins, and notes imported with it? This cannot be undone.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: () => deleteMutation.mutate(trip),
+ },
+ ]
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ Import Trip
+
+
+
+
+
+ router.push("/packs" as Href)}
+ activeOpacity={0.7}
+ style={tw`bg-amber-50 rounded-xl px-4 py-3 mb-6 flex-row items-center border border-amber-200`}
+ >
+
+
+ Hotspot packs must be installed for the areas your trips cover.
+
+
+
+
+
+ {trips.length > 0 ? `Imported Trips (${trips.length})` : "Imported Trips"}
+
+
+ {isLoading ? (
+
+
+
+ ) : trips.length === 0 ? (
+
+ No trips imported yet
+
+ ) : (
+ trips.map((trip) => (
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/app/settings.tsx b/app/settings.tsx
index c7b4e55..0ce4496 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -1,3 +1,4 @@
+import BirdPlanLogo from "@/components/icons/BirdPlanLogo";
import tw from "@/lib/tw";
import { getExternalMapProviders } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -33,14 +34,19 @@ type SettingsRowProps = {
onPress: () => void;
isLast?: boolean;
icon?: SettingsIconProps | undefined;
+ iconElement?: React.ReactNode;
};
-function SettingsRow({ label, value, onPress, isLast, icon }: SettingsRowProps) {
+function SettingsRow({ label, value, onPress, isLast, icon, iconElement }: SettingsRowProps) {
const borderStyle = isLast ? {} : tw`border-b border-gray-200/50`;
return (
- {icon && }
+ {iconElement ? (
+ {iconElement}
+ ) : icon ? (
+
+ ) : null}
{label}
{value && {value}}
@@ -181,6 +187,15 @@ export default function SettingsPage() {
/>
+
+ router.push("/settings-birdplan" as Href)}
+ iconElement={}
+ isLast
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/lib/birdplan.ts b/lib/birdplan.ts
new file mode 100644
index 0000000..457e8a2
--- /dev/null
+++ b/lib/birdplan.ts
@@ -0,0 +1,37 @@
+import Constants from "expo-constants";
+import { BirdPlanTripData } from "./types";
+
+const BIRDPLAN_DOMAIN = (Constants.expoConfig?.extra?.BIRDPLAN_DOMAIN as string | undefined) ?? "https://api.birdplan.app";
+
+export class BirdPlanError extends Error {
+ constructor(
+ message: string,
+ public status: number
+ ) {
+ super(message);
+ this.name = "BirdPlanError";
+ }
+}
+
+export async function fetchBirdPlanTrip(codeOrToken: string): Promise {
+ const res = await fetch(`${BIRDPLAN_DOMAIN}/v1/trips/openbirding/${codeOrToken}`);
+
+ if (!res.ok) {
+ if (res.status === 404) {
+ throw new BirdPlanError("Not found", 404);
+ }
+ if (res.status === 410) {
+ throw new BirdPlanError("This code has expired", 410);
+ }
+ if (res.status === 429) {
+ throw new BirdPlanError("Too many requests — try again in a moment", 429);
+ }
+ throw new BirdPlanError("Unable to reach BirdPlan. Check your connection and try again.", res.status);
+ }
+
+ const data = (await res.json()) as BirdPlanTripData;
+ if (!data?.id || !Array.isArray(data.hotspots) || !Array.isArray(data.markers)) {
+ throw new BirdPlanError("Unexpected response from BirdPlan", 0);
+ }
+ return data;
+}
diff --git a/lib/database.ts b/lib/database.ts
index 7e92e0b..f7b8ecc 100644
--- a/lib/database.ts
+++ b/lib/database.ts
@@ -1,5 +1,5 @@
import * as SQLite from "expo-sqlite";
-import { SavedPlace, StaticPackHotspot, StaticPackTarget } from "./types";
+import { BirdPlanTripData, SavedPlace, StaticPackHotspot, StaticPackTarget, Trip } from "./types";
let db: SQLite.SQLiteDatabase | null = null;
let isInstallingPack = false;
@@ -8,8 +8,8 @@ export async function initializeDatabase(): Promise {
try {
db = await SQLite.openDatabaseAsync("openbirding.db");
await createTables();
- await createIndexes();
await runMigrations();
+ await createIndexes();
console.log("Database initialized successfully");
} catch (error) {
console.error("Failed to initialize database:", error);
@@ -49,12 +49,15 @@ async function createTables(): Promise {
);
`);
+ // Foreign keys are intentionally not enforced (PRAGMA foreign_keys is OFF).
+ // This allows saved_hotspots to reference hotspot IDs that may not yet exist
+ // in the hotspots table (e.g. trip imports), and survive pack uninstalls.
await db.execAsync(`
CREATE TABLE IF NOT EXISTS saved_hotspots (
hotspot_id TEXT PRIMARY KEY NOT NULL,
saved_at TEXT NOT NULL,
notes TEXT,
- FOREIGN KEY (hotspot_id) REFERENCES hotspots (id) ON DELETE CASCADE
+ trip_id TEXT
);
`);
@@ -66,7 +69,8 @@ async function createTables(): Promise {
icon TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
- saved_at TEXT NOT NULL
+ saved_at TEXT NOT NULL,
+ trip_id TEXT
);
`);
@@ -84,9 +88,27 @@ async function createTables(): Promise {
hotspot_id TEXT NOT NULL,
code TEXT NOT NULL,
pinned_at TEXT NOT NULL,
+ trip_id TEXT,
PRIMARY KEY (hotspot_id, code)
);
`);
+
+ await db.execAsync(`
+ CREATE TABLE IF NOT EXISTS trips (
+ id TEXT PRIMARY KEY NOT NULL,
+ type TEXT NOT NULL DEFAULT 'birdplan',
+ name TEXT NOT NULL,
+ start_month INTEGER,
+ end_month INTEGER,
+ min_lat REAL,
+ max_lat REAL,
+ min_lng REAL,
+ max_lng REAL,
+ imported_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ update_token TEXT
+ );
+ `);
}
async function createIndexes(): Promise {
@@ -116,6 +138,21 @@ async function createIndexes(): Promise {
CREATE INDEX IF NOT EXISTS idx_targets_pack_id
ON targets (pack_id);
`);
+
+ await db.execAsync(`
+ CREATE INDEX IF NOT EXISTS idx_saved_hotspots_trip_id
+ ON saved_hotspots (trip_id);
+ `);
+
+ await db.execAsync(`
+ CREATE INDEX IF NOT EXISTS idx_saved_places_trip_id
+ ON saved_places (trip_id);
+ `);
+
+ await db.execAsync(`
+ CREATE INDEX IF NOT EXISTS idx_pinned_targets_trip_id
+ ON pinned_targets (trip_id);
+ `);
}
async function runMigrations(): Promise {
@@ -130,6 +167,34 @@ async function runMigrations(): Promise {
if (!packsColumns.includes("updated_at")) {
await db.execAsync(`ALTER TABLE packs ADD COLUMN updated_at TEXT`);
}
+
+ const savedHotspotsTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(saved_hotspots)");
+ const savedHotspotsColumns = savedHotspotsTableInfo.map((col) => col.name);
+ if (!savedHotspotsColumns.includes("trip_id")) {
+ await db.execAsync(`ALTER TABLE saved_hotspots ADD COLUMN trip_id TEXT`);
+ }
+
+ const savedPlacesTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(saved_places)");
+ const savedPlacesColumns = savedPlacesTableInfo.map((col) => col.name);
+ if (!savedPlacesColumns.includes("trip_id")) {
+ await db.execAsync(`ALTER TABLE saved_places ADD COLUMN trip_id TEXT`);
+ }
+
+ const pinnedTargetsTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(pinned_targets)");
+ const pinnedTargetsColumns = pinnedTargetsTableInfo.map((col) => col.name);
+ if (!pinnedTargetsColumns.includes("trip_id")) {
+ await db.execAsync(`ALTER TABLE pinned_targets ADD COLUMN trip_id TEXT`);
+ }
+
+ const tripsTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(trips)");
+ const tripsColumns = tripsTableInfo.map((col) => col.name);
+ if (tripsColumns.length > 0 && !tripsColumns.includes("update_token")) {
+ await db.execAsync(`ALTER TABLE trips ADD COLUMN update_token TEXT`);
+ }
+ if (tripsColumns.length > 0 && !tripsColumns.includes("type")) {
+ await db.execAsync(`ALTER TABLE trips ADD COLUMN type TEXT NOT NULL DEFAULT 'birdplan'`);
+ }
+
}
export function getDatabase(): SQLite.SQLiteDatabase {
@@ -233,12 +298,15 @@ export async function getPackById(id: number): Promise<{
export async function saveHotspot(hotspotId: string, notes?: string): Promise {
if (!db) throw new Error("Database not initialized");
+ const existing = await db.getFirstAsync<{ trip_id: string | null }>(
+ `SELECT trip_id FROM saved_hotspots WHERE hotspot_id = ?`,
+ [hotspotId]
+ );
const savedAt = new Date().toISOString();
- await db.runAsync(`INSERT OR REPLACE INTO saved_hotspots (hotspot_id, saved_at, notes) VALUES (?, ?, ?)`, [
- hotspotId,
- savedAt,
- notes || null,
- ]);
+ await db.runAsync(
+ `INSERT OR REPLACE INTO saved_hotspots (hotspot_id, saved_at, notes, trip_id) VALUES (?, ?, ?, ?)`,
+ [hotspotId, savedAt, notes || null, existing?.trip_id ?? null]
+ );
}
export async function unsaveHotspot(hotspotId: string): Promise {
@@ -406,10 +474,14 @@ export async function cleanupPartialInstall(packId: number): Promise {
export async function savePlace({ id, name, notes, icon, lat, lng }: Omit): Promise {
if (!db) throw new Error("Database not initialized");
+ const existing = await db.getFirstAsync<{ trip_id: string | null }>(
+ `SELECT trip_id FROM saved_places WHERE id = ?`,
+ [id]
+ );
const savedAt = new Date().toISOString();
await db.runAsync(
- `INSERT OR REPLACE INTO saved_places (id, name, notes, icon, lat, lng, saved_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
- [id, name, notes, icon, lat, lng, savedAt]
+ `INSERT OR REPLACE INTO saved_places (id, name, notes, icon, lat, lng, saved_at, trip_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [id, name, notes, icon, lat, lng, savedAt, existing?.trip_id ?? null]
);
return id;
@@ -625,9 +697,13 @@ export async function getPinnedTargets(hotspotId: string): Promise {
export async function pinTarget(hotspotId: string, speciesCode: string): Promise {
if (!db) throw new Error("Database not initialized");
+ const existing = await db.getFirstAsync<{ trip_id: string | null }>(
+ `SELECT trip_id FROM pinned_targets WHERE hotspot_id = ? AND code = ?`,
+ [hotspotId, speciesCode]
+ );
await db.runAsync(
- `INSERT OR REPLACE INTO pinned_targets (hotspot_id, code, pinned_at) VALUES (?, ?, ?)`,
- [hotspotId, speciesCode, new Date().toISOString()]
+ `INSERT OR REPLACE INTO pinned_targets (hotspot_id, code, pinned_at, trip_id) VALUES (?, ?, ?, ?)`,
+ [hotspotId, speciesCode, new Date().toISOString(), existing?.trip_id ?? null]
);
}
@@ -635,3 +711,117 @@ export async function unpinTarget(hotspotId: string, speciesCode: string): Promi
if (!db) throw new Error("Database not initialized");
await db.runAsync(`DELETE FROM pinned_targets WHERE hotspot_id = ? AND code = ?`, [hotspotId, speciesCode]);
}
+
+export async function getTrips(): Promise {
+ if (!db) throw new Error("Database not initialized");
+
+ const rows = await db.getAllAsync(
+ `SELECT
+ t.id, t.type, t.name, t.start_month, t.end_month,
+ t.min_lat, t.max_lat, t.min_lng, t.max_lng,
+ t.imported_at, t.updated_at, t.update_token,
+ (SELECT COUNT(*) FROM saved_hotspots WHERE trip_id = t.id) AS hotspot_count,
+ (SELECT COUNT(*) FROM saved_places WHERE trip_id = t.id) AS marker_count
+ FROM trips t
+ ORDER BY t.imported_at DESC`
+ );
+
+ return rows;
+}
+
+export async function getTripById(tripId: string): Promise {
+ if (!db) throw new Error("Database not initialized");
+
+ const row = await db.getFirstAsync(
+ `SELECT
+ t.id, t.type, t.name, t.start_month, t.end_month,
+ t.min_lat, t.max_lat, t.min_lng, t.max_lng,
+ t.imported_at, t.updated_at, t.update_token,
+ (SELECT COUNT(*) FROM saved_hotspots WHERE trip_id = t.id) AS hotspot_count,
+ (SELECT COUNT(*) FROM saved_places WHERE trip_id = t.id) AS marker_count
+ FROM trips t
+ WHERE t.id = ?`,
+ [tripId]
+ );
+
+ return row ?? null;
+}
+
+export async function importTrip(data: BirdPlanTripData): Promise {
+ if (!db) throw new Error("Database not initialized");
+
+ const database = db;
+ const now = new Date().toISOString();
+ const existing = await database.getFirstAsync<{ imported_at: string; update_token: string | null }>(
+ `SELECT imported_at, update_token FROM trips WHERE id = ?`,
+ [data.id]
+ );
+ const importedAt = existing?.imported_at ?? now;
+ // On initial import we receive a fresh updateToken. Refresh responses omit it — keep the one we already have.
+ const updateToken = data.updateToken ?? existing?.update_token ?? null;
+
+ await database.withTransactionAsync(async () => {
+ // Remove any existing trip content so we start fresh.
+ await deleteTripContent(database, data.id);
+
+ await database.runAsync(
+ `INSERT OR REPLACE INTO trips
+ (id, type, name, start_month, end_month, min_lat, max_lat, min_lng, max_lng, imported_at, updated_at, update_token)
+ VALUES (?, 'birdplan', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ data.id,
+ data.name,
+ data.startMonth ?? null,
+ data.endMonth ?? null,
+ data.bounds?.minY ?? null,
+ data.bounds?.maxY ?? null,
+ data.bounds?.minX ?? null,
+ data.bounds?.maxX ?? null,
+ importedAt,
+ now,
+ updateToken,
+ ]
+ );
+
+ for (const hotspot of data.hotspots) {
+ await database.runAsync(
+ `INSERT OR REPLACE INTO saved_hotspots (hotspot_id, saved_at, notes, trip_id) VALUES (?, ?, ?, ?)`,
+ [hotspot.id, now, hotspot.notes?.trim() || null, data.id]
+ );
+
+ if (hotspot.favs?.length) {
+ for (const fav of hotspot.favs) {
+ await database.runAsync(
+ `INSERT OR REPLACE INTO pinned_targets (hotspot_id, code, pinned_at, trip_id) VALUES (?, ?, ?, ?)`,
+ [hotspot.id, fav.code, now, data.id]
+ );
+ }
+ }
+ }
+
+ for (const marker of data.markers) {
+ const placeId = `${data.id}_${marker.id}`;
+ await database.runAsync(
+ `INSERT OR REPLACE INTO saved_places (id, name, notes, icon, lat, lng, saved_at, trip_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [placeId, marker.name, marker.notes?.trim() || null, marker.icon, marker.lat, marker.lng, now, data.id]
+ );
+ }
+ });
+}
+
+async function deleteTripContent(database: SQLite.SQLiteDatabase, tripId: string): Promise {
+ await database.runAsync(`DELETE FROM saved_places WHERE trip_id = ?`, [tripId]);
+ await database.runAsync(`DELETE FROM pinned_targets WHERE trip_id = ?`, [tripId]);
+ await database.runAsync(`DELETE FROM saved_hotspots WHERE trip_id = ?`, [tripId]);
+}
+
+export async function deleteTrip(tripId: string): Promise {
+ if (!db) throw new Error("Database not initialized");
+
+ const database = db;
+ await database.withTransactionAsync(async () => {
+ await deleteTripContent(database, tripId);
+ await database.runAsync(`DELETE FROM trips WHERE id = ?`, [tripId]);
+ });
+}
diff --git a/lib/types.ts b/lib/types.ts
index c56df34..183656f 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -89,3 +89,64 @@ export type Hotspot = {
species: number;
country: string | null;
};
+
+export type TripType = "birdplan";
+
+export type Trip = {
+ id: string;
+ type: TripType;
+ name: string;
+ start_month: number | null;
+ end_month: number | null;
+ min_lat: number | null;
+ max_lat: number | null;
+ min_lng: number | null;
+ max_lng: number | null;
+ imported_at: string;
+ updated_at: string;
+ update_token: string | null;
+ hotspot_count: number;
+ marker_count: number;
+};
+
+export type BirdPlanTripFav = {
+ name: string;
+ code: string;
+ range: string;
+ percent: number;
+};
+
+export type BirdPlanTripHotspot = {
+ id: string;
+ name: string;
+ lat: number;
+ lng: number;
+ species: number;
+ notes: string | null;
+ favs?: BirdPlanTripFav[];
+};
+
+export type BirdPlanTripMarker = {
+ id: string;
+ name: string;
+ lat: number;
+ lng: number;
+ icon: string;
+ notes: string | null;
+};
+
+export type BirdPlanTripData = {
+ id: string;
+ name: string;
+ startMonth: number;
+ endMonth: number;
+ bounds: {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ };
+ hotspots: BirdPlanTripHotspot[];
+ markers: BirdPlanTripMarker[];
+ updateToken?: string;
+};