From ff00a37c4a3fdd528b7b16d68d26f30ab8f78b3f Mon Sep 17 00:00:00 2001 From: FluffyFire <62662486+sturla1511@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:39:46 +0200 Subject: [PATCH 1/3] volunteer-shift-planning --- .../migrations/0030_groovy_paper_doll.sql | 10 + .../migrations/meta/0029_snapshot.json | 4 +- .../migrations/meta/0030_snapshot.json | 1273 +++++++++++++++++ .../migrations/meta/_journal.json | 10 +- .../src/lib/api/remotes/events.remote.ts | 13 + .../src/lib/availability-periods.test.ts | 97 ++ .../src/lib/availability-periods.ts | 62 + .../components/portal/PortalSidebar.svelte | 2 + .../portal/ShiftRecommendations.svelte | 165 +++ .../lib/components/styles/button-variant.ts | 2 +- .../src/lib/server/db/schemas/index.ts | 1 + .../lib/server/db/schemas/unavailability.ts | 20 + .../lib/server/services/shift.service.test.ts | 119 ++ .../src/lib/server/services/shift.service.ts | 80 +- .../src/lib/shift-planning.test.ts | 215 +++ programmerbar-web/src/lib/shift-planning.ts | 113 ++ programmerbar-web/src/lib/validators.ts | 1 + .../src/lib/validators/availability.ts | 18 + .../portal/admin/vaktoversikt/+page.server.ts | 43 + .../portal/admin/vaktoversikt/+page.svelte | 187 +++ .../portal/arrangementer/[id]/+page.server.ts | 18 +- .../portal/arrangementer/[id]/+page.svelte | 3 +- .../[id]/rediger/+page.server.ts | 37 +- .../arrangementer/[id]/rediger/+page.svelte | 20 +- .../portal/arrangementer/ny/+page.server.ts | 1 + .../portal/arrangementer/ny/+page.svelte | 21 +- .../(portal)/portal/fravaer/+page.server.ts | 87 ++ .../(portal)/portal/fravaer/+page.svelte | 163 +++ programmerbar-web/src/tailwind.css | 18 + 29 files changed, 2786 insertions(+), 17 deletions(-) create mode 100644 programmerbar-web/migrations/0030_groovy_paper_doll.sql create mode 100644 programmerbar-web/migrations/meta/0030_snapshot.json create mode 100644 programmerbar-web/src/lib/availability-periods.test.ts create mode 100644 programmerbar-web/src/lib/availability-periods.ts create mode 100644 programmerbar-web/src/lib/components/portal/ShiftRecommendations.svelte create mode 100644 programmerbar-web/src/lib/server/db/schemas/unavailability.ts create mode 100644 programmerbar-web/src/lib/server/services/shift.service.test.ts create mode 100644 programmerbar-web/src/lib/shift-planning.test.ts create mode 100644 programmerbar-web/src/lib/shift-planning.ts create mode 100644 programmerbar-web/src/lib/validators/availability.ts create mode 100644 programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.server.ts create mode 100644 programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.svelte create mode 100644 programmerbar-web/src/routes/(portal)/portal/fravaer/+page.server.ts create mode 100644 programmerbar-web/src/routes/(portal)/portal/fravaer/+page.svelte diff --git a/programmerbar-web/migrations/0030_groovy_paper_doll.sql b/programmerbar-web/migrations/0030_groovy_paper_doll.sql new file mode 100644 index 00000000..e3ca55cd --- /dev/null +++ b/programmerbar-web/migrations/0030_groovy_paper_doll.sql @@ -0,0 +1,10 @@ +CREATE TABLE `unavailability` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `start_at` integer NOT NULL, + `end_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "unavailability_valid_interval" CHECK("unavailability"."end_at" > "unavailability"."start_at") +); +--> statement-breakpoint +CREATE INDEX `unavailability_user_id_idx` ON `unavailability` (`user_id`); \ No newline at end of file diff --git a/programmerbar-web/migrations/meta/0029_snapshot.json b/programmerbar-web/migrations/meta/0029_snapshot.json index 11930225..c42fe16a 100644 --- a/programmerbar-web/migrations/meta/0029_snapshot.json +++ b/programmerbar-web/migrations/meta/0029_snapshot.json @@ -2,7 +2,7 @@ "version": "6", "dialect": "sqlite", "id": "7cf34021-def4-4271-a8d3-ee7ef8f93136", - "prevId": "e982dbc1-a960-4bbf-ad49-b48bdc24d6d8", + "prevId": "b72667c7-1e6f-4cd5-9ec8-32b41ec5a89b", "tables": { "claimed_credits": { "name": "claimed_credits", @@ -1205,4 +1205,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/programmerbar-web/migrations/meta/0030_snapshot.json b/programmerbar-web/migrations/meta/0030_snapshot.json new file mode 100644 index 00000000..4f3b5815 --- /dev/null +++ b/programmerbar-web/migrations/meta/0030_snapshot.json @@ -0,0 +1,1273 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dd124260-ab89-4465-837c-ce3db2fd07ba", + "prevId": "7cf34021-def4-4271-a8d3-ee7ef8f93136", + "tables": { + "claimed_credits": { + "name": "claimed_credits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credit_cost": { + "name": "credit_cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "claimed_credits_user_id_user_id_fk": { + "name": "claimed_credits_user_id_user_id_fk", + "tableFrom": "claimed_credits", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board_member": { + "name": "board_member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_submission": { + "name": "contact_submission", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_slug_idx": { + "name": "event_slug_idx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_user_id": { + "name": "idx_notifications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_notifications_archived_at": { + "name": "idx_notifications_archived_at", + "columns": [ + "archived_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscription": { + "name": "push_subscription", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "push_subscription_endpoint_unique": { + "name": "push_subscription_endpoint_unique", + "columns": [ + "endpoint" + ], + "isUnique": true + }, + "idx_push_subscriptions_user_id": { + "name": "idx_push_subscriptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "push_subscription_user_id_user_id_fk": { + "name": "push_subscription_user_id_user_id_fk", + "tableFrom": "push_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shift": { + "name": "shift", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_at": { + "name": "start_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_at": { + "name": "end_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "shift_event_id_event_id_fk": { + "name": "shift_event_id_event_id_fk", + "tableFrom": "shift", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_shift": { + "name": "user_shift", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shift_id": { + "name": "shift_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_beer_claimed": { + "name": "is_beer_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'accepted'" + } + }, + "indexes": { + "user_shift_user_id_shift_id_unique": { + "name": "user_shift_user_id_shift_id_unique", + "columns": [ + "user_id", + "shift_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_shift_user_id_user_id_fk": { + "name": "user_shift_user_id_user_id_fk", + "tableFrom": "user_shift", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_shift_shift_id_shift_id_fk": { + "name": "user_shift_shift_id_shift_id_fk", + "tableFrom": "user_shift", + "tableTo": "shift", + "columnsFrom": [ + "shift_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unavailability": { + "name": "unavailability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_at": { + "name": "start_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_at": { + "name": "end_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unavailability_user_id_idx": { + "name": "unavailability_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "unavailability_user_id_user_id_fk": { + "name": "unavailability_user_id_user_id_fk", + "tableFrom": "unavailability", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "unavailability_valid_interval": { + "name": "unavailability_valid_interval", + "value": "\"unavailability\".\"end_at\" > \"unavailability\".\"start_at\"" + } + } + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feide_id": { + "name": "feide_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "additional_beers": { + "name": "additional_beers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "alt_email": { + "name": "alt_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_trained": { + "name": "is_trained", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "can_refer": { + "name": "can_refer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": true + }, + "feide_id_idx": { + "name": "feide_id_idx", + "columns": [ + "feide_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "producers": { + "name": "producers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_types": { + "name": "product_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "products": { + "name": "products", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sold_out": { + "name": "is_sold_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ordinary_price": { + "name": "ordinary_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_price": { + "name": "student_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_price": { + "name": "internal_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "volume": { + "name": "volume", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alcohol_content": { + "name": "alcohol_content", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "variants": { + "name": "variants", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "producer_id": { + "name": "producer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "products_producer_id_producers_id_fk": { + "name": "products_producer_id_producers_id_fk", + "tableFrom": "products", + "tableTo": "producers", + "columnsFrom": [ + "producer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_product_types": { + "name": "product_product_types", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_type_id": { + "name": "product_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_product_types_product_id_products_id_fk": { + "name": "product_product_types_product_id_products_id_fk", + "tableFrom": "product_product_types", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_product_types_product_type_id_product_types_id_fk": { + "name": "product_product_types_product_type_id_product_types_id_fk", + "tableFrom": "product_product_types", + "tableTo": "product_types", + "columnsFrom": [ + "product_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "images": { + "name": "images", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "referral": { + "name": "referral", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "referred_by": { + "name": "referred_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referred": { + "name": "referred", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referred_by_user_id_fk": { + "name": "referral_referred_by_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_user_id_fk": { + "name": "referral_referred_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_application": { + "name": "pending_application", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feide_id": { + "name": "feide_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_application_email_idx": { + "name": "pending_application_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "pending_application_feide_id_idx": { + "name": "pending_application_feide_id_idx", + "columns": [ + "feide_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/programmerbar-web/migrations/meta/_journal.json b/programmerbar-web/migrations/meta/_journal.json index f146bdab..12fff741 100644 --- a/programmerbar-web/migrations/meta/_journal.json +++ b/programmerbar-web/migrations/meta/_journal.json @@ -203,6 +203,7 @@ "version": "6", "when": 1789667294738, "tag": "0028_tiresome_mister_fear", + "breakpoints": true }, { "idx": 29, @@ -210,6 +211,13 @@ "when": 1789753789061, "tag": "0029_dear_mattie_franklin", "breakpoints": true + }, + { + "idx": 30, + "version": "6", + "when": 1790965848085, + "tag": "0030_groovy_paper_doll", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/programmerbar-web/src/lib/api/remotes/events.remote.ts b/programmerbar-web/src/lib/api/remotes/events.remote.ts index b8970d20..96816fe3 100644 --- a/programmerbar-web/src/lib/api/remotes/events.remote.ts +++ b/programmerbar-web/src/lib/api/remotes/events.remote.ts @@ -3,6 +3,7 @@ import type { ShiftEmailProps } from '$lib/server/services/email.service'; import { normalDate } from '$lib/utils/date'; import { z } from 'zod'; import { command, getRequestEvent } from '$app/server'; +import { isHttpError } from '@sveltejs/kit'; export const createEvent = command(CreateEventSchema, async (event) => { const { locals, platform } = getRequestEvent(); @@ -17,6 +18,18 @@ export const createEvent = command(CreateEventSchema, async (event) => { const { name, date, slug, description, shifts } = event; await locals.eventService.assertActiveVolunteers(shifts.flatMap((shift) => shift.users)); + try { + await locals.shiftService.assertAvailable( + shifts.map((shift) => ({ + startAt: new Date(shift.startAt), + endAt: new Date(shift.endAt), + users: shift.users + })) + ); + } catch (cause) { + if (isHttpError(cause)) return { success: false, message: cause.body.message }; + throw cause; + } const createdEvent = await locals.eventService.create(name, date, slug, description); if (!createdEvent) { diff --git a/programmerbar-web/src/lib/availability-periods.test.ts b/programmerbar-web/src/lib/availability-periods.test.ts new file mode 100644 index 00000000..615ec107 --- /dev/null +++ b/programmerbar-web/src/lib/availability-periods.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { + recurringAvailabilitySchema, + recurringPeriods, + dateRangeSchema, + wholeDayPeriod +} from './availability-periods'; +import { toLocalDateTimeString } from './utils/date'; + +describe('repeating availability', () => { + it('includes both date boundaries and permits a single selected day', () => { + const input = { startDate: '2026-09-01', endDate: '2026-09-01', weekdays: [2] }; + expect(recurringAvailabilitySchema.safeParse(input).success).toBe(true); + const periods = recurringPeriods(input, new Date('2026-01-01')); + expect(periods).toHaveLength(1); + expect(toLocalDateTimeString(periods[0].endAt)).toBe('2026-09-02T00:00'); + expect(recurringPeriods({ ...input, weekdays: [3] }, new Date('2026-01-01'))).toEqual([]); + }); + it('creates every Tuesday and Wednesday for one calendar month', () => { + const periods = recurringPeriods( + { startDate: '2026-09-01', endDate: '2026-09-30', weekdays: [2, 3] }, + new Date('2026-09-01T00:00Z') + ); + expect(periods).toHaveLength(10); + expect(toLocalDateTimeString(periods[0].startAt)).toBe('2026-09-01T00:00'); + expect(toLocalDateTimeString(periods.at(-1)!.startAt)).toBe('2026-09-30T00:00'); + }); + it('supports custom date ranges and ignores duplicate weekdays', () => { + const periods = recurringPeriods( + { startDate: '2026-01-31', endDate: '2026-03-30', weekdays: [2, 2] }, + new Date('2026-01-01') + ); + expect(periods).toHaveLength(8); + expect(toLocalDateTimeString(periods.at(-1)!.startAt)).toBe('2026-03-24T00:00'); + const february = recurringPeriods( + { startDate: '2026-01-31', endDate: '2026-02-27', weekdays: [6] }, + new Date('2026-01-01') + ); + expect(toLocalDateTimeString(february.at(-1)!.startAt)).toBe('2026-02-21T00:00'); + }); + it('keeps local midnights across both daylight saving transitions', () => { + for (const [startDate, expectedHours] of [ + ['2026-03-29', 23], + ['2026-10-25', 25] + ] as const) { + const [period] = recurringPeriods( + { startDate, endDate: startDate, weekdays: [0] }, + new Date('2026-01-01') + ); + expect(toLocalDateTimeString(period.startAt)).toBe(`${startDate}T00:00`); + expect((period.endAt.getTime() - period.startAt.getTime()) / 3600000).toBe(expectedHours); + } + }); + it('omits expired occurrences', () => { + const periods = recurringPeriods( + { startDate: '2026-09-01', endDate: '2026-09-30', weekdays: [2] }, + new Date('2026-09-16') + ); + expect(periods).toHaveLength(2); + }); + it('validates dates, weekday selection and bounded duration', () => { + for (const change of [ + { weekdays: [] }, + { weekdays: [7] }, + { endDate: '2026-08-31' }, + { endDate: '2028-01-01' }, + { endDate: 'bad' }, + { startDate: '2026-02-30' } + ]) { + expect( + recurringAvailabilitySchema.safeParse({ + startDate: '2026-09-01', + endDate: '2026-09-30', + weekdays: [2], + ...change + }).success + ).toBe(false); + } + expect(dateRangeSchema.safeParse({ startDate: 'bad', endDate: '2026-09-30' }).success).toBe( + false + ); + }); +}); + +describe('whole day date ranges', () => { + it('includes the end date even across years', () => { + const period = wholeDayPeriod('2026-12-28', '2027-01-03'); + expect(toLocalDateTimeString(period.startAt)).toBe('2026-12-28T00:00'); + expect(toLocalDateTimeString(period.endAt)).toBe('2027-01-04T00:00'); + }); + it('handles a daylight saving week as seven local days', () => { + const period = wholeDayPeriod('2026-03-23', '2026-03-29'); + expect(toLocalDateTimeString(period.startAt)).toBe('2026-03-23T00:00'); + expect(toLocalDateTimeString(period.endAt)).toBe('2026-03-30T00:00'); + expect((period.endAt.getTime() - period.startAt.getTime()) / 3600000).toBe(167); + }); +}); diff --git a/programmerbar-web/src/lib/availability-periods.ts b/programmerbar-web/src/lib/availability-periods.ts new file mode 100644 index 00000000..bdd2195c --- /dev/null +++ b/programmerbar-web/src/lib/availability-periods.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { parseDateTimeLocal, toLocalDateTimeString } from './utils/date'; +import type { Interval } from './shift-planning'; + +const calendarDate = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => { + const date = new Date(`${value}T00:00:00Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; + }, 'Velg en gyldig dato.'); + +export const dateRangeSchema = z + .object({ + startDate: calendarDate, + endDate: calendarDate + }) + .refine( + (value) => value.endDate >= value.startDate, + 'Til-dato må være lik eller etter fra-dato.' + ); + +export const recurringAvailabilitySchema = dateRangeSchema + .safeExtend({ + weekdays: z.array(z.coerce.number().int().min(0).max(6)).min(1, 'Velg minst én ukedag.').max(7) + }) + .refine( + (value) => + new Date(value.endDate).getTime() - new Date(value.startDate).getTime() < 366 * 86400000, + 'Velg maksimalt ett år om gangen.' + ); + +// UTC dates are calendar cursors only. Each boundary is converted separately to Oslo time, +// so a whole day remains midnight-to-midnight across daylight saving changes. +const localMidnight = (date: Date) => + parseDateTimeLocal(`${date.toISOString().slice(0, 10)}T00:00`); +const nextDay = (date: Date) => new Date(date.getTime() + 24 * 60 * 60 * 1000); + +export function recurringPeriods( + input: z.infer, + now: Date +): Interval[] { + let cursor = new Date(`${input.startDate}T00:00:00Z`); + const end = new Date(`${input.endDate}T00:00:00Z`); + const periods: Interval[] = []; + while (cursor <= end) { + const tomorrow = nextDay(cursor); + const period = { startAt: localMidnight(cursor), endAt: localMidnight(tomorrow) }; + if (input.weekdays.includes(cursor.getUTCDay()) && period.endAt > now) periods.push(period); + cursor = tomorrow; + } + return periods; +} + +export function wholeDayPeriod(startDate: string, endDate: string): Interval { + return { + startAt: localMidnight(new Date(`${startDate}T00:00:00Z`)), + endAt: localMidnight(nextDay(new Date(`${endDate}T00:00:00Z`))) + }; +} + +export const todayInOslo = (now: Date) => toLocalDateTimeString(now).slice(0, 10); diff --git a/programmerbar-web/src/lib/components/portal/PortalSidebar.svelte b/programmerbar-web/src/lib/components/portal/PortalSidebar.svelte index 7217cbfb..b1954379 100644 --- a/programmerbar-web/src/lib/components/portal/PortalSidebar.svelte +++ b/programmerbar-web/src/lib/components/portal/PortalSidebar.svelte @@ -45,6 +45,7 @@ // Main portal navigation items const portalRoutes = [ + { name: 'Når jeg ikke kan stå', routeId: '/(portal)/portal/fravaer', icon: Calendar }, { name: 'Hjem', routeId: '/(portal)/portal', @@ -71,6 +72,7 @@ const adminRoutes = $derived( user.current?.role === 'board' ? ([ + { name: 'Vaktoversikt', routeId: '/(portal)/portal/admin/vaktoversikt', icon: Users }, { name: 'Status', routeId: '/(portal)/portal/status', diff --git a/programmerbar-web/src/lib/components/portal/ShiftRecommendations.svelte b/programmerbar-web/src/lib/components/portal/ShiftRecommendations.svelte new file mode 100644 index 00000000..bd1bf12e --- /dev/null +++ b/programmerbar-web/src/lib/components/portal/ShiftRecommendations.svelte @@ -0,0 +1,165 @@ + + +
+
+

Anbefalte frivillige

+ + + + + + + Bare vakter i samme semester teller. Færre vakter gir høyere prioritet, og vakter + innenfor 14 dager i semesteret trekker ned. Ved lik poengsum trekkes rekkefølgen + tilfeldig når siden lastes. Styret velges manuelt. Vår: januar–juni. Høst: + juli–desember. + + + + +
+ {#if !valid} +

Velg start og slutt for å se anbefalinger.

+ {:else} +

+ {semesterFor(target.startAt).label} +

+ +
+ {#each recommended as user (user.id)} +
+
+

{user.name}

+

+ {user.completed} fullført · {user.upcoming} kommende · Siste: {user.lastShift + ? formatDate(user.lastShift) + : 'Ingen'} +

+ {#if user.recent}

Har vakt innenfor 14 dager av denne vakten

{/if} +
+ +
+ {:else}

Ingen tilgjengelige frivillige som passer søket.

{/each} +
+
+ Styret og utilgjengelige ({others.length}) + {#each others as user (user.id)} +
+
+

{user.name}{user.role === 'board' ? ' · Styret (valgfritt)' : ''}

+ {#if user.unavailable}

Kan ikke stå i denne perioden

{/if} + {#if user.busy}

Overlappende vakt

{/if} +
+ +
+ {/each} +
+ {#each ranked.filter((user) => (user.unavailable || user.busy) && drafts[index].users.some((selected) => selected.id === user.id)) as user (user.id)} + + {/each} + {/if} +
diff --git a/programmerbar-web/src/lib/components/styles/button-variant.ts b/programmerbar-web/src/lib/components/styles/button-variant.ts index 0dc254dd..186643fe 100644 --- a/programmerbar-web/src/lib/components/styles/button-variant.ts +++ b/programmerbar-web/src/lib/components/styles/button-variant.ts @@ -1,7 +1,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; export const buttonVariant = cva( - 'flex items-center justify-center border rounded-lg font-medium transition-all ', + 'flex cursor-pointer items-center justify-center border rounded-lg font-medium transition-all disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ', { variants: { intent: { diff --git a/programmerbar-web/src/lib/server/db/schemas/index.ts b/programmerbar-web/src/lib/server/db/schemas/index.ts index dd5c632f..7c998e7b 100644 --- a/programmerbar-web/src/lib/server/db/schemas/index.ts +++ b/programmerbar-web/src/lib/server/db/schemas/index.ts @@ -8,6 +8,7 @@ export * from './push-subscriptions'; export * from './sessions'; export * from './shifts'; export * from './user-shifts'; +export * from './unavailability'; export * from './users'; export * from './producers'; export * from './product-types'; diff --git a/programmerbar-web/src/lib/server/db/schemas/unavailability.ts b/programmerbar-web/src/lib/server/db/schemas/unavailability.ts new file mode 100644 index 00000000..15cf3036 --- /dev/null +++ b/programmerbar-web/src/lib/server/db/schemas/unavailability.ts @@ -0,0 +1,20 @@ +import { sqliteTable, text, integer, index, check } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { users } from './users'; + +export const unavailability = sqliteTable( + 'unavailability', + { + id: text().primaryKey().$defaultFn(nanoid), + userId: text() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + startAt: integer({ mode: 'timestamp' }).notNull(), + endAt: integer({ mode: 'timestamp' }).notNull() + }, + (table) => [ + index('unavailability_user_id_idx').on(table.userId), + check('unavailability_valid_interval', sql`${table.endAt} > ${table.startAt}`) + ] +); diff --git a/programmerbar-web/src/lib/server/services/shift.service.test.ts b/programmerbar-web/src/lib/server/services/shift.service.test.ts new file mode 100644 index 00000000..c2069c24 --- /dev/null +++ b/programmerbar-web/src/lib/server/services/shift.service.test.ts @@ -0,0 +1,119 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { drizzle } from 'drizzle-orm/sqlite-proxy'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Database } from '../db/drizzle'; +import { ShiftService } from './shift.service'; + +describe('shift planning persistence', () => { + let sqlite: DatabaseSync; + let service: ShiftService; + const period = { startAt: new Date('2026-10-09T16:00Z'), endAt: new Date('2026-10-09T20:00Z') }; + beforeEach(() => { + // Historical Drizzle migrations use SQLite's legacy double-quoted string syntax. + sqlite = new DatabaseSync(':memory:', { enableDoubleQuotedStringLiterals: true }); + const migrations = new URL('../../../../migrations/', import.meta.url); + for (const file of readdirSync(migrations) + .filter((file) => file.endsWith('.sql')) + .sort()) { + sqlite.exec(readFileSync(new URL(file, migrations), 'utf8')); + } + sqlite.exec(`INSERT INTO user (id, name, email, role) VALUES + ('a', 'Anna', 'a@example.com', 'normal'), ('b', 'Bjørn', 'b@example.com', 'board');`); + const db = drizzle( + async (query, params) => { + const statement = sqlite.prepare(query); + statement.setReturnArrays(true); + return { rows: statement.all(...params) }; + }, + async (batch) => { + sqlite.exec('BEGIN'); + try { + const results = batch.map(({ sql, params }) => { + expect(params.length).toBeLessThanOrEqual(100); + const statement = sqlite.prepare(sql); + statement.setReturnArrays(true); + return { rows: statement.all(...params) }; + }); + sqlite.exec('COMMIT'); + return results; + } catch (cause) { + sqlite.exec('ROLLBACK'); + throw cause; + } + }, + { casing: 'snake_case' } + ); + service = new ShiftService(db as unknown as Database); + }); + afterEach(() => sqlite.close()); + + it('saves a large recurring selection in bounded statements and reports assigned shift conflicts', async () => { + seedAssignments(); + const periods = Array.from({ length: 60 }, (_, index) => ({ + startAt: new Date(period.startAt.getTime() + index * 86400000), + endAt: new Date(period.endAt.getTime() + index * 86400000) + })); + expect(await service.addAbsences('a', periods)).toBe(true); + expect(await service.findAbsences('a')).toHaveLength(60); + expect(await service.findAbsences('b')).toEqual([]); + }); + + it('rolls back the entire recurring selection if a later statement fails', async () => { + await expect( + service.addAbsences('a', [ + ...Array.from({ length: 20 }, () => period), + { startAt: period.endAt, endAt: period.startAt } + ]) + ).rejects.toThrow(); + expect(await service.findAbsences('a')).toEqual([]); + }); + + function seedAssignments() { + sqlite.exec(`INSERT INTO event (id, name, date) VALUES ('event', 'Bar', 1791561600); + INSERT INTO shift (id, event_id, start_at, end_at) VALUES ('shift', 'event', ${period.startAt.getTime() / 1000}, ${period.endAt.getTime() / 1000}); + INSERT INTO user_shift (user_id, shift_id, created_at, status) VALUES ('a', 'shift', 1, 'accepted'), ('b', 'shift', 1, 'denied');`); + } + + it('persists absences and prevents another user from reading or deleting them', async () => { + expect(await service.addAbsence('a', period)).toBe(false); + const [absence] = await service.findAbsences('a'); + expect(absence).toMatchObject(period); + expect(await service.findAbsences('b')).toEqual([]); + expect(await service.deleteAbsence('b', absence.id)).toEqual([]); + expect(await service.findAbsences('a')).toHaveLength(1); + expect(await service.deleteAbsence('a', absence.id)).toHaveLength(1); + }); + + it('counts only accepted assignments and excludes the edited event when planning', async () => { + seedAssignments(); + const planning = await service.planningData(); + expect(planning.assignments).toEqual([ + { ...period, userId: 'a', shiftId: 'shift', eventId: 'event' } + ]); + expect((await service.planningData('event')).assignments).toEqual([]); + await expect(service.assertAvailable([{ ...period, users: ['a'] }])).rejects.toMatchObject({ + status: 400 + }); + await expect( + service.assertAvailable([{ ...period, users: ['a'] }], 'event') + ).resolves.toBeUndefined(); + }); + + it('retains an existing assignment when absence is registered and detects the conflict', async () => { + seedAssignments(); + expect(await service.addAbsence('a', period)).toBe(true); + expect((await service.planningData()).assignments).toHaveLength(1); + await expect( + service.assertAvailable([{ ...period, users: ['a'] }], 'event') + ).rejects.toMatchObject({ status: 400 }); + }); + + it('enforces valid intervals at the database level', () => { + expect(() => + sqlite.exec( + "INSERT INTO unavailability (id, user_id, start_at, end_at) VALUES ('bad', 'a', 10, 5)" + ) + ).toThrow(); + }); +}); diff --git a/programmerbar-web/src/lib/server/services/shift.service.ts b/programmerbar-web/src/lib/server/services/shift.service.ts index d1b18f33..d56d3497 100644 --- a/programmerbar-web/src/lib/server/services/shift.service.ts +++ b/programmerbar-web/src/lib/server/services/shift.service.ts @@ -1,5 +1,7 @@ import type { Database } from '$lib/server/db/drizzle'; -import { events, shifts, userShifts } from '$lib/server/db/schemas'; +import { events, shifts, userShifts, unavailability, users } from '../db/schemas'; +import { findPlanningConflict, overlaps, type PlannedShift } from '../../shift-planning'; +import { error } from '@sveltejs/kit'; import { eq, and, lte, gte, inArray, asc } from 'drizzle-orm'; export class ShiftService { @@ -9,6 +11,82 @@ export class ShiftService { this.#db = db; } + async planningData(excludeEventId?: string) { + const [assignments, absences, volunteers] = await Promise.all([ + this.#db + .select({ + userId: userShifts.userId, + shiftId: shifts.id, + eventId: shifts.eventId, + startAt: shifts.startAt, + endAt: shifts.endAt + }) + .from(userShifts) + .innerJoin(shifts, eq(shifts.id, userShifts.shiftId)) + .where(eq(userShifts.status, 'accepted')), + this.#db.select().from(unavailability), + this.#db + .select({ id: users.id, name: users.name, role: users.role }) + .from(users) + .where(eq(users.isDeleted, false)) + ]); + return { + assignments: assignments.filter((shift) => shift.eventId !== excludeEventId), + absences, + // Draw once per page load; reuse on the client when filtering and editing shifts. + volunteers: volunteers.map((user) => ({ ...user, tieBreaker: Math.random() })), + now: new Date() + }; + } + + async assertAvailable(planned: PlannedShift[], excludeEventId?: string) { + const { assignments, absences } = await this.planningData(excludeEventId); + const conflict = findPlanningConflict(planned, assignments, absences); + if (conflict) error(400, conflict); + } + + async findAbsences(userId: string) { + return this.#db + .select() + .from(unavailability) + .where(eq(unavailability.userId, userId)) + .orderBy(asc(unavailability.startAt)); + } + + async addAbsence(userId: string, period: { startAt: Date; endAt: Date }) { + const assigned = await this.#db + .select({ startAt: shifts.startAt, endAt: shifts.endAt }) + .from(userShifts) + .innerJoin(shifts, eq(shifts.id, userShifts.shiftId)) + .where(and(eq(userShifts.userId, userId), eq(userShifts.status, 'accepted'))); + await this.#db.insert(unavailability).values({ userId, ...period }); + return assigned.some((shift) => overlaps(shift, period)); + } + + async deleteAbsence(userId: string, id: string) { + return this.#db + .delete(unavailability) + .where(and(eq(unavailability.id, id), eq(unavailability.userId, userId))) + .returning(); + } + + async addAbsences(userId: string, periods: { startAt: Date; endAt: Date }[]) { + if (!periods.length) return false; + const assigned = await this.#db + .select({ startAt: shifts.startAt, endAt: shifts.endAt }) + .from(userShifts) + .innerJoin(shifts, eq(shifts.id, userShifts.shiftId)) + .where(and(eq(userShifts.userId, userId), eq(userShifts.status, 'accepted'))); + const insertChunk = (chunk: typeof periods) => + this.#db.insert(unavailability).values(chunk.map((period) => ({ userId, ...period }))); + // Keep each statement below D1's parameter limit. The batch is atomic. + const first = insertChunk(periods.slice(0, 20)); + const rest = []; + for (let i = 20; i < periods.length; i += 20) rest.push(insertChunk(periods.slice(i, i + 20))); + await this.#db.batch([first, ...rest]); + return assigned.some((shift) => periods.some((period) => overlaps(shift, period))); + } + async findCompletedShiftsByUserId(userId: string) { const completedShifts = await this.#db .select() diff --git a/programmerbar-web/src/lib/shift-planning.test.ts b/programmerbar-web/src/lib/shift-planning.test.ts new file mode 100644 index 00000000..327d05b4 --- /dev/null +++ b/programmerbar-web/src/lib/shift-planning.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; +import { + findPlanningConflict, + overlaps, + rankVolunteers, + shiftStatistics, + semesterFor, + assignmentsInSemester, + type Assignment +} from './shift-planning'; +import { availabilitySchema } from './validators/availability'; + +const now = new Date('2026-10-01T12:00:00Z'); +const target = { + startAt: new Date('2026-10-09T16:00:00Z'), + endAt: new Date('2026-10-09T20:00:00Z') +}; +const volunteer = (id: string, role = 'normal') => ({ id, name: id, role }); +const assignment = (userId: string, start: string, end: string): Assignment => ({ + userId, + shiftId: start, + eventId: 'event', + startAt: new Date(start), + endAt: new Date(end) +}); + +describe('shift recommendations', () => { + it('breaks equal scores using the saved random draw instead of name or last shift', () => { + const people = [ + { ...volunteer('Anna'), tieBreaker: 0.8 }, + { ...volunteer('Zara'), tieBreaker: 0.2 } + ]; + const shifts = [ + assignment('Anna', '2026-08-01T16:00Z', '2026-08-01T20:00Z'), + assignment('Zara', '2026-09-01T16:00Z', '2026-09-01T20:00Z') + ]; + const ranked = rankVolunteers(people, shifts, [], target, now); + expect(ranked[0].score).toBe(ranked[1].score); + expect(ranked.map((user) => user.id)).toEqual(['Zara', 'Anna']); + expect(rankVolunteers([...people].reverse(), shifts, [], target, now)).toEqual(ranked); + expect( + rankVolunteers( + people.map((user) => ({ ...user, tieBreaker: 1 - user.tieBreaker })), + shifts, + [], + target, + now + ).map((user) => user.id) + ).toEqual(['Anna', 'Zara']); + }); + + it('always prioritizes score before the random draw', () => { + const people = [ + { ...volunteer('new'), tieBreaker: 0.99 }, + { ...volunteer('experienced'), tieBreaker: 0 } + ]; + const shifts = [assignment('experienced', '2026-08-01T16:00Z', '2026-08-01T20:00Z')]; + expect(rankVolunteers(people, shifts, [], target, now).map((user) => user.id)).toEqual([ + 'new', + 'experienced' + ]); + }); + it('ignores previous semesters for workload, recency and tie-breaking', () => { + const january = { + startAt: new Date('2027-01-02T16:00Z'), + endAt: new Date('2027-01-02T20:00Z') + }; + const people = [volunteer('a'), volunteer('b')]; + const previous = [ + assignment('a', '2026-12-31T16:00Z', '2026-12-31T20:00Z'), + assignment('a', '2026-12-25T16:00Z', '2026-12-25T20:00Z'), + assignment('b', '2026-06-25T16:00Z', '2026-06-25T20:00Z') + ]; + expect(rankVolunteers(people, previous, [], january, now)).toEqual( + rankVolunteers(people, [], [], january, now) + ); + }); + + it('uses the target semester rather than the current semester and keeps cross-semester conflicts', () => { + const january = { + startAt: new Date('2026-12-31T23:00Z'), + endAt: new Date('2027-01-01T03:00Z') + }; + const rows = rankVolunteers( + [volunteer('a')], + [ + assignment('a', '2026-12-31T22:00Z', '2027-01-01T02:00Z'), + assignment('a', '2027-01-05T16:00Z', '2027-01-05T20:00Z') + ], + [], + january, + now + ); + expect(rows[0].upcoming).toBe(1); + expect(rows[0].busy).toBe(true); + }); + + it('counts shifts by their start in Norwegian time at both semester boundaries', () => { + expect(semesterFor(new Date('2026-06-30T21:59Z')).id).toBe('2026-1'); + expect(semesterFor(new Date('2026-06-30T22:00Z')).id).toBe('2026-2'); + expect(semesterFor(new Date('2026-12-31T23:00Z')).id).toBe('2027-1'); + const shifts = [ + assignment('a', '2026-06-30T21:00Z', '2026-07-01T02:00Z'), + assignment('a', '2026-06-30T22:00Z', '2026-07-01T03:00Z'), + assignment('a', '2026-12-31T23:00Z', '2027-01-01T03:00Z') + ]; + expect(assignmentsInSemester(shifts, semesterFor(now))).toEqual([shifts[1]]); + }); + it('prioritizes volunteers with fewer shifts, including those with no history', () => { + const rows = rankVolunteers( + [volunteer('experienced'), volunteer('new')], + [assignment('experienced', '2026-09-01T16:00Z', '2026-09-01T20:00Z')], + [], + target, + now + ); + expect(rows.map((row) => row.id)).toEqual(['new', 'experienced']); + }); + it('can rank a person with fewer shifts lower when they have a shift the previous week', () => { + const rows = rankVolunteers( + [volunteer('recent'), volunteer('rested')], + [ + assignment('recent', '2026-10-02T16:00Z', '2026-10-02T20:00Z'), + assignment('rested', '2026-09-01T16:00Z', '2026-09-01T20:00Z'), + assignment('rested', '2026-09-08T16:00Z', '2026-09-08T20:00Z') + ], + [], + target, + now + ); + expect(rows.map((row) => row.id)).toEqual(['rested', 'recent']); + expect(rows[1].recent).toBe(true); + }); + it('flags absence and overlapping assignments while retaining board for manual selection', () => { + const rows = rankVolunteers( + [ + volunteer('absent'), + volunteer('busy'), + volunteer('board', 'board'), + volunteer('inactive', 'inactive') + ], + [{ ...target, userId: 'busy', shiftId: 'other', eventId: 'other' }], + [{ ...target, userId: 'absent' }], + target, + now + ); + expect(rows.find((row) => row.id === 'absent')?.unavailable).toBe(true); + expect(rows.find((row) => row.id === 'busy')?.busy).toBe(true); + expect(rows.find((row) => row.id === 'board')?.role).toBe('board'); + expect(rows.some((row) => row.id === 'inactive')).toBe(false); + expect(rows.filter((row) => row.role === 'normal' && !row.busy && !row.unavailable)).toEqual( + [] + ); + }); + it('counts completed and upcoming separately and uses only completed shifts for last shift', () => { + const past = assignment('a', '2026-09-01T16:00Z', '2026-09-01T20:00Z'); + const future = assignment('a', '2026-10-02T16:00Z', '2026-10-02T20:00Z'); + expect(shiftStatistics('a', [past, future], now)).toEqual({ + completed: 1, + upcoming: 1, + lastShift: past.endAt + }); + expect(shiftStatistics('new', [past, future], now)).toEqual({ + completed: 0, + upcoming: 0, + lastShift: null + }); + }); +}); + +describe('availability conflicts', () => { + it('allows touching boundaries but rejects partial and enclosing overlaps', () => { + expect(overlaps(target, { startAt: target.endAt, endAt: new Date('2026-10-09T23:00Z') })).toBe( + false + ); + expect( + overlaps(target, { + startAt: new Date('2026-10-09T19:00Z'), + endAt: new Date('2026-10-10T02:00Z') + }) + ).toBe(true); + expect( + overlaps(target, { + startAt: new Date('2026-10-08T00:00Z'), + endAt: new Date('2026-10-11T00:00Z') + }) + ).toBe(true); + }); + it('rejects absences, existing assignments and conflicts within the same unsaved event', () => { + const planned = [{ ...target, users: ['a'] }]; + expect(findPlanningConflict(planned, [], [{ ...target, userId: 'a' }])).toContain( + 'ikke kan stå' + ); + expect( + findPlanningConflict(planned, [{ ...target, userId: 'a', shiftId: 's', eventId: 'e' }], []) + ).toContain('overlapper'); + expect(findPlanningConflict([...planned, ...planned], [], [])).toContain('overlapper'); + expect(findPlanningConflict(planned, [], [{ ...target, userId: 'b' }])).toBeNull(); + }); + it('validates intervals and converts Norwegian local time across midnight and daylight saving', () => { + const parsed = availabilitySchema.parse({ + startAt: '2026-10-24T23:00', + endAt: '2026-10-25T04:00' + }); + expect(parsed.startAt.toISOString()).toBe('2026-10-24T21:00:00.000Z'); + expect(parsed.endAt.toISOString()).toBe('2026-10-25T03:00:00.000Z'); + for (const value of [ + { startAt: 'bad', endAt: '2026-10-25T04:00' }, + { startAt: '2026-10-25T04:00', endAt: '2026-10-25T04:00' }, + { startAt: '2026-10-25T05:00', endAt: '2026-10-25T04:00' }, + { startAt: '2026-03-29T02:30', endAt: '2026-03-29T04:00' } + ]) + expect(availabilitySchema.safeParse(value).success).toBe(false); + }); +}); diff --git a/programmerbar-web/src/lib/shift-planning.ts b/programmerbar-web/src/lib/shift-planning.ts new file mode 100644 index 00000000..789629e7 --- /dev/null +++ b/programmerbar-web/src/lib/shift-planning.ts @@ -0,0 +1,113 @@ +import { parseDateTimeLocal, toLocalDateTimeString } from './utils/date'; + +export type Interval = { startAt: Date; endAt: Date }; + +export function semesterFor(date: Date) { + const local = toLocalDateTimeString(date); + const year = Number(local.slice(0, 4)); + const autumn = Number(local.slice(5, 7)) >= 7; + return { + id: `${year}-${autumn ? '2' : '1'}`, + label: `${autumn ? 'Høst' : 'Vår'} ${year}`, + startAt: parseDateTimeLocal(`${year}-${autumn ? '07' : '01'}-01T00:00`), + endAt: parseDateTimeLocal(`${autumn ? year + 1 : year}-${autumn ? '01' : '07'}-01T00:00`) + }; +} + +// A shift belongs to the semester in which it starts, in Norwegian time. +export function assignmentsInSemester(assignments: Assignment[], semester: Interval) { + return assignments.filter( + (shift) => shift.startAt >= semester.startAt && shift.startAt < semester.endAt + ); +} +export type Assignment = Interval & { userId: string; shiftId: string; eventId: string }; +export type Absence = Interval & { userId: string }; +export type Volunteer = { id: string; name: string; role: string; tieBreaker?: number }; +export type PlannedShift = Interval & { users: string[] }; + +export function overlaps(a: Interval, b: Interval) { + return a.startAt < b.endAt && b.startAt < a.endAt; +} + +export function shiftStatistics(userId: string, assignments: Assignment[], now: Date) { + const own = assignments.filter((assignment) => assignment.userId === userId); + const completed = own.filter((assignment) => assignment.endAt <= now); + return { + completed: completed.length, + upcoming: own.filter((assignment) => assignment.endAt > now).length, + lastShift: completed.reduce( + (last, shift) => (!last || shift.endAt > last ? shift.endAt : last), + null + ) + }; +} + +// Lower scores are preferred. A nearby assignment adds up to four shifts of weight, +// tapering to zero after 14 days. Planned assignments count toward the workload too. +export function rankVolunteers( + volunteers: Volunteer[], + assignments: Assignment[], + absences: Absence[], + target: Interval, + now: Date +) { + const fortnight = 14 * 24 * 60 * 60 * 1000; + const semesterAssignments = assignmentsInSemester(assignments, semesterFor(target.startAt)); + return volunteers + .filter((user) => user.role === 'normal' || user.role === 'board') + .map((user) => { + const own = assignments.filter((assignment) => assignment.userId === user.id); + const unavailable = absences.some( + (absence) => absence.userId === user.id && overlaps(absence, target) + ); + const busy = own.some((assignment) => overlaps(assignment, target)); + const semesterOwn = semesterAssignments.filter((assignment) => assignment.userId === user.id); + const distance = semesterOwn.reduce( + (nearest, assignment) => + Math.min( + nearest, + Math.max( + 0, + target.startAt.getTime() - assignment.endAt.getTime(), + assignment.startAt.getTime() - target.endAt.getTime() + ) + ), + Infinity + ); + const recentPenalty = 4 * Math.max(0, 1 - distance / fortnight); + return { + ...user, + ...shiftStatistics(user.id, semesterAssignments, now), + unavailable, + busy, + recent: distance < fortnight, + score: semesterOwn.length + recentPenalty + }; + }) + .sort((a, b) => a.score - b.score || (a.tieBreaker ?? 0) - (b.tieBreaker ?? 0)); +} + +export function findPlanningConflict( + planned: PlannedShift[], + assignments: Assignment[], + absences: Absence[] +) { + for (const [index, shift] of planned.entries()) { + for (const userId of shift.users) { + if (absences.some((absence) => absence.userId === userId && overlaps(absence, shift))) { + return 'En valgt person har registrert at de ikke kan stå i denne perioden. Fjern personen eller endre tidspunktet.'; + } + if ( + assignments.some( + (assignment) => assignment.userId === userId && overlaps(assignment, shift) + ) || + planned + .slice(0, index) + .some((other) => other.users.includes(userId) && overlaps(other, shift)) + ) { + return 'En valgt person har en annen vakt som overlapper. Fjern personen eller endre tidspunktet.'; + } + } + } + return null; +} diff --git a/programmerbar-web/src/lib/validators.ts b/programmerbar-web/src/lib/validators.ts index 9720313a..ec17d939 100644 --- a/programmerbar-web/src/lib/validators.ts +++ b/programmerbar-web/src/lib/validators.ts @@ -14,6 +14,7 @@ export const CreateEventSchema = z.object({ endAt: z.coerce.date(), users: z.array(z.string()) }) + .refine((shift) => shift.endAt > shift.startAt, 'Slutt må være etter start.') .array() }); diff --git a/programmerbar-web/src/lib/validators/availability.ts b/programmerbar-web/src/lib/validators/availability.ts new file mode 100644 index 00000000..df54bcdd --- /dev/null +++ b/programmerbar-web/src/lib/validators/availability.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { parseDateTimeLocal, toLocalDateTimeString } from '../utils/date'; + +const localDate = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/) + .transform((value, ctx) => { + const date = parseDateTimeLocal(value); + if (!Number.isFinite(date.getTime()) || toLocalDateTimeString(date) !== value) { + ctx.addIssue({ code: 'custom', message: 'Ugyldig dato eller klokkeslett.' }); + return z.NEVER; + } + return date; + }); + +export const availabilitySchema = z + .object({ startAt: localDate, endAt: localDate }) + .refine((value) => value.endAt > value.startAt, 'Slutt må være etter start.'); diff --git a/programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.server.ts b/programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.server.ts new file mode 100644 index 00000000..c9c89a7a --- /dev/null +++ b/programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.server.ts @@ -0,0 +1,43 @@ +import { error } from '@sveltejs/kit'; +import { shiftStatistics, semesterFor, assignmentsInSemester, overlaps } from '$lib/shift-planning'; +import { parseDateTimeLocal } from '$lib/utils/date'; +import { z } from 'zod'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (locals.user?.role !== 'board') error(403, 'Du har ikke tilgang.'); + const { volunteers, assignments, absences, now } = await locals.shiftService.planningData(); + const requested = z + .string() + .regex(/^[1-9]\d{3}-[12]$/) + .refine((value) => Number(value.slice(0, 4)) < 9999) + .safeParse(url.searchParams.get('semester')); + const semester = requested.success + ? semesterFor( + parseDateTimeLocal( + `${requested.data.slice(0, 4)}-${requested.data.endsWith('2') ? '07' : '01'}-01T00:00` + ) + ) + : semesterFor(now); + const semesters = [ + ...new Map( + [semesterFor(now), semester, ...assignments.map((shift) => semesterFor(shift.startAt))].map( + (item) => [item.id, { id: item.id, label: item.label }] + ) + ).values() + ].sort((a, b) => b.id.localeCompare(a.id)); + const semesterAssignments = assignmentsInSemester(assignments, semester); + return { + semester: { id: semester.id, label: semester.label }, + semesters, + users: volunteers + .map((user) => ({ + ...user, + ...shiftStatistics(user.id, semesterAssignments, now), + absences: absences + .filter((absence) => absence.userId === user.id && overlaps(absence, semester)) + .sort((a, b) => a.startAt.getTime() - b.startAt.getTime()) + })) + .sort((a, b) => a.completed - b.completed || a.name.localeCompare(b.name, 'nb')) + }; +}; diff --git a/programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.svelte b/programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.svelte new file mode 100644 index 00000000..85db5972 --- /dev/null +++ b/programmerbar-web/src/routes/(portal)/portal/admin/vaktoversikt/+page.svelte @@ -0,0 +1,187 @@ + + +Vaktoversikt +
+
+
+
+
+
+

+ {data.semester.label} +

+

+ Viser {filtered.length} av {data.users.length} personer +

+
+
+ + +
+
+
+ + +
+
+ + + + + {#each columns as column (column.key)} + + {/each} + + + + {#each filtered as user (user.id)} + + + + + + + + {:else} + + {/each} + +
Vaktfordeling for {data.semester.label}
+ +
+ {user.name} + {#if user.role !== 'normal'}{user.role === 'board' ? 'Styret' : 'Inaktiv'}{/if} + {user.completed}{user.upcoming}{user.lastShift ? normalDate(user.lastShift) : 'Ingen'} + {#if user.absences.length} +
+ {user.absences.length} + {user.absences.length === 1 ? 'periode' : 'perioder'} +
    + {#each user.absences as absence (absence.id)}
  • + {normalDate(absence.startAt)} – {normalDate(absence.endAt)} +
  • {/each} +
+
+ {:else}Ingen{/if} +
Ingen frivillige som passer søket. Prøv et annet navn eller endre filtrene.
+
+
+ Slik telles vaktene +

+ Vårsemesteret er januar–juni, og høstsemesteret er juli–desember. Vaktens start avgjør + semesteret. Fullførte vakter er aksepterte vakter med passert sluttid; oppmøte registreres + ikke separat. Kommende inkluderer pågående vakter. +

+
+
+
diff --git a/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.server.ts b/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.server.ts index eeab1dba..dd4cf4c7 100644 --- a/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.server.ts +++ b/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.server.ts @@ -1,4 +1,4 @@ -import { error, fail, redirect } from '@sveltejs/kit'; +import { error, fail, redirect, isHttpError } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import type { EventService } from '$lib/server/services/event.service'; @@ -85,6 +85,22 @@ export const actions: Actions = { }); } + const targetEvent = await locals.eventService.findFullEventById(params.id); + const targetShift = targetEvent?.shifts.find((shift) => shift.id === shiftId); + if (!targetShift || targetShift.startAt <= new Date()) + return fail(400, { message: 'Vakten finnes ikke eller har allerede startet.' }); + if ( + targetShift.members.some( + (member) => member.userId === locals.user!.id && member.status === 'accepted' + ) + ) + return { success: true }; + try { + await locals.shiftService.assertAvailable([{ ...targetShift, users: [locals.user.id] }]); + } catch (cause) { + if (isHttpError(cause)) return fail(cause.status, { message: cause.body.message }); + throw cause; + } const created = await locals.eventService.createUserShift({ shiftId, userId: locals.user.id, diff --git a/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.svelte b/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.svelte index e02df974..9b4ddac9 100644 --- a/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.svelte +++ b/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/+page.svelte @@ -10,7 +10,7 @@ import ButtonLink from '$lib/components/ui/ButtonLink.svelte'; import { cn } from '$lib/utils/cn'; - let { data } = $props(); + let { data, form } = $props(); let user = getUser(); let activeTab = $state('details'); let isPastEvent = $derived(new Date() > new Date(data.event.date)); @@ -24,6 +24,7 @@
+ {#if form?.message}{/if}
{ + if (locals.user?.role !== 'board') error(403, 'Du har ikke tilgang.'); const event = await locals.eventService.findFullEventById(params.id); if (!event) { throw error(404, 'Event not found'); @@ -16,6 +19,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { ); return { + planning: await locals.shiftService.planningData(params.id), event, users }; @@ -43,18 +47,49 @@ export const actions: Actions = { if (!originalEvent) return fail(404, { message: 'Event not found' }); const addedUserIds: string[] = []; const count = Number(formData.get('shiftsCount') || 0); + if (!Number.isInteger(count) || count < 0 || count > 100) + return fail(400, { message: 'Ugyldig antall vakter.' }); + const planned: PlannedShift[] = []; for (let i = 0; i < count; i++) { const shiftId = formData.get(`shift[${i}].id`)?.toString(); const existing = originalEvent.shifts.find((shift) => shift.id === shiftId); + if (shiftId && !existing) + return fail(400, { message: 'Vakten tilhører ikke arrangementet.' }); + const period = availabilitySchema.safeParse({ + startAt: formData.get(`shift[${i}].startAt`), + endAt: formData.get(`shift[${i}].endAt`) + }); + if (!period.success) + return fail(400, { message: 'Velg gyldig start og slutt for alle vaktene.' }); + const plannedShift: PlannedShift = { ...period.data, users: [] }; const userCount = Number(formData.get(`shift[${i}].userCount`) || 0); + if (!Number.isInteger(userCount) || userCount < 0 || userCount > 100) + return fail(400, { message: 'Ugyldig antall frivillige.' }); for (let j = 0; j < userCount; j++) { const userId = formData.get(`shift[${i}].user[${j}].id`)?.toString(); + if (userId?.trim()) plannedShift.users.push(userId); if (userId?.trim() && !existing?.members.some((member) => member.user.id === userId)) { addedUserIds.push(userId); } } + planned.push(plannedShift); } await locals.eventService.assertActiveVolunteers(addedUserIds); + try { + await locals.shiftService.assertAvailable(planned, eventId); + } catch (cause) { + if (isHttpError(cause)) return fail(cause.status, { message: cause.body.message }); + throw cause; + } + const ownedShiftIds = new Set(originalEvent.shifts.map((shift) => shift.id)); + if ( + formData.getAll('deletedShiftIds').some((id) => !ownedShiftIds.has(String(id))) || + formData + .getAll('removedUserShifts') + .some((value) => !ownedShiftIds.has(String(value).split('|')[0])) + ) { + return fail(400, { message: 'Vakten tilhører ikke arrangementet.' }); + } const shouldBePublic = formData.get('shouldBePublic') === 'true'; diff --git a/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/rediger/+page.svelte b/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/rediger/+page.svelte index f3b495ca..03d99dac 100644 --- a/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/rediger/+page.svelte +++ b/programmerbar-web/src/routes/(portal)/portal/arrangementer/[id]/rediger/+page.svelte @@ -1,4 +1,5 @@ + +Når jeg ikke kan stå +
+ Når jeg ikke kan stå +

+ Velg datoene du ikke kan stå. Har du allerede en vakt, må du kontakte frivilligansvarlig for å + bytte. +

+ {#if form?.message}

{form.message}

{/if} +
{ + saving = true; + return async ({ update }) => { + try { + await update({ reset: false }); + } finally { + saving = false; + } + }; + }} + class="bg-portal-card space-y-5 rounded-lg border p-5" + > +
+ Registrer når du ikke kan stå +
+ + +
+
+ + +
+ {#if mode === 'recurring'} +
+ Hvilke ukedager? +
+ {#each days as day (day.value)} + + {/each} +
+
+

Gjelder hele dagen på valgte ukedager, til og med sluttdatoen.

+ {#if startDate && endDate && weekdays.length && !selection.success} +

{selection.error.issues[0]?.message}

+ {:else if selection.success && !preview.length} +

Ingen av de valgte ukedagene finnes i perioden.

+ {/if} + {#if preview.length} +
+ {preview.length} dager valgt – se datoene +
    + {#each preview as period (period.startAt.getTime())}
  • + {formatDate(period.startAt)} +
  • {/each} +
+
+ {/if} + {:else} + + + {#if !allDay} +
+ + +
+

+ Fra klokkeslettet på startdatoen til klokkeslettet på sluttdatoen. Norsk tid. +

+ {:else} +

Alle dager i perioden tas med, også sluttdatoen.

+ {/if} + {/if} + +
+
+ Registrerte perioder + {#each data.absences as absence (absence.id)} +
+

{normalDate(absence.startAt)} – {normalDate(absence.endAt)}

+
+ + +
+
+ {:else}

Du har ikke registrert noen perioder du ikke kan stå.

{/each} +
diff --git a/programmerbar-web/src/tailwind.css b/programmerbar-web/src/tailwind.css index 6de203cd..d0f71fc9 100644 --- a/programmerbar-web/src/tailwind.css +++ b/programmerbar-web/src/tailwind.css @@ -7,6 +7,24 @@ @custom-variant dark (&:where(.dark, .dark *)); +@layer base { + #portal-layout + :is( + button, + select, + summary, + [role='button'], + input[type='checkbox'], + input[type='radio'], + input[type='submit'] + ) { + cursor: pointer; + } + #portal-layout :is(:disabled, [aria-disabled='true']) { + cursor: not-allowed; + } +} + :root { --background: #f5f5f5; --foreground: #262626; From db8e206c617f426798380cbcf4ab7c34d980e30d Mon Sep 17 00:00:00 2001 From: FluffyFire <62662486+sturla1511@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:13:30 +0200 Subject: [PATCH 2/3] volunteer-shift-planning --- .../migrations/0031_spicy_gauntlet.sql | 2 + .../migrations/meta/0031_snapshot.json | 1287 +++++++++++++++++ .../migrations/meta/_journal.json | 7 + .../src/lib/components/styles/checkbox.ts | 2 + .../src/lib/components/ui/Checkbox.svelte | 12 +- .../lib/server/db/schemas/unavailability.ts | 4 +- .../lib/server/services/shift.service.test.ts | 41 +- .../src/lib/server/services/shift.service.ts | 37 +- .../(portal)/portal/fravaer/+page.server.ts | 59 +- .../(portal)/portal/fravaer/+page.svelte | 86 +- 10 files changed, 1508 insertions(+), 29 deletions(-) create mode 100644 programmerbar-web/migrations/0031_spicy_gauntlet.sql create mode 100644 programmerbar-web/migrations/meta/0031_snapshot.json create mode 100644 programmerbar-web/src/lib/components/styles/checkbox.ts diff --git a/programmerbar-web/migrations/0031_spicy_gauntlet.sql b/programmerbar-web/migrations/0031_spicy_gauntlet.sql new file mode 100644 index 00000000..1bd4066f --- /dev/null +++ b/programmerbar-web/migrations/0031_spicy_gauntlet.sql @@ -0,0 +1,2 @@ +ALTER TABLE `unavailability` ADD `group_id` text;--> statement-breakpoint +ALTER TABLE `unavailability` ADD `group_label` text; \ No newline at end of file diff --git a/programmerbar-web/migrations/meta/0031_snapshot.json b/programmerbar-web/migrations/meta/0031_snapshot.json new file mode 100644 index 00000000..2dd7aa80 --- /dev/null +++ b/programmerbar-web/migrations/meta/0031_snapshot.json @@ -0,0 +1,1287 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "81478e77-e4c8-48bb-9da0-ba00db7ddf81", + "prevId": "dd124260-ab89-4465-837c-ce3db2fd07ba", + "tables": { + "claimed_credits": { + "name": "claimed_credits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credit_cost": { + "name": "credit_cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "claimed_credits_user_id_user_id_fk": { + "name": "claimed_credits_user_id_user_id_fk", + "tableFrom": "claimed_credits", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board_member": { + "name": "board_member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_submission": { + "name": "contact_submission", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_slug_idx": { + "name": "event_slug_idx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_user_id": { + "name": "idx_notifications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_notifications_archived_at": { + "name": "idx_notifications_archived_at", + "columns": [ + "archived_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscription": { + "name": "push_subscription", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "push_subscription_endpoint_unique": { + "name": "push_subscription_endpoint_unique", + "columns": [ + "endpoint" + ], + "isUnique": true + }, + "idx_push_subscriptions_user_id": { + "name": "idx_push_subscriptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "push_subscription_user_id_user_id_fk": { + "name": "push_subscription_user_id_user_id_fk", + "tableFrom": "push_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shift": { + "name": "shift", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_at": { + "name": "start_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_at": { + "name": "end_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "shift_event_id_event_id_fk": { + "name": "shift_event_id_event_id_fk", + "tableFrom": "shift", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_shift": { + "name": "user_shift", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shift_id": { + "name": "shift_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_beer_claimed": { + "name": "is_beer_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'accepted'" + } + }, + "indexes": { + "user_shift_user_id_shift_id_unique": { + "name": "user_shift_user_id_shift_id_unique", + "columns": [ + "user_id", + "shift_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_shift_user_id_user_id_fk": { + "name": "user_shift_user_id_user_id_fk", + "tableFrom": "user_shift", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_shift_shift_id_shift_id_fk": { + "name": "user_shift_shift_id_shift_id_fk", + "tableFrom": "user_shift", + "tableTo": "shift", + "columnsFrom": [ + "shift_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unavailability": { + "name": "unavailability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_at": { + "name": "start_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_at": { + "name": "end_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_label": { + "name": "group_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "unavailability_user_id_idx": { + "name": "unavailability_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "unavailability_user_id_user_id_fk": { + "name": "unavailability_user_id_user_id_fk", + "tableFrom": "unavailability", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "unavailability_valid_interval": { + "name": "unavailability_valid_interval", + "value": "\"unavailability\".\"end_at\" > \"unavailability\".\"start_at\"" + } + } + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feide_id": { + "name": "feide_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "additional_beers": { + "name": "additional_beers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "alt_email": { + "name": "alt_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_trained": { + "name": "is_trained", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "can_refer": { + "name": "can_refer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": true + }, + "feide_id_idx": { + "name": "feide_id_idx", + "columns": [ + "feide_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "producers": { + "name": "producers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_types": { + "name": "product_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "products": { + "name": "products", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sold_out": { + "name": "is_sold_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ordinary_price": { + "name": "ordinary_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_price": { + "name": "student_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_price": { + "name": "internal_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "volume": { + "name": "volume", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alcohol_content": { + "name": "alcohol_content", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "variants": { + "name": "variants", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "producer_id": { + "name": "producer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "products_producer_id_producers_id_fk": { + "name": "products_producer_id_producers_id_fk", + "tableFrom": "products", + "tableTo": "producers", + "columnsFrom": [ + "producer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_product_types": { + "name": "product_product_types", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_type_id": { + "name": "product_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_product_types_product_id_products_id_fk": { + "name": "product_product_types_product_id_products_id_fk", + "tableFrom": "product_product_types", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_product_types_product_type_id_product_types_id_fk": { + "name": "product_product_types_product_type_id_product_types_id_fk", + "tableFrom": "product_product_types", + "tableTo": "product_types", + "columnsFrom": [ + "product_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "images": { + "name": "images", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "referral": { + "name": "referral", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "referred_by": { + "name": "referred_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referred": { + "name": "referred", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referred_by_user_id_fk": { + "name": "referral_referred_by_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_user_id_fk": { + "name": "referral_referred_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_application": { + "name": "pending_application", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feide_id": { + "name": "feide_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_application_email_idx": { + "name": "pending_application_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "pending_application_feide_id_idx": { + "name": "pending_application_feide_id_idx", + "columns": [ + "feide_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/programmerbar-web/migrations/meta/_journal.json b/programmerbar-web/migrations/meta/_journal.json index 12fff741..ea334b5a 100644 --- a/programmerbar-web/migrations/meta/_journal.json +++ b/programmerbar-web/migrations/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1790965848085, "tag": "0030_groovy_paper_doll", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1791060339063, + "tag": "0031_spicy_gauntlet", + "breakpoints": true } ] } \ No newline at end of file diff --git a/programmerbar-web/src/lib/components/styles/checkbox.ts b/programmerbar-web/src/lib/components/styles/checkbox.ts new file mode 100644 index 00000000..87978bea --- /dev/null +++ b/programmerbar-web/src/lib/components/styles/checkbox.ts @@ -0,0 +1,2 @@ +export const checkboxClass = + 'border-input-border bg-input-background text-primary checked:border-primary checked:bg-primary focus-visible:ring-primary size-4 shrink-0 cursor-pointer rounded border-2 transition-colors focus:ring-0 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:checked:border-primary dark:checked:bg-primary dark:focus-visible:ring-offset-slate-900'; diff --git a/programmerbar-web/src/lib/components/ui/Checkbox.svelte b/programmerbar-web/src/lib/components/ui/Checkbox.svelte index 56d4ac92..01754747 100644 --- a/programmerbar-web/src/lib/components/ui/Checkbox.svelte +++ b/programmerbar-web/src/lib/components/ui/Checkbox.svelte @@ -1,6 +1,7 @@