diff --git a/db/migrations/20260824120000_bike_size.sql b/db/migrations/20260824120000_bike_size.sql new file mode 100644 index 0000000..0f70fe3 --- /dev/null +++ b/db/migrations/20260824120000_bike_size.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql index fec713d..f3eac39 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -15,6 +15,16 @@ SET xmloption = content; SET client_min_messages = warning; 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: - -- @@ -55,6 +65,7 @@ CREATE TABLE public.bikes ( drivetrain text, battery text, 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)) ); @@ -490,4 +501,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260823153320'), ('20260823170000'), ('20260823210000'), - ('20260823230000'); + ('20260823230000'), + ('20260824120000'); diff --git a/db/seed.sql b/db/seed.sql index fe7073d..0048f40 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -31,12 +31,12 @@ FROM (VALUES JOIN public.units u ON u."name" = membership.unit_name ON CONFLICT DO NOTHING; -INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES - (1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'), - (2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'), - (3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service'), - (4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'), - (5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service') +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', 'large', 'in_service'), + (2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'large', 'in_service'), + (3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'small', 'out_of_service'), + (4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'small', 'in_service'), + (5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'small', 'in_service') ON CONFLICT DO NOTHING; -- Reservations across the current week, one per status, so the admin page and diff --git a/frontend/src/components/ui/select/SelectItem.vue b/frontend/src/components/ui/select/SelectItem.vue index e320f3f..bbd81e2 100644 --- a/frontend/src/components/ui/select/SelectItem.vue +++ b/frontend/src/components/ui/select/SelectItem.vue @@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps) v-bind="forwardedProps" :class=" 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, ) " diff --git a/frontend/src/components/ui/select/SelectTrigger.vue b/frontend/src/components/ui/select/SelectTrigger.vue index 8b57792..8d9e925 100644 --- a/frontend/src/components/ui/select/SelectTrigger.vue +++ b/frontend/src/components/ui/select/SelectTrigger.vue @@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps) v-bind="forwardedProps" :class=" 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, ) " diff --git a/frontend/src/lib/api.d.ts b/frontend/src/lib/api.d.ts index bd8e134..4bd6b1e 100644 --- a/frontend/src/lib/api.d.ts +++ b/frontend/src/lib/api.d.ts @@ -103,7 +103,7 @@ export interface paths { } content?: never } - /** @description no content */ + /** @description Unauthenticated - a session is required */ 401: { headers: { [name: string]: unknown @@ -529,8 +529,15 @@ export interface components { /** Format: int32 */ key_quantity: number name: string + size: components['schemas']['BikeSize'] 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} */ BikeStatus: 'in_service' | 'out_of_service' DevUser: { diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml index 5143228..922e6ba 100644 --- a/frontend/src/locales/en.yml +++ b/frontend/src/locales/en.yml @@ -30,6 +30,9 @@ reservation: bikes-empty: No cargobike available for this period. bikes-error: Unable to load the cargobikes. 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 emails: Email addresses of the Linka Go accounts to authorize email-nth: 'Email address {n}' diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml index c4ae196..1fca4b6 100644 --- a/frontend/src/locales/fr.yml +++ b/frontend/src/locales/fr.yml @@ -31,6 +31,9 @@ reservation: bikes-empty: Aucun cargobike disponible pour ce créneau. bikes-error: Impossible de charger les cargobikes. 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 emails: Adresses mail du/des comptes Linka Go à autoriser email-nth: 'Adresse e-mail {n}' diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css index 3368459..93f608c 100644 --- a/frontend/src/styles/tailwind.css +++ b/frontend/src/styles/tailwind.css @@ -120,4 +120,22 @@ body { @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; + } } diff --git a/frontend/src/views/ReservationView.vue b/frontend/src/views/ReservationView.vue index 1eebe7e..a38f9c6 100644 --- a/frontend/src/views/ReservationView.vue +++ b/frontend/src/views/ReservationView.vue @@ -81,6 +81,18 @@ function isUnavailable(bike: 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) => { const ids = new Set(available.map((bike) => bike.id)) form.bikes = form.bikes.filter((id) => ids.has(id)) @@ -204,10 +216,14 @@ function submit() {

{{ errors.reason }}

- +
- -
+ + {{ $t('reservation.start') }} + +
- -
+ + {{ $t('reservation.end') }} + +
-
- + +

+ {{ $t('reservation.bike-size-empty') }} +

+ + +

{{ errors.bikes }}

diff --git a/src/api/mod.rs b/src/api/mod.rs index 26943c8..de9dec6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -44,9 +44,7 @@ pub fn get_router(aac: AnonAppController) -> Router { let config = utils::config::get(); 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://")) - // The provider sends the browser back with a top level navigation .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity(Duration::minutes( config.get_session_lifetime(), diff --git a/src/core/models/bike.rs b/src/core/models/bike.rs index aa372bc..7e32cdb 100644 --- a/src/core/models/bike.rs +++ b/src/core/models/bike.rs @@ -12,6 +12,15 @@ pub enum BikeStatus { 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)] pub struct Bike { pub id: BikeId, @@ -20,6 +29,7 @@ pub struct Bike { pub key_quantity: i32, pub drivetrain: Option, pub battery: Option, + pub size: BikeSize, pub status: BikeStatus, } @@ -30,5 +40,6 @@ pub struct NewBike { pub key_quantity: i32, pub drivetrain: Option, pub battery: Option, + pub size: BikeSize, pub status: BikeStatus, } diff --git a/src/services/database/bikes.rs b/src/services/database/bikes.rs index caf5dc8..0408808 100644 --- a/src/services/database/bikes.rs +++ b/src/services/database/bikes.rs @@ -1,12 +1,13 @@ -//! The bike fleet. `bike_status` is a postgres enum: `BikeStatusDB` mirrors the -//! core `BikeStatus` so that sqlx stays out of `core/models`. +//! The bike fleet. `bike_status` and `bike_size` are postgres enums: +//! `BikeStatusDB` and `BikeSizeDB` mirror the core types so that sqlx stays out +//! of `core/models`. use async_trait::async_trait; use sqlx::{query, query_as}; use crate::{ core::{ - models::bike::{Bike, BikeId, BikeStatus, NewBike}, + models::bike::{Bike, BikeId, BikeSize, BikeStatus, NewBike}, repositories::{RepositoryError, bikes_repository::BikesRepository}, }, services::database::SqlxDatabase, @@ -37,6 +38,31 @@ impl From for BikeStatusDB { } } +#[derive(Debug, Clone, Copy, sqlx::Type)] +#[sqlx(type_name = "bike_size", rename_all = "snake_case")] +enum BikeSizeDB { + Large, + Small, +} + +impl From for BikeSize { + fn from(value: BikeSizeDB) -> Self { + match value { + BikeSizeDB::Large => BikeSize::Large, + BikeSizeDB::Small => BikeSize::Small, + } + } +} + +impl From for BikeSizeDB { + fn from(value: BikeSize) -> Self { + match value { + BikeSize::Large => BikeSizeDB::Large, + BikeSize::Small => BikeSizeDB::Small, + } + } +} + struct BikeDB { pub id: i32, pub name: String, @@ -44,6 +70,7 @@ struct BikeDB { pub key_quantity: i32, pub drivetrain: Option, pub battery: Option, + pub size: BikeSizeDB, pub status: BikeStatusDB, } @@ -56,6 +83,7 @@ impl From for Bike { key_quantity: value.key_quantity, drivetrain: value.drivetrain, battery: value.battery, + size: value.size.into(), status: value.status.into(), } } @@ -67,7 +95,7 @@ impl BikesRepository for SqlxDatabase { Ok(query_as!( BikeDB, r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery, - status AS "status: BikeStatusDB" + "size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB" FROM bikes ORDER BY "name""# ) @@ -82,7 +110,7 @@ impl BikesRepository for SqlxDatabase { Ok(query_as!( BikeDB, r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery, - status AS "status: BikeStatusDB" + "size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB" FROM bikes WHERE id = $1"#, id @@ -94,17 +122,19 @@ impl BikesRepository for SqlxDatabase { async fn create_bike(&self, bike: NewBike) -> Result { let status: BikeStatusDB = bike.status.into(); + let size: BikeSizeDB = bike.size.into(); Ok(query_as!( BikeDB, - r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, status) - VALUES ($1, $2, $3, $4, $5, $6) + r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, "size", status) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, "name", key_number, key_quantity, drivetrain, battery, - status AS "status: BikeStatusDB""#, + "size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB""#, bike.name, bike.key_number, bike.key_quantity, bike.drivetrain, bike.battery, + size as BikeSizeDB, status as BikeStatusDB ) .fetch_one(&self.pool) @@ -114,10 +144,11 @@ impl BikesRepository for SqlxDatabase { async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> { let status: BikeStatusDB = bike.status.into(); + let size: BikeSizeDB = bike.size.into(); let result = query!( r#"UPDATE bikes SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5, - battery = $6, status = $7 + battery = $6, "size" = $7, status = $8 WHERE id = $1"#, bike.id, bike.name, @@ -125,6 +156,7 @@ impl BikesRepository for SqlxDatabase { bike.key_quantity, bike.drivetrain, bike.battery, + size as BikeSizeDB, status as BikeStatusDB ) .execute(&self.pool) diff --git a/src/utils/config.rs b/src/utils/config.rs index 331b51c..3d04377 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -28,7 +28,6 @@ pub struct OidcConfig { pub issuer_url: String, pub client_id: String, pub client_secret: String, - /// How long a session stays valid, in minutes pub session_lifetime: Option, } @@ -38,7 +37,6 @@ pub struct OidcConfig { pub struct DevUserConfig { pub firstname: String, pub name: String, - /// Also the handle used to pick the user at login pub email: String, pub external_id: Option, #[serde(default)]