Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down
1 change: 1 addition & 0 deletions backend/models/Trip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
28 changes: 4 additions & 24 deletions backend/routes/trips/[tripId]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
generateOpenBirdingCode,
getBounds,
isDuplicateKeyError,
validateTripDates,
} from "lib/utils.js";
import { connect, Trip, Participant, User, IntegrationToken } from "lib/db.js";
import {
Expand Down Expand Up @@ -93,10 +94,13 @@ trip.patch("/", async (c) => {
}

const data = await c.req.json<TripUpdateInput>();
validateTripDates(data);

const newData: Record<string, any> = {
name: data.name,
region: data.region,
startDate: data.startDate ?? null,
endDate: data.endDate ?? null,
startMonth: data.startMonth,
endMonth: data.endMonth,
};
Expand Down Expand Up @@ -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;
153 changes: 148 additions & 5 deletions backend/routes/trips/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParticipantView, "_id" | "userId" | "name" | "photoUrl">;

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
Expand Down Expand Up @@ -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<TripStats>([
{ $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<string, unknown> = { _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<TripListRow>([
{ $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<string, ParticipantAvatar[]>();
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<TripInput>();
validateTripDates(data);

const bounds = await getBounds(data.region);
if (!bounds) {
Expand Down
83 changes: 62 additions & 21 deletions frontend/components/TripCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link to={`/${_id}`}>
<div className="bg-white rounded-lg shadow-sm relative p-4">
{trip?.imgUrl && (
<div className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white sm:flex-row">
<Link to={`/${_id}`} className="relative block aspect-[300/185] w-full shrink-0 bg-gray-100 sm:w-56">
{imgUrl && (
<img
src={trip?.imgUrl}
className="w-full h-36 object-cover rounded-lg mb-3"
src={imgUrl}
alt=""
width={600}
height={370}
className="absolute inset-0 h-full w-full object-cover [filter:saturate(0.92)_brightness(0.93)]"
/>
)}
<div>
<div className="flex justify-between items-center">
<h2 className="text-lg font-bold text-gray-800 mb-2">{name}</h2>
<ReactCountryFlag
countryCode={trip.region.slice(0, 2)}
style={{ fontSize: "1.6rem" }}
aria-label="country flag"
/>
{daysUntilStart !== null && (
<div className="absolute top-2.5 left-2.5 rounded-full bg-white/95 px-3 py-1.5 text-xs font-bold text-gray-800 shadow-md">
{daysUntilStart === 0 ? "Today" : `In ${daysUntilStart} ${daysUntilStart === 1 ? "day" : "days"}`}
</div>
<p className="text-sm text-gray-500">
{hotspots.length} {hotspots.length === 1 ? "saved hotspot" : "saved hotspots"}
)}
</Link>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-2 p-4">
<Link to={`/${_id}`} className="mb-1 truncate">
<h2 className="truncate text-2xl font-bold text-gray-800">{name}</h2>
</Link>
<div className="flex items-center gap-2">
<ReactCountryFlag
countryCode={region.slice(0, 2)}
style={{ fontSize: "1.1rem" }}
className="shrink-0"
aria-label="country flag"
/>
<p className="text-gray-600 text-sm">
{formatTripDateRange(trip)}
{durationDays && ` · ${durationDays} ${durationDays === 1 ? "day" : "days"}`}
</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-gray-600">
<b className="font-bold text-gray-800">{hotspotCount}</b> hotspots &nbsp;·&nbsp;
<b className="font-bold text-gray-800">{participantCount}</b>{" "}
{participantCount === 1 ? "participant" : "participants"}
</p>
{participants.length > 0 && (
<div className="flex shrink-0 items-center -space-x-2">
{shownAvatars.map((p) => (
<Avatar
key={p._id}
user={{ seed: p.userId || p._id, name: p.name, photoUrl: p.photoUrl }}
gravatar={false}
size={28}
className="ring-2 ring-white"
/>
))}
{extraAvatars > 0 && (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-[11px] font-bold text-primary ring-2 ring-white">
+{extraAvatars}
</div>
)}
</div>
)}
</div>
</div>
</Link>
</div>
);
}
19 changes: 19 additions & 0 deletions frontend/components/WidgetHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">{title}</h2>
{action && (
<Link to={action.to} className="text-xs font-bold text-link">
{action.label}
</Link>
)}
</div>
);
}
12 changes: 12 additions & 0 deletions frontend/data/news.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
Loading
Loading