fix: date selection and hand selection

This commit is contained in:
Antoine Pelletier 2026-08-24 11:47:21 +02:00
parent 754d2ddfc4
commit b50d1fe855
14 changed files with 184 additions and 47 deletions

View file

@ -0,0 +1,18 @@
-- migrate:up
-- The fleet mixes two frame sizes and the reservation form asks for one of
-- them, so the size is an attribute of the bike rather than something guessed
-- from its name.
CREATE TYPE bike_size AS ENUM ('large', 'small');
ALTER TABLE bikes ADD COLUMN "size" bike_size NOT NULL DEFAULT 'small';
-- The current fleet: 1000 and 2000 are the large ones, 3000/4000/5000 the small
UPDATE bikes SET "size" = 'large' WHERE "name" IN ('1000', '2000');
-- Every bike must state its size, so no value is implied from now on
ALTER TABLE bikes ALTER COLUMN "size" DROP DEFAULT;
-- migrate:down
ALTER TABLE bikes DROP COLUMN "size";
DROP TYPE bike_size;

View file

@ -15,6 +15,16 @@ SET xmloption = content;
SET client_min_messages = warning; SET client_min_messages = warning;
SET row_security = off; SET row_security = off;
--
-- Name: bike_size; Type: TYPE; Schema: public; Owner: -
--
CREATE TYPE public.bike_size AS ENUM (
'large',
'small'
);
-- --
-- Name: bike_status; Type: TYPE; Schema: public; Owner: - -- Name: bike_status; Type: TYPE; Schema: public; Owner: -
-- --
@ -55,6 +65,7 @@ CREATE TABLE public.bikes (
drivetrain text, drivetrain text,
battery text, battery text,
status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL, status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL,
size public.bike_size NOT NULL,
CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0)) CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0))
); );
@ -490,4 +501,5 @@ INSERT INTO public.schema_migrations (version) VALUES
('20260823153320'), ('20260823153320'),
('20260823170000'), ('20260823170000'),
('20260823210000'), ('20260823210000'),
('20260823230000'); ('20260823230000'),
('20260824120000');

View file

@ -31,12 +31,12 @@ FROM (VALUES
JOIN public.units u ON u."name" = membership.unit_name JOIN public.units u ON u."name" = membership.unit_name
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, "size", status) VALUES
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'), (1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'large', 'in_service'),
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'), (2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'large', 'in_service'),
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service'), (3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'small', 'out_of_service'),
(4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'), (4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'small', 'in_service'),
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service') (5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'small', 'in_service')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Reservations across the current week, one per status, so the admin page and -- Reservations across the current week, one per status, so the admin page and

View file

@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class, props.class,
) )
" "

View file

@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start', 'border-input dark:bg-input/30 flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
props.class, props.class,
) )
" "

View file

@ -103,7 +103,7 @@ export interface paths {
} }
content?: never content?: never
} }
/** @description no content */ /** @description Unauthenticated - a session is required */
401: { 401: {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
@ -529,8 +529,15 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
key_quantity: number key_quantity: number
name: string name: string
size: components['schemas']['BikeSize']
status: components['schemas']['BikeStatus'] status: components['schemas']['BikeStatus']
} }
/**
* @description Frame size. The reservation form lets a requester ask for one kind or the
* other, so it is part of the bike rather than being read off its name.
* @enum {string}
*/
BikeSize: 'large' | 'small'
/** @enum {string} */ /** @enum {string} */
BikeStatus: 'in_service' | 'out_of_service' BikeStatus: 'in_service' | 'out_of_service'
DevUser: { DevUser: {

View file

@ -30,6 +30,9 @@ reservation:
bikes-empty: No cargobike available for this period. bikes-empty: No cargobike available for this period.
bikes-error: Unable to load the cargobikes. bikes-error: Unable to load the cargobikes.
bike-out-of-service: Out of service bike-out-of-service: Out of service
bike-size-large: Large cargo bikes
bike-size-small: Small cargo bikes
bike-size-empty: No bike of this size.
telegram: Telegram username telegram: Telegram username
emails: Email addresses of the Linka Go accounts to authorize emails: Email addresses of the Linka Go accounts to authorize
email-nth: 'Email address {n}' email-nth: 'Email address {n}'

View file

@ -31,6 +31,9 @@ reservation:
bikes-empty: Aucun cargobike disponible pour ce créneau. bikes-empty: Aucun cargobike disponible pour ce créneau.
bikes-error: Impossible de charger les cargobikes. bikes-error: Impossible de charger les cargobikes.
bike-out-of-service: Hors service bike-out-of-service: Hors service
bike-size-large: Grands cargos
bike-size-small: Petits cargos
bike-size-empty: Aucun vélo de cette taille.
telegram: Username Telegram telegram: Username Telegram
emails: Adresses mail du/des comptes Linka Go à autoriser emails: Adresses mail du/des comptes Linka Go à autoriser
email-nth: 'Adresse e-mail {n}' email-nth: 'Adresse e-mail {n}'

View file

@ -120,4 +120,22 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* Tailwind v4 dropped the v3 preflight rule that gave buttons a pointer
cursor, so anything actionable would otherwise show the arrow. The
`[role=]` selectors cover the elements reka-ui builds out of divs:
calendar days, select options, popover triggers. */
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
[role='option']:not([aria-disabled='true']),
[role='menuitem']:not([aria-disabled='true']),
[role='tab']:not([aria-disabled='true']),
label[for],
summary,
a[href] {
@apply cursor-pointer;
}
button:disabled,
[aria-disabled='true'] {
@apply cursor-not-allowed;
}
} }

View file

@ -81,6 +81,18 @@ function isUnavailable(bike: Bike) {
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike))) const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
// The fleet is presented one column per frame size, since that is what the
// requester actually chooses. The size lives on the bike, so adding a sixth one
// only means giving it a size in the admin, never touching this file.
const BIKE_SIZES = ['large', 'small'] as const
const bikeGroups = computed(() =>
BIKE_SIZES.map((size) => ({
size,
bikes: (bikes.value ?? []).filter((bike) => bike.size === size),
})),
)
watch(availableBikes, (available) => { watch(availableBikes, (available) => {
const ids = new Set(available.map((bike) => bike.id)) const ids = new Set(available.map((bike) => bike.id))
form.bikes = form.bikes.filter((id) => ids.has(id)) form.bikes = form.bikes.filter((id) => ids.has(id))
@ -204,10 +216,14 @@ function submit() {
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p> <p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
</div> </div>
<!-- Start --> <!-- Start. The caption names the date/time pair rather than one of
them: a `<label for>` on the date would make a click on the text
open the calendar, since the browser forwards the activation. -->
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="start-date">{{ $t('reservation.start') }}</Label> <span id="start-label" class="text-sm leading-none font-medium">
<div class="grid gap-2 sm:grid-cols-2"> {{ $t('reservation.start') }}
</span>
<div role="group" aria-labelledby="start-label" class="grid gap-2 sm:grid-cols-2">
<DatePicker <DatePicker
id="start-date" id="start-date"
v-model="startDate" v-model="startDate"
@ -222,8 +238,10 @@ function submit() {
<!-- End --> <!-- End -->
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="end-date">{{ $t('reservation.end') }}</Label> <span id="end-label" class="text-sm leading-none font-medium">
<div class="grid gap-2 sm:grid-cols-2"> {{ $t('reservation.end') }}
</span>
<div role="group" aria-labelledby="end-label" class="grid gap-2 sm:grid-cols-2">
<DatePicker <DatePicker
id="end-date" id="end-date"
v-model="endDate" v-model="endDate"
@ -264,29 +282,48 @@ function submit() {
{{ $t('reservation.bikes-empty') }} {{ $t('reservation.bikes-empty') }}
</p> </p>
<div v-else class="grid gap-2 sm:grid-cols-2"> <div v-else class="grid gap-4 sm:grid-cols-2">
<button <div
v-for="bike in bikes" v-for="group in bikeGroups"
:key="bike.id" :key="group.size"
type="button" role="group"
:disabled="isUnavailable(bike)" :aria-labelledby="`bike-size-${group.size}`"
:aria-pressed="form.bikes.includes(bike.id)" class="grid content-start gap-2"
class="flex h-10 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="
form.bikes.includes(bike.id)
? 'border-primary bg-primary/10 text-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
"
@click="toggleBike(bike)"
> >
<span class="truncate">{{ bike.name }}</span>
<span <span
v-if="isUnavailable(bike)" :id="`bike-size-${group.size}`"
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase" class="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
> >
{{ $t('reservation.bike-out-of-service') }} {{ $t(`reservation.bike-size-${group.size}`) }}
</span> </span>
</button>
<p v-if="group.bikes.length === 0" class="text-muted-foreground text-sm">
{{ $t('reservation.bike-size-empty') }}
</p>
<button
v-for="bike in group.bikes"
:key="bike.id"
type="button"
:disabled="isUnavailable(bike)"
:aria-pressed="form.bikes.includes(bike.id)"
class="flex h-10 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="
form.bikes.includes(bike.id)
? 'border-primary bg-primary/10 text-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
"
@click="toggleBike(bike)"
>
<span class="truncate">{{ bike.name }}</span>
<span
v-if="isUnavailable(bike)"
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
>
{{ $t('reservation.bike-out-of-service') }}
</span>
</button>
</div>
</div> </div>
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p> <p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>

View file

@ -44,9 +44,7 @@ pub fn get_router(aac: AnonAppController) -> Router {
let config = utils::config::get(); let config = utils::config::get();
let session_layer = SessionManagerLayer::new(MemoryStore::default()) let session_layer = SessionManagerLayer::new(MemoryStore::default())
// Over plain http in development the cookie cannot be `Secure`
.with_secure(config.get_base_url().starts_with("https://")) .with_secure(config.get_base_url().starts_with("https://"))
// The provider sends the browser back with a top level navigation
.with_same_site(SameSite::Lax) .with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(Duration::minutes( .with_expiry(Expiry::OnInactivity(Duration::minutes(
config.get_session_lifetime(), config.get_session_lifetime(),

View file

@ -12,6 +12,15 @@ pub enum BikeStatus {
OutOfService, OutOfService,
} }
/// Frame size. The reservation form lets a requester ask for one kind or the
/// other, so it is part of the bike rather than being read off its name.
#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BikeSize {
Large,
Small,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Bike { pub struct Bike {
pub id: BikeId, pub id: BikeId,
@ -20,6 +29,7 @@ pub struct Bike {
pub key_quantity: i32, pub key_quantity: i32,
pub drivetrain: Option<String>, pub drivetrain: Option<String>,
pub battery: Option<String>, pub battery: Option<String>,
pub size: BikeSize,
pub status: BikeStatus, pub status: BikeStatus,
} }
@ -30,5 +40,6 @@ pub struct NewBike {
pub key_quantity: i32, pub key_quantity: i32,
pub drivetrain: Option<String>, pub drivetrain: Option<String>,
pub battery: Option<String>, pub battery: Option<String>,
pub size: BikeSize,
pub status: BikeStatus, pub status: BikeStatus,
} }

View file

@ -1,12 +1,13 @@
//! The bike fleet. `bike_status` is a postgres enum: `BikeStatusDB` mirrors the //! The bike fleet. `bike_status` and `bike_size` are postgres enums:
//! core `BikeStatus` so that sqlx stays out of `core/models`. //! `BikeStatusDB` and `BikeSizeDB` mirror the core types so that sqlx stays out
//! of `core/models`.
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::{query, query_as}; use sqlx::{query, query_as};
use crate::{ use crate::{
core::{ core::{
models::bike::{Bike, BikeId, BikeStatus, NewBike}, models::bike::{Bike, BikeId, BikeSize, BikeStatus, NewBike},
repositories::{RepositoryError, bikes_repository::BikesRepository}, repositories::{RepositoryError, bikes_repository::BikesRepository},
}, },
services::database::SqlxDatabase, services::database::SqlxDatabase,
@ -37,6 +38,31 @@ impl From<BikeStatus> for BikeStatusDB {
} }
} }
#[derive(Debug, Clone, Copy, sqlx::Type)]
#[sqlx(type_name = "bike_size", rename_all = "snake_case")]
enum BikeSizeDB {
Large,
Small,
}
impl From<BikeSizeDB> for BikeSize {
fn from(value: BikeSizeDB) -> Self {
match value {
BikeSizeDB::Large => BikeSize::Large,
BikeSizeDB::Small => BikeSize::Small,
}
}
}
impl From<BikeSize> for BikeSizeDB {
fn from(value: BikeSize) -> Self {
match value {
BikeSize::Large => BikeSizeDB::Large,
BikeSize::Small => BikeSizeDB::Small,
}
}
}
struct BikeDB { struct BikeDB {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@ -44,6 +70,7 @@ struct BikeDB {
pub key_quantity: i32, pub key_quantity: i32,
pub drivetrain: Option<String>, pub drivetrain: Option<String>,
pub battery: Option<String>, pub battery: Option<String>,
pub size: BikeSizeDB,
pub status: BikeStatusDB, pub status: BikeStatusDB,
} }
@ -56,6 +83,7 @@ impl From<BikeDB> for Bike {
key_quantity: value.key_quantity, key_quantity: value.key_quantity,
drivetrain: value.drivetrain, drivetrain: value.drivetrain,
battery: value.battery, battery: value.battery,
size: value.size.into(),
status: value.status.into(), status: value.status.into(),
} }
} }
@ -67,7 +95,7 @@ impl BikesRepository for SqlxDatabase {
Ok(query_as!( Ok(query_as!(
BikeDB, BikeDB,
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery, r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
status AS "status: BikeStatusDB" "size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB"
FROM bikes FROM bikes
ORDER BY "name""# ORDER BY "name""#
) )
@ -82,7 +110,7 @@ impl BikesRepository for SqlxDatabase {
Ok(query_as!( Ok(query_as!(
BikeDB, BikeDB,
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery, r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
status AS "status: BikeStatusDB" "size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB"
FROM bikes FROM bikes
WHERE id = $1"#, WHERE id = $1"#,
id id
@ -94,17 +122,19 @@ impl BikesRepository for SqlxDatabase {
async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError> { async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError> {
let status: BikeStatusDB = bike.status.into(); let status: BikeStatusDB = bike.status.into();
let size: BikeSizeDB = bike.size.into();
Ok(query_as!( Ok(query_as!(
BikeDB, BikeDB,
r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, status) r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, "size", status)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, "name", key_number, key_quantity, drivetrain, battery, RETURNING id, "name", key_number, key_quantity, drivetrain, battery,
status AS "status: BikeStatusDB""#, "size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB""#,
bike.name, bike.name,
bike.key_number, bike.key_number,
bike.key_quantity, bike.key_quantity,
bike.drivetrain, bike.drivetrain,
bike.battery, bike.battery,
size as BikeSizeDB,
status as BikeStatusDB status as BikeStatusDB
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
@ -114,10 +144,11 @@ impl BikesRepository for SqlxDatabase {
async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> { async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> {
let status: BikeStatusDB = bike.status.into(); let status: BikeStatusDB = bike.status.into();
let size: BikeSizeDB = bike.size.into();
let result = query!( let result = query!(
r#"UPDATE bikes r#"UPDATE bikes
SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5, SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5,
battery = $6, status = $7 battery = $6, "size" = $7, status = $8
WHERE id = $1"#, WHERE id = $1"#,
bike.id, bike.id,
bike.name, bike.name,
@ -125,6 +156,7 @@ impl BikesRepository for SqlxDatabase {
bike.key_quantity, bike.key_quantity,
bike.drivetrain, bike.drivetrain,
bike.battery, bike.battery,
size as BikeSizeDB,
status as BikeStatusDB status as BikeStatusDB
) )
.execute(&self.pool) .execute(&self.pool)

View file

@ -28,7 +28,6 @@ pub struct OidcConfig {
pub issuer_url: String, pub issuer_url: String,
pub client_id: String, pub client_id: String,
pub client_secret: String, pub client_secret: String,
/// How long a session stays valid, in minutes
pub session_lifetime: Option<i64>, pub session_lifetime: Option<i64>,
} }
@ -38,7 +37,6 @@ pub struct OidcConfig {
pub struct DevUserConfig { pub struct DevUserConfig {
pub firstname: String, pub firstname: String,
pub name: String, pub name: String,
/// Also the handle used to pick the user at login
pub email: String, pub email: String,
pub external_id: Option<String>, pub external_id: Option<String>,
#[serde(default)] #[serde(default)]