diff --git a/backend/lib/utils.ts b/backend/lib/utils.ts index c56a351c..1e5b1f7f 100644 --- a/backend/lib/utils.ts +++ b/backend/lib/utils.ts @@ -16,6 +16,14 @@ export const newInviteToken = () => ({ export const isDuplicateKeyError = (err: unknown): boolean => (err as { code?: number })?.code === 11000; +export const validateTripDates = (data: { startDate?: string; endDate?: string }): void => { + if (!data.startDate) throw new HTTPException(400, { message: "A start date is required" }); + if (!data.endDate) throw new HTTPException(400, { message: "An end date is required" }); + if (data.endDate < data.startDate) { + throw new HTTPException(400, { message: "End date must be on or after the start date" }); + } +}; + function getBearerToken(c: Context): string { const authHeader = c.req.header("authorization"); if (!authHeader?.startsWith("Bearer ")) return ""; diff --git a/backend/models/Trip.ts b/backend/models/Trip.ts index b6a474b7..a4f92ab8 100644 --- a/backend/models/Trip.ts +++ b/backend/models/Trip.ts @@ -87,6 +87,7 @@ const fields: Record< }, ], startDate: { type: String, default: null }, + endDate: { type: String, default: null }, startMonth: { type: Number, required: true }, endMonth: { type: Number, required: true }, imgUrl: { type: String, default: null }, diff --git a/backend/routes/trips/[tripId]/index.ts b/backend/routes/trips/[tripId]/index.ts index 864c2ecc..c7bdd7ed 100644 --- a/backend/routes/trips/[tripId]/index.ts +++ b/backend/routes/trips/[tripId]/index.ts @@ -10,6 +10,7 @@ import { generateOpenBirdingCode, getBounds, isDuplicateKeyError, + validateTripDates, } from "lib/utils.js"; import { connect, Trip, Participant, User, IntegrationToken } from "lib/db.js"; import { @@ -93,10 +94,13 @@ trip.patch("/", async (c) => { } const data = await c.req.json(); + validateTripDates(data); const newData: Record = { name: data.name, region: data.region, + startDate: data.startDate ?? null, + endDate: data.endDate ?? null, startMonth: data.startMonth, endMonth: data.endMonth, }; @@ -264,28 +268,4 @@ trip.post("/share-code", async (c) => { throw new HTTPException(500, { message: "Failed to generate share code" }); }); -trip.patch("/set-start-date", async (c) => { - const session = await authenticate(c); - const tripId: string | undefined = c.req.param("tripId"); - - if (!tripId) { - throw new HTTPException(400, { message: "Trip ID is required" }); - } - - const { startDate } = await c.req.json<{ startDate: string }>(); - - await connect(); - const trip = await Trip.findById(tripId).lean(); - if (!trip) { - throw new HTTPException(404, { message: "Trip not found" }); - } - if (!(await isTripEditor(tripId, session.userId))) { - throw new HTTPException(403, { message: "Forbidden" }); - } - - await Trip.updateOne({ _id: tripId }, { startDate }); - - return c.json({}); -}); - export default trip; diff --git a/backend/routes/trips/index.ts b/backend/routes/trips/index.ts index 12f048e0..9490c562 100644 --- a/backend/routes/trips/index.ts +++ b/backend/routes/trips/index.ts @@ -2,11 +2,39 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { rateLimiter } from "hono-rate-limiter"; import trip from "./[tripId]/index.js"; -import { authenticate, getBounds } from "lib/utils.js"; +import { authenticate, getBounds, validateTripDates } from "lib/utils.js"; import { connect, Trip, Participant, IntegrationToken, User } from "lib/db.js"; import { uploadMapboxImageToStorage, imageUrl } from "lib/storage.js"; import { SHARE_CODE_TTL_MINUTES } from "lib/config.js"; -import type { TripInput } from "@birdplan/shared"; +import type { TripInput, ParticipantView, TripStats, TripListItem, TripListPage } from "@birdplan/shared"; + +type ParticipantAvatar = Pick; + +type TripListRow = { + _id: string; + name: string; + region: string; + imgUrl: string | null; + startDate?: string; + endDate?: string; + startMonth: number; + endMonth: number; + createdAt: Date; + hotspotCount: number; +}; + +const encodeCursor = (createdAt: Date, id: string): string => + Buffer.from(JSON.stringify({ c: new Date(createdAt).toISOString(), i: id })).toString("base64url"); + +const decodeCursor = (cursor: string): { c: string; i: string } | null => { + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString()); + if (typeof parsed?.c === "string" && typeof parsed?.i === "string") return parsed; + return null; + } catch { + return null; + } +}; const shareCodeLimiter = rateLimiter({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -91,23 +119,138 @@ trips.get("/openbirding/:codeOrToken", shareCodeLimiter, async (c) => { return c.json(serializeTripForImport(trip)); }); +trips.get("/stats", async (c) => { + const session = await authenticate(c); + + await connect(); + const tripIds = await Participant.find({ userId: session.userId, status: "active" }).distinct("tripId"); + + const [stats] = await Trip.aggregate([ + { $match: { _id: { $in: tripIds } } }, + { + $project: { + hotspotCount: { $size: { $ifNull: ["$hotspots", []] } }, + countries: { + $filter: { + input: { + $map: { + input: { $split: [{ $ifNull: ["$region", ""] }, ","] }, + as: "code", + in: { $arrayElemAt: [{ $split: ["$$code", "-"] }, 0] }, + }, + }, + as: "code", + cond: { $ne: ["$$code", ""] }, + }, + }, + }, + }, + { + $group: { + _id: null, + tripCount: { $sum: 1 }, + hotspotTotal: { $sum: "$hotspotCount" }, + countrySets: { $push: "$countries" }, + }, + }, + { + $project: { + _id: 0, + tripCount: 1, + hotspotTotal: 1, + countryCount: { + $size: { + $reduce: { + input: "$countrySets", + initialValue: [], + in: { $setUnion: ["$$value", "$$this"] }, + }, + }, + }, + }, + }, + ]); + + return c.json(stats ?? { tripCount: 0, hotspotTotal: 0, countryCount: 0 }); +}); + trips.route("/:tripId", trip); trips.get("/", async (c) => { const session = await authenticate(c); await connect(); + const limit = Math.min(Math.max(Number(c.req.query("limit")) || 12, 1), 50); + const cursorParam = c.req.query("cursor"); + const cursor = cursorParam ? decodeCursor(cursorParam) : null; + const tripIds = await Participant.find({ userId: session.userId, status: "active" }).distinct("tripId"); - const trips = await Trip.find({ _id: { $in: tripIds } }) - .sort({ createdAt: -1 }) + + const match: Record = { _id: { $in: tripIds } }; + if (cursor) { + const cursorDate = new Date(cursor.c); + match.$or = [{ createdAt: { $lt: cursorDate } }, { createdAt: cursorDate, _id: { $lt: cursor.i } }]; + } + + const docs = await Trip.aggregate([ + { $match: match }, + { $sort: { createdAt: -1, _id: -1 } }, + { $limit: limit + 1 }, + { + $project: { + name: 1, + region: 1, + imgUrl: 1, + startDate: 1, + endDate: 1, + startMonth: 1, + endMonth: 1, + createdAt: 1, + hotspotCount: { $size: { $ifNull: ["$hotspots", []] } }, + }, + }, + ]); + + const hasMore = docs.length > limit; + const page = hasMore ? docs.slice(0, limit) : docs; + const last = page[page.length - 1]; + const nextCursor = hasMore && last ? encodeCursor(last.createdAt, last._id) : null; + + const pageTripIds = page.map((t) => t._id); + const roster = await Participant.find({ tripId: { $in: pageTripIds }, status: "active" }) + .sort({ createdAt: 1 }) .lean(); - return c.json(trips.map((trip) => ({ ...trip, imgUrl: imageUrl(trip.imgUrl) }))); + const userIds = roster.map((p) => p.userId).filter((u): u is string => !!u); + const users = userIds.length ? await User.find({ _id: { $in: userIds } }).select("photoUrl").lean() : []; + const photoByUser = new Map(users.map((u) => [u._id, u.photoUrl])); + const participantsByTrip = new Map(); + for (const p of roster) { + const list = participantsByTrip.get(p.tripId) ?? []; + list.push({ _id: p._id, userId: p.userId, name: p.name, photoUrl: p.userId ? photoByUser.get(p.userId) : undefined }); + participantsByTrip.set(p.tripId, list); + } + + const items: TripListItem[] = page.map((trip) => ({ + _id: trip._id, + name: trip.name, + region: trip.region, + imgUrl: imageUrl(trip.imgUrl), + startDate: trip.startDate, + endDate: trip.endDate, + startMonth: trip.startMonth, + endMonth: trip.endMonth, + hotspotCount: trip.hotspotCount, + participants: participantsByTrip.get(trip._id) ?? [], + })); + + return c.json({ trips: items, nextCursor } satisfies TripListPage); }); trips.post("/", async (c) => { const session = await authenticate(c); const data = await c.req.json(); + validateTripDates(data); const bounds = await getBounds(data.region); if (!bounds) { diff --git a/frontend/components/TripCard.tsx b/frontend/components/TripCard.tsx index 24f9fcbe..4e613763 100644 --- a/frontend/components/TripCard.tsx +++ b/frontend/components/TripCard.tsx @@ -1,40 +1,81 @@ import { Link } from "react-router-dom"; -import { Trip } from "@birdplan/shared"; +import { TripListItem } from "@birdplan/shared"; import ReactCountryFlag from "react-country-flag"; +import Avatar from "components/Avatar"; +import { formatTripDateRange, tripDurationDays, tripDaysUntilStart } from "lib/helpers"; type Props = { - trip: Trip; + trip: TripListItem; }; +const MAX_AVATARS = 5; + export default function TripCard({ trip }: Props) { - const { _id, name, hotspots } = trip; + const { _id, name, region, imgUrl, hotspotCount, participants = [] } = trip; + const durationDays = tripDurationDays(trip); + const daysUntilStart = tripDaysUntilStart(trip); + const participantCount = participants.length || 1; + const shownAvatars = participants.slice(0, MAX_AVATARS); + const extraAvatars = participants.length - shownAvatars.length; return ( - -
- {trip?.imgUrl && ( +
+ + {imgUrl && ( )} -
-
-

{name}

- + {daysUntilStart !== null && ( +
+ {daysUntilStart === 0 ? "Today" : `In ${daysUntilStart} ${daysUntilStart === 1 ? "day" : "days"}`}
-

- {hotspots.length} {hotspots.length === 1 ? "saved hotspot" : "saved hotspots"} + )} + +

+ +

{name}

+ +
+ +

+ {formatTripDateRange(trip)} + {durationDays && ` · ${durationDays} ${durationDays === 1 ? "day" : "days"}`} +

+
+
+

+ {hotspotCount} hotspots  ·  + {participantCount}{" "} + {participantCount === 1 ? "participant" : "participants"}

+ {participants.length > 0 && ( +
+ {shownAvatars.map((p) => ( + + ))} + {extraAvatars > 0 && ( +
+ +{extraAvatars} +
+ )} +
+ )}
- +
); } diff --git a/frontend/components/WidgetHeader.tsx b/frontend/components/WidgetHeader.tsx new file mode 100644 index 00000000..101b5ac4 --- /dev/null +++ b/frontend/components/WidgetHeader.tsx @@ -0,0 +1,19 @@ +import { Link } from "react-router-dom"; + +type Props = { + title: string; + action?: { label: string; to: string }; +}; + +export default function WidgetHeader({ title, action }: Props) { + return ( +
+

{title}

+ {action && ( + + {action.label} + + )} +
+ ); +} diff --git a/frontend/data/news.json b/frontend/data/news.json new file mode 100644 index 00000000..895ece11 --- /dev/null +++ b/frontend/data/news.json @@ -0,0 +1,12 @@ +[ + { + "date": "2026-06-24", + "title": "Simpler email sign-in", + "description": "We've switched to a simpler email sign-in that sends a one-time code instead of using a password, streamlining authentication and reducing our reliance on external services." + }, + { + "date": "2026-06-19", + "title": "Group trips overhaul", + "description": "Everyone on a trip now keeps their own life list, and BirdPlan tracks targets for the whole group, including the mutual targets everyone still needs." + } +] diff --git a/frontend/hooks/useTrip.ts b/frontend/hooks/useTrip.ts index 10ff4f5c..e934e963 100644 --- a/frontend/hooks/useTrip.ts +++ b/frontend/hooks/useTrip.ts @@ -4,7 +4,7 @@ import { Trip, ParticipantView } from "@birdplan/shared"; import { useLocation } from "react-router-dom"; import { useUser } from "hooks/useUser"; import { useSessionToken } from "lib/sessionToken"; -import { fullMonths, months, getTripIdFromPath } from "lib/helpers"; +import { formatMonthRange, getTripIdFromPath } from "lib/helpers"; import { useQuery } from "@tanstack/react-query"; type SelectedSpecies = { @@ -82,12 +82,7 @@ export const useTrip = () => { const ui = useTripUiStore(); const is404 = !!token && !!id && !trip && !isLoading; - const dateRangeLabel = - trip?.startMonth && trip?.endMonth - ? trip.startMonth === trip.endMonth - ? fullMonths[trip.startMonth - 1] - : `${months[trip.startMonth - 1]} - ${months[trip.endMonth - 1]}` - : ""; + const dateRangeLabel = trip?.startMonth && trip?.endMonth ? formatMonthRange(trip.startMonth, trip.endMonth) : ""; return { trip: trip || null, diff --git a/frontend/lib/helpers.ts b/frontend/lib/helpers.ts index 70ececb1..624cd0ee 100644 --- a/frontend/lib/helpers.ts +++ b/frontend/lib/helpers.ts @@ -25,6 +25,36 @@ export const fullMonths = [ "December", ]; +export const formatMonthRange = (startMonth: number, endMonth: number): string => + startMonth === endMonth ? fullMonths[startMonth - 1] : `${months[startMonth - 1]} – ${months[endMonth - 1]}`; + +export const formatTripDateRange = ( + trip: Pick +): string => { + if (!trip.startDate) return formatMonthRange(trip.startMonth, trip.endMonth); + const start = dayjs(trip.startDate); + const end = trip.endDate ? dayjs(trip.endDate) : start; + if (start.isSame(end, "day")) return start.format("MMM D, YYYY"); + if (!start.isSame(end, "year")) return `${start.format("MMM D, YYYY")} – ${end.format("MMM D, YYYY")}`; + if (!start.isSame(end, "month")) return `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`; + return `${start.format("MMM D")} – ${end.format("D, YYYY")}`; +}; + +export const tripDurationDays = (trip: Pick): number | null => { + if (!trip.startDate) return null; + const start = dayjs(trip.startDate).startOf("day"); + const end = (trip.endDate ? dayjs(trip.endDate) : start).startOf("day"); + return end.diff(start, "day") + 1; +}; + +export const tripDaysUntilStart = (trip: Pick): number | null => { + if (!trip.startDate) return null; + const today = dayjs().startOf("day"); + const end = (trip.endDate ? dayjs(trip.endDate) : dayjs(trip.startDate)).startOf("day"); + if (end.isBefore(today)) return null; + return Math.max(0, dayjs(trip.startDate).startOf("day").diff(today, "day")); +}; + export const englishCountries = [ "US", "CA", diff --git a/frontend/main.tsx b/frontend/main.tsx index 3bb32952..1c18d201 100644 --- a/frontend/main.tsx +++ b/frontend/main.tsx @@ -11,7 +11,7 @@ import { teardownSession, IDB_CACHE_KEY } from "lib/logout"; import ErrorBoundary from "components/ErrorBoundary"; import { router } from "router"; -const QUERY_CACHE_BUSTER = "birdplan-cache-v2"; +const QUERY_CACHE_BUSTER = "birdplan-cache-v3"; const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/pages/[tripId]/itinerary.tsx b/frontend/pages/[tripId]/itinerary.tsx index 3edde419..b4db082b 100644 --- a/frontend/pages/[tripId]/itinerary.tsx +++ b/frontend/pages/[tripId]/itinerary.tsx @@ -3,10 +3,8 @@ import Header from "components/Header"; import TripNav from "components/TripNav"; import { useUser } from "hooks/useUser"; import ErrorBoundary from "components/ErrorBoundary"; -import Input from "components/Input"; import Button from "components/Button"; import { useTrip } from "hooks/useTrip"; -import toast from "react-hot-toast"; import { useModal } from "stores/modals"; import Icon from "components/Icon"; import NotFound from "components/NotFound"; @@ -19,7 +17,6 @@ export default function Itinerary() { const { is404, trip, canEdit } = useTrip(); const { close, modalId } = useModal(); const hasStartDate = !!trip?.startDate; - const [editingStartDate, setEditingStartDate] = React.useState(false); const shouldDefaultEdit = !!(trip && !trip?.startDate) || !!(trip && !trip?.itinerary?.length); const [editing, setEditing] = React.useState(shouldDefaultEdit); const [prevShouldDefaultEdit, setPrevShouldDefaultEdit] = React.useState(shouldDefaultEdit); @@ -30,15 +27,6 @@ export default function Itinerary() { if (shouldDefaultEdit) setEditing(true); } - const setStartDateMutation = useTripMutation<{ startDate: string }>({ - url: `/trips/${trip?._id}/set-start-date`, - method: "PATCH", - updateCache: (old, input) => ({ - ...old, - startDate: input.startDate, - }), - }); - const addDayMutation = useTripMutation<{ id: string; locations: any[] }>({ url: `/trips/${trip?._id}/itinerary`, method: "POST", @@ -48,16 +36,6 @@ export default function Itinerary() { }), }); - const submitStartDate = (e: React.FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const date = form.date.value; - if (!date) return toast.error("Please choose a date"); - setStartDateMutation.mutate({ startDate: date }); - setEditingStartDate(false); - setEditing(true); - }; - const handleAddDay = () => { addDayMutation.mutate({ id: nanoId(6), locations: [] }); }; @@ -103,31 +81,17 @@ export default function Itinerary() { )}
- {canEdit && !!trip?.startDate && !editingStartDate && ( - - )}
- {canEdit && (!trip?.startDate || editingStartDate) && ( + {canEdit && !trip?.startDate && (
-

Choose start date

-
- - -
+

Set your trip dates

+

+ Add a start date in trip settings to build your day-by-day itinerary. +

+
)} {!canEdit && !trip?.startDate && ( diff --git a/frontend/pages/[tripId]/settings.tsx b/frontend/pages/[tripId]/settings.tsx index d0e292e3..a55a8be8 100644 --- a/frontend/pages/[tripId]/settings.tsx +++ b/frontend/pages/[tripId]/settings.tsx @@ -18,6 +18,12 @@ import Icon from "components/Icon"; import { useQueryClient } from "@tanstack/react-query"; import { getRegionCode, validateRegionFields, RegionFieldsValue } from "lib/region"; import { Trip } from "@birdplan/shared"; +import dayjs from "dayjs"; + +const monthOption = (month: number): Option => ({ + value: month.toString(), + label: months[month - 1], +}); export default function TripSettings() { const { trip, is404, isOwner } = useTrip(); @@ -48,21 +54,30 @@ type SettingsFormProps = { function SettingsForm({ trip, initialRegion, isOwner }: SettingsFormProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); - const [startMonth, setStartMonth] = React.useState