From 8b943f17bbc4142a4be290f3c2e9bc5a1fa12a90 Mon Sep 17 00:00:00 2001 From: FluffyFire <62662486+sturla1511@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:21:21 +0200 Subject: [PATCH] Automatically open the bar on the front page --- programmerbar-web/src/hooks.server.ts | 2 +- .../server/services/status.service.test.ts | 77 +++++++++++++++++++ .../src/lib/server/services/status.service.ts | 37 +++++++-- .../(portal)/portal/status/+page.server.ts | 10 ++- .../(portal)/portal/status/+page.svelte | 12 ++- 5 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 programmerbar-web/src/lib/server/services/status.service.test.ts diff --git a/programmerbar-web/src/hooks.server.ts b/programmerbar-web/src/hooks.server.ts index abe4b049..dfe34533 100644 --- a/programmerbar-web/src/hooks.server.ts +++ b/programmerbar-web/src/hooks.server.ts @@ -64,7 +64,7 @@ const setup: Handle = async ({ event, resolve }) => { event.locals.feideProvider = feideProvider; // Setup services - event.locals.statusService = new StatusService(STATUS_KV); + event.locals.statusService = new StatusService(STATUS_KV, db); event.locals.rateLimitService = new RateLimitService(STATUS_KV); event.locals.magicLinkService = new MagicLinkService(STATUS_KV); event.locals.emailService = new EmailService(sendEmail); diff --git a/programmerbar-web/src/lib/server/services/status.service.test.ts b/programmerbar-web/src/lib/server/services/status.service.test.ts new file mode 100644 index 00000000..f0e20f7f --- /dev/null +++ b/programmerbar-web/src/lib/server/services/status.service.test.ts @@ -0,0 +1,77 @@ +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 { STATUS, StatusService } from './status.service'; + +describe('automatic bar status', () => { + let sqlite: DatabaseSync; + let service: StatusService; + + beforeEach(() => { + sqlite = new DatabaseSync(':memory:'); + sqlite.exec('CREATE TABLE shift (event_id TEXT, start_at INTEGER, end_at INTEGER)'); + const db = drizzle( + async (query, params) => { + const statement = sqlite.prepare(query); + statement.setReturnArrays(true); + return { rows: statement.all(...params) }; + }, + undefined, + { casing: 'snake_case' } + ); + const values = new Map(); + const kv = { + get: async (key: string) => values.get(key) ?? null, + put: async (key: string, value: string) => { + values.set(key, value); + }, + delete: async (key: string) => { + values.delete(key); + } + } as unknown as KVNamespace; + service = new StatusService(kv, db as unknown as Database); + }); + + afterEach(() => sqlite.close()); + + function addShift(event: string, start: string, end: string) { + sqlite + .prepare('INSERT INTO shift VALUES (?, ?, ?)') + .run(event, Date.parse(start) / 1000, Date.parse(end) / 1000); + } + + it('is closed without shifts', async () => { + expect(await service.get()).toBe(STATUS.CLOSED); + }); + + it('opens at the first start, stays open across gaps and midnight, and closes at the last end', async () => { + addShift('a', '2026-09-18T18:00:00+02:00', '2026-09-18T20:00:00+02:00'); + addShift('a', '2026-09-18T21:00:00+02:00', '2026-09-19T02:00:00+02:00'); + for (const [time, expected] of [ + ['2026-09-18T17:59:59+02:00', STATUS.CLOSED], + ['2026-09-18T18:00:00+02:00', STATUS.OPEN], + ['2026-09-18T20:30:00+02:00', STATUS.OPEN], + ['2026-09-19T01:59:59+02:00', STATUS.OPEN], + ['2026-09-19T02:00:00+02:00', STATUS.CLOSED] + ] as const) { + expect(await service.get(new Date(time))).toBe(expected); + } + }); + + it('does not open in the gap between separate events', async () => { + addShift('a', '2026-09-18T18:00:00Z', '2026-09-18T20:00:00Z'); + addShift('b', '2026-09-18T21:00:00Z', '2026-09-18T23:00:00Z'); + expect(await service.get(new Date('2026-09-18T20:30:00Z'))).toBe(STATUS.CLOSED); + }); + + it('supports manual overrides and returning to automatic mode', async () => { + for (const status of [STATUS.OPEN, STATUS.PRIVATE, STATUS.CLOSED]) { + await service.set(status); + expect(await service.get()).toBe(status); + } + await service.setAutomatic(); + expect(await service.getOverride()).toBeNull(); + expect(await service.get()).toBe(STATUS.CLOSED); + }); +}); diff --git a/programmerbar-web/src/lib/server/services/status.service.ts b/programmerbar-web/src/lib/server/services/status.service.ts index 5f3b2ef8..56cfa756 100644 --- a/programmerbar-web/src/lib/server/services/status.service.ts +++ b/programmerbar-web/src/lib/server/services/status.service.ts @@ -1,6 +1,10 @@ import z from 'zod'; +import type { Database } from '../db/drizzle'; +import { shifts } from '../db/schemas/shifts'; +import { sql } from 'drizzle-orm'; -export const STATUS_KEY = 'status'; +// A new key makes automatic scheduling the default, ignoring the old static status. +const OVERRIDE_KEY = 'status-override'; export const STATUS = { CLOSED: 0, @@ -10,17 +14,40 @@ export const STATUS = { export class StatusService { #kv: KVNamespace; + #db: Database; - constructor(kv: KVNamespace) { + constructor(kv: KVNamespace, db: Database) { this.#kv = kv; + this.#db = db; } - async get() { - return Number(await this.#kv.get(STATUS_KEY)); + async getOverride() { + const value = await this.#kv.get(OVERRIDE_KEY); + return value === null ? null : Number(value); + } + + async get(now = new Date()) { + const override = await this.getOverride(); + if (override !== null) return override; + + // Compare Unix seconds, matching Drizzle's timestamp storage. Grouping keeps + // the bar open between shifts belonging to the same event, also past midnight. + const timestamp = Math.floor(now.getTime() / 1000); + const activeEvents = await this.#db + .select({ eventId: shifts.eventId }) + .from(shifts) + .groupBy(shifts.eventId) + .having(sql`min(${shifts.startAt}) <= ${timestamp} and max(${shifts.endAt}) > ${timestamp}`) + .limit(1); + return activeEvents.length > 0 ? STATUS.OPEN : STATUS.CLOSED; + } + + async setAutomatic() { + await this.#kv.delete(OVERRIDE_KEY); } async set(status: number) { - await this.#kv.put(STATUS_KEY, String(status)); + await this.#kv.put(OVERRIDE_KEY, String(status)); } async getWithMessage() { diff --git a/programmerbar-web/src/routes/(portal)/portal/status/+page.server.ts b/programmerbar-web/src/routes/(portal)/portal/status/+page.server.ts index dd97afdc..39270c18 100644 --- a/programmerbar-web/src/routes/(portal)/portal/status/+page.server.ts +++ b/programmerbar-web/src/routes/(portal)/portal/status/+page.server.ts @@ -2,7 +2,7 @@ import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { - const status = await locals.statusService.get(); + const status = (await locals.statusService.getOverride()) ?? -1; return { status @@ -13,7 +13,7 @@ export const actions: Actions = { default: async ({ locals, request }) => { const formData = await request.formData(); const status = Number(formData.get('status')); - const isValidStatus = [0, 1, 2].includes(status); + const isValidStatus = formData.has('status') && [-1, 0, 1, 2].includes(status); if (!isValidStatus) { return fail(400, { @@ -21,7 +21,11 @@ export const actions: Actions = { }); } - await locals.statusService.set(status); + if (status === -1) { + await locals.statusService.setAutomatic(); + } else { + await locals.statusService.set(status); + } return { success: true diff --git a/programmerbar-web/src/routes/(portal)/portal/status/+page.svelte b/programmerbar-web/src/routes/(portal)/portal/status/+page.svelte index 00493a93..6723530a 100644 --- a/programmerbar-web/src/routes/(portal)/portal/status/+page.svelte +++ b/programmerbar-web/src/routes/(portal)/portal/status/+page.svelte @@ -1,5 +1,5 @@