feat(server): expose the active trip as its own endpoint

GET /api/trips/active answers with the trip a user means by "my trip"
right now: the one running today, else the next one starting, else the
most recently started. Archived trips never qualify.

That order is the dashboard hero's (sortTrips), on purpose - the startup
redirect this is for has to land on the same trip the dashboard features,
or the two would disagree about which trip is yours.

Kept apart from list() because it runs on a first paint: four columns of
one row instead of every trip with its per-trip day and place counts.
This commit is contained in:
Maurice
2026-08-08 21:03:11 +02:00
committed by Maurice
parent aca6c556ed
commit 99ee307034
7 changed files with 188 additions and 4 deletions
+21 -1
View File
@@ -1,4 +1,9 @@
import { tripCreateRequestSchema, tripUpdateRequestSchema, tripAddMemberRequestSchema } from './trip.schema';
import {
tripCreateRequestSchema,
tripUpdateRequestSchema,
tripAddMemberRequestSchema,
activeTripResponseSchema,
} from './trip.schema';
import { describe, it, expect } from 'vitest';
@@ -29,3 +34,18 @@ describe('tripAddMemberRequestSchema', () => {
expect(tripAddMemberRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('activeTripResponseSchema', () => {
it('accepts a trip with or without dates, and an explicit null', () => {
expect(activeTripResponseSchema.safeParse({ trip: { id: 1, title: 'Japan' } }).success).toBe(true);
expect(
activeTripResponseSchema.safeParse({ trip: { id: 1, title: 'Japan', start_date: null, end_date: null } }).success,
).toBe(true);
expect(activeTripResponseSchema.safeParse({ trip: null }).success).toBe(true);
});
it('rejects a missing trip key — "no trip" is null, not absent', () => {
expect(activeTripResponseSchema.safeParse({}).success).toBe(false);
expect(activeTripResponseSchema.safeParse({ trip: { title: 'Japan' } }).success).toBe(false);
});
});
+25
View File
@@ -118,3 +118,28 @@ export const tripTransferOwnershipRequestSchema = z.object({
newOwnerId: z.number().int().positive(),
});
export type TripTransferOwnershipRequest = z.infer<typeof tripTransferOwnershipRequestSchema>;
/**
* The one trip TREK opens on for a user who wants to land in their trip instead
* of on the dashboard (GET /api/trips/active). Deliberately narrow: it is read
* on the very first paint to decide where to navigate, so it carries the id plus
* enough to label the destination — nothing that would make it as wide (or as
* slow) as the trip list it replaces there.
*
* Relevance order matches the dashboard hero (client sortTrips): the trip
* running today, else the next one starting, else the most recently started.
*/
export const activeTripSchema = z.object({
id: z.number(),
title: z.string(),
start_date: z.string().nullable().optional(),
end_date: z.string().nullable().optional(),
});
export type ActiveTrip = z.infer<typeof activeTripSchema>;
// null when the user has no unarchived trip at all — the caller falls back to
// the dashboard rather than navigating into nothing.
export const activeTripResponseSchema = z.object({
trip: activeTripSchema.nullable(),
});
export type ActiveTripResponse = z.infer<typeof activeTripResponseSchema>;