feat: public calendar

This commit is contained in:
Antoine Pelletier 2026-08-24 13:02:01 +02:00
parent 46c5edf5bd
commit 34fbc2f3de
15 changed files with 1135 additions and 938 deletions

12
.cargo/config.toml Normal file
View file

@ -0,0 +1,12 @@
# Development overrides for the app configuration. `cargo run` and `cargo test`
# inject these into the process environment; the `config` crate reads them last,
# so they win over `config.yml` without it having to be edited.
#
# The `cargagep` client exists on Hydra but has no matching redirect uri, so we
# borrow the one registered for `trust`. Drop this file once `cargagep` is
# properly registered and `config.yml` takes over again.
#
# `force = false`: a value given on the command line still takes precedence,
# APP__OIDC__CLIENT_ID=other cargo run
[env]
APP__OIDC__CLIENT_ID = { value = "trust", force = false }

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
/target
config.yml
config.yaml
/LEGACY
/LEGACY
summary.ai

View file

@ -24,7 +24,9 @@ const setStatus = useSetReservationStatus()
/** Kept in step with the state machine drawn on the backend enum */
const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
requested: ['approved', 'refused'],
approved: ['ongoing', 'cancelled'],
// No "start" here on purpose: a reservation becomes `ongoing` on its own, not
// by an admin pressing a button. The backend still allows the transition.
approved: ['cancelled'],
ongoing: ['archived', 'cancelled'],
refused: [],
cancelled: [],

View file

@ -21,15 +21,19 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
import { unitLabel, type Bike, type CalendarReservation } from '@/utils/types'
const props = defineProps<{ reservations: Reservation[]; bikes: Bike[] }>()
// Takes the narrow shape the public calendar receives; a full `Reservation`
// satisfies it too, so the admin page keeps passing its own list unchanged.
const props = defineProps<{ reservations: CalendarReservation[]; bikes: Bike[] }>()
const { locale } = useI18n()
const FIRST_HOUR = 7
const LAST_HOUR = 20
const HOUR_HEIGHT = 44
// The whole day, 00:00 to 23:59. 24 rows at 44px would make the card very tall,
// hence the shorter row.
const FIRST_HOUR = 0
const LAST_HOUR = 24
const HOUR_HEIGHT = 32
const weekStart = ref(startOfWeek(new Date()))
const selectedBike = ref<string>('all')
@ -140,18 +144,18 @@ function blocksFor(day: Date, bike: Bike): Block[] {
<CardHeader class="gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>{{ $t('admin.calendar.title') }}</CardTitle>
<CardDescription>{{ $t('admin.calendar.intro') }}</CardDescription>
<CardTitle>{{ $t('calendar.title') }}</CardTitle>
<CardDescription>{{ $t('calendar.intro') }}</CardDescription>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">{{ $t('admin.calendar.bike') }}</span>
<span class="text-muted-foreground text-sm">{{ $t('calendar.bike') }}</span>
<Select v-model="selectedBike">
<SelectTrigger class="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t('admin.calendar.all-bikes') }}</SelectItem>
<SelectItem value="all">{{ $t('calendar.all-bikes') }}</SelectItem>
<SelectItem v-for="bike in bikes" :key="bike.id" :value="String(bike.id)">
{{ bike.name }}
</SelectItem>
@ -163,13 +167,13 @@ function blocksFor(day: Date, bike: Bike): Block[] {
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" @click="shiftWeek(-1)">
<ChevronLeft class="size-4" />
{{ $t('admin.calendar.previous') }}
{{ $t('calendar.previous') }}
</Button>
<Button variant="secondary" size="sm" @click="weekStart = startOfWeek(new Date())">
{{ $t('admin.calendar.today') }}
{{ $t('calendar.today') }}
</Button>
<Button variant="outline" size="sm" @click="shiftWeek(1)">
{{ $t('admin.calendar.next') }}
{{ $t('calendar.next') }}
<ChevronRight class="size-4" />
</Button>
<span class="text-muted-foreground text-sm">{{ range }}</span>
@ -178,7 +182,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
<CardContent>
<p v-if="!lanes.length" class="text-muted-foreground text-sm">
{{ $t('admin.calendar.no-bike') }}
{{ $t('calendar.no-bike') }}
</p>
<!-- The grid is wide: it scrolls on its own rather than the page -->
@ -186,7 +190,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
<div class="min-w-[52rem]">
<div class="flex border-b">
<div class="text-muted-foreground w-12 shrink-0 py-2 text-xs">
{{ $t('admin.calendar.hour') }}
{{ $t('calendar.hour') }}
</div>
<div
v-for="day in days"
@ -241,8 +245,8 @@ function blocksFor(day: Date, bike: Bike): Block[] {
type="button"
class="bg-primary hover:bg-primary/80 focus-visible:ring-ring absolute inset-x-0 rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
:title="$t('admin.calendar.open', { id: block.id })"
:aria-label="$t('admin.calendar.open', { id: block.id })"
:title="$t('calendar.open', { id: block.id })"
:aria-label="$t('calendar.open', { id: block.id })"
@click="opened = block.id"
/>
</div>
@ -252,13 +256,13 @@ function blocksFor(day: Date, bike: Bike): Block[] {
</div>
</div>
<p class="text-muted-foreground mt-3 text-xs">{{ $t('admin.calendar.legend') }}</p>
<p class="text-muted-foreground mt-3 text-xs">{{ $t('calendar.legend') }}</p>
<Dialog v-model:open="dialogOpen">
<DialogScrollContent v-if="openedReservation" class="sm:max-w-lg">
<DialogHeader>
<DialogTitle class="flex flex-wrap items-center gap-2">
{{ $t('admin.calendar.reservation', { id: openedReservation.id }) }}
{{ $t('calendar.reservation', { id: openedReservation.id }) }}
<Badge variant="secondary">
{{ $t(`reservation.status.${openedReservation.status}`) }}
</Badge>

View file

@ -6,13 +6,13 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Bike, Reservation } from '@/utils/types'
import type { Bike, ReservationLike } from '@/utils/types'
type Field = 'period' | 'bikes' | 'telegram' | 'people' | 'linka' | 'reason'
const props = withDefaults(
defineProps<{
reservation: Reservation
reservation: ReservationLike
bikes: Bike[]
/** Fields the caller renders itself — the edit dialog omits what it edits */
omit?: Field[]
@ -55,17 +55,17 @@ function format(iso: string) {
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.bikes') }}</dt>
<dd>{{ bikeNames }}</dd>
</div>
<div v-if="shows('telegram')" class="flex gap-2">
<div v-if="shows('telegram') && reservation.telegram" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.telegram') }}</dt>
<dd class="truncate">{{ reservation.telegram }}</dd>
</div>
<div v-if="shows('people')" class="flex gap-2">
<div v-if="shows('people') && reservation.users?.length" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.people') }}</dt>
<dd class="min-w-0 break-words">
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
</dd>
</div>
<div v-if="shows('linka') && reservation.linka_emails.length" class="flex gap-2">
<div v-if="shows('linka') && reservation.linka_emails?.length" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.linka') }}</dt>
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
</div>

File diff suppressed because it is too large Load diff

View file

@ -107,8 +107,19 @@ login:
not-allowed: Your account is not allowed to use this application.
error: The login failed.
calendar:
title: Calendar
todo: This page is not built yet.
load-error: Unable to load the calendar.
title: Calendar per cargobike
intro: Approved and ongoing reservations are shown in the calendar.
bike: Cargobike
all-bikes: All
previous: Week
next: Week
today: Today
hour: H
no-bike: No cargobike to show.
legend: One bar per booked cargobike, split by day. Click one for the details.
open: 'Open reservation #{id}'
reservation: 'Reservation #{id}'
admin:
title: Reservation administration
intro: Approve, refuse and follow the reservations, and see the planning of each cargobike.
@ -144,16 +155,3 @@ admin:
cancelled: Cancel
ongoing: Start
archived: Archive
calendar:
title: Calendar per cargobike
intro: Approved and ongoing reservations are shown in the calendar.
bike: Cargobike
all-bikes: All
previous: Week
next: Week
today: Today
hour: H
no-bike: No cargobike to show.
legend: One bar per booked cargobike, split by day. Click one for the details.
open: 'Open reservation #{id}'
reservation: 'Reservation #{id}'

View file

@ -108,8 +108,19 @@ login:
not-allowed: Votre compte n'est pas autorisé à accéder à cette application.
error: La connexion a échoué.
calendar:
title: Calendrier
todo: Cette page n'est pas encore construite.
load-error: Impossible de charger le calendrier.
title: Calendrier par cargobike
intro: Les réservations validées et en cours sont affichées dans le calendrier.
bike: Cargobike
all-bikes: Tous
previous: Sem.
next: Sem.
today: Aujourd'hui
hour: H
no-bike: Aucun cargobike à afficher.
legend: Une barre par cargobike réservé, découpée par jour. Cliquez pour voir le détail.
open: 'Voir la réservation #{id}'
reservation: 'Réservation #{id}'
admin:
title: Administration des réservations
intro: Validez, refusez et suivez les réservations, et visualisez les plannings par cargobike.
@ -145,16 +156,3 @@ admin:
cancelled: Annuler
ongoing: Démarrer
archived: Archiver
calendar:
title: Calendrier par cargobike
intro: Les réservations validées et en cours sont affichées dans le calendrier.
bike: Cargobike
all-bikes: Tous
previous: Sem.
next: Sem.
today: Aujourd'hui
hour: H
no-bike: Aucun cargobike à afficher.
legend: Une barre par cargobike réservé, découpée par jour. Cliquez pour voir le détail.
open: 'Voir la réservation #{id}'
reservation: 'Réservation #{id}'

View file

@ -10,6 +10,7 @@ import { getClient } from './client'
export const RESERVATIONS_KEY = ['reservations']
export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
export const CALENDAR_KEY = ['reservations', 'calendar']
export function useReservations() {
return useQuery({
@ -52,6 +53,24 @@ export function useSetReservationStatus() {
})
}
/**
* The public availability calendar: the approved and ongoing reservations,
* without any personal field. No session needed.
*/
export function useCalendarReservations() {
return useQuery({
queryKey: CALENDAR_KEY,
staleTime: 30 * 1000,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/reservations/calendar')
if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`)
}
return data ?? []
},
})
}
/**
* Everything the session user is part of: what they filed, what they were added
* to, and what lists their address among the Linka Go accounts — which is how a

View file

@ -29,6 +29,16 @@ export type UserSummary = components['schemas']['UserSummary']
export type ReservationUnit = components['schemas']['ReservationUnit']
export type NewReservation = components['schemas']['NewReservation']
export type ReservationEdit = components['schemas']['ReservationEditForm']
export type CalendarReservation = components['schemas']['CalendarReservation']
/**
* What the details block can render: the fields the public calendar carries,
* plus whatever personal ones the caller was entitled to receive. A full
* `Reservation` satisfies it; a `CalendarReservation` satisfies it with the
* personal half missing, and the block simply leaves those rows out.
*/
export type ReservationLike = CalendarReservation &
Partial<Pick<Reservation, 'telegram' | 'users' | 'linka_emails' | 'description'>>
/**
* What to display for a reservation's unit: the name of the unit it points at,

View file

@ -6,7 +6,7 @@
import { computed } from 'vue'
import { RefreshCw } from '@lucide/vue'
import BikeCalendar from '@/components/admin/BikeCalendar.vue'
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
import BikeFleet from '@/components/admin/BikeFleet.vue'
import ReservationAdmin from '@/components/admin/ReservationAdmin.vue'
import { Button } from '@/components/ui/button'

View file

@ -1,13 +1,29 @@
<script setup lang="ts">
/**
* Placeholder: the header links here, and a dead link would be worse than an
* explicit "not built yet". Replace with the real availability calendar.
* The public availability calendar: the same grid as the admin page, fed by the
* endpoint that carries no personal field. Anybody can look up when the bikes
* are taken without being signed in.
*/
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
import { Skeleton } from '@/components/ui/skeleton'
import { useBikes } from '@/services/api/bikes'
import { useCalendarReservations } from '@/services/api/reservations'
const { data: reservations, isPending, isError } = useCalendarReservations()
const { data: bikes, isPending: bikesPending } = useBikes()
</script>
<template>
<div class="mx-auto w-full max-w-2xl">
<h1 class="text-xl font-semibold">{{ $t('calendar.title') }}</h1>
<p class="text-muted-foreground mt-2 text-sm">{{ $t('calendar.todo') }}</p>
<div class="mx-auto w-full max-w-6xl">
<div v-if="isPending || bikesPending" class="grid gap-3">
<Skeleton class="h-24 w-full" />
<Skeleton class="h-[40rem] w-full" />
</div>
<p v-else-if="isError" class="text-destructive text-sm">
{{ $t('calendar.load-error') }}
</p>
<BikeCalendar v-else :reservations="reservations ?? []" :bikes="bikes ?? []" />
</div>
</template>

View file

@ -21,10 +21,16 @@ use serde::Deserialize;
use crate::{
api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error},
core::{
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
controller::{
AnonAppController, AppController, ControllerError,
reservations::ReservationsControllerError,
},
models::{
bike::BikeId,
reservation::{NewReservation, Reservation, ReservationEdit, ReservationStatus},
reservation::{
CalendarReservation, NewReservation, Reservation, ReservationEdit,
ReservationStatus,
},
},
},
};
@ -36,11 +42,15 @@ pub fn routes() -> ApiRouter {
get_with(get_reservations, get_reservations_docs)
.post_with(create_reservation, create_reservation_docs),
)
// Before `/{id}`, which would otherwise be a candidate for "mine"
// Before `/{id}`, which would otherwise be a candidate for these
.api_route(
"/mine",
get_with(get_my_reservations, get_my_reservations_docs),
)
.api_route(
"/calendar",
get_with(get_calendar_reservations, get_calendar_reservations_docs),
)
.api_route(
"/{id}",
put_with(update_reservation, update_reservation_docs),
@ -105,6 +115,27 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
.response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
}
/// The availability calendar, open to everybody: when the bikes are taken and by
/// which association, with nothing personal attached.
#[axum::debug_handler]
async fn get_calendar_reservations(
aac: AnonAppController,
) -> Result<Json<Vec<CalendarReservation>>, (StatusCode, String)> {
match aac.get_calendar_reservations().await {
Ok(reservations) => Ok(Json(reservations)),
Err(err) => unexpected_error("get_calendar_reservations", err),
}
}
fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Get the approved and ongoing reservations, for the public calendar")
.description(
"No session needed, and no personal field travels: the telegram handle, \
the people, the Linka Go addresses and the reason are left out.",
)
}
/// Everything the session user is part of: what they filed, what they were
/// added to, and what lists their address among the Linka Go accounts.
#[axum::debug_handler]

View file

@ -5,8 +5,8 @@ use crate::core::{
models::{
bike::BikeStatus,
reservation::{
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
ReservationStatus,
CalendarReservation, NewReservation, NewReservationUnit, Reservation, ReservationEdit,
ReservationId, ReservationStatus,
},
unit::UnitId,
},
@ -32,6 +32,26 @@ impl AnonAppController {
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
self.db.get_reservation(id).await.map_err(Into::into)
}
/// What the availability calendar shows: the reservations that actually hold
/// a bike, stripped of everything personal. Reading it needs no session.
pub async fn get_calendar_reservations(
&self,
) -> Result<Vec<CalendarReservation>, ControllerError> {
Ok(self
.db
.get_reservations()
.await?
.into_iter()
.filter(|reservation| {
matches!(
reservation.status,
ReservationStatus::Approved | ReservationStatus::Ongoing
)
})
.map(Into::into)
.collect())
}
}
/// Filing a request is done in one's own name: the requester is the session

View file

@ -121,6 +121,33 @@ pub struct Reservation {
pub status: ReservationStatus,
}
/// A reservation as the public availability calendar sees it: when the bikes are
/// taken and by which association, and nothing else. The telegram handle, the
/// people, the Linka Go addresses and the reason are deliberately absent — this
/// travels to anybody, signed in or not.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct CalendarReservation {
pub id: ReservationId,
pub unit: ReservationUnit,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub bikes: Vec<BikeId>,
pub status: ReservationStatus,
}
impl From<Reservation> for CalendarReservation {
fn from(value: Reservation) -> Self {
CalendarReservation {
id: value.id,
unit: value.unit,
start_time: value.start_time,
end_time: value.end_time,
bikes: value.bikes,
status: value.status,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewReservation {
pub unit: NewReservationUnit,