From de5483979acda4ce99553e3d428546e4b1556141 Mon Sep 17 00:00:00 2001 From: mateusz-solecki <62967142+mateusz-solecki@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:46:42 +0200 Subject: [PATCH 1/4] Add FTP deployment workflow --- .github/workflows/deply.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/deply.yml diff --git a/.github/workflows/deply.yml b/.github/workflows/deply.yml new file mode 100644 index 000000000..5256f4dfe --- /dev/null +++ b/.github/workflows/deply.yml @@ -0,0 +1,22 @@ +name: Deploy to FTP + +on: + push: + branches: + - main # lub master, w zależności od domyślnej gałęzi + +jobs: + web-deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + - name: Get code + uses: actions/checkout@v4 + + - name: Sync files + uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + with: + server: ${{ secrets.FTP_SERVER }} + username: ${{ secrets.FTP_USERNAME }} + password: ${{ secrets.FTP_PASSWORD }} + # server-dir: /public_html/ # odkomentuj, jeśli pliki mają trafić do konkretnego podkatalogu From 365c99e66885f986a20d85769c463c846a354a0f Mon Sep 17 00:00:00 2001 From: mateusz-solecki <62967142+mateusz-solecki@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:47:09 +0200 Subject: [PATCH 2/4] Fix typo in deploy.yml filename --- .github/workflows/{deply.yml => deploy.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{deply.yml => deploy.yml} (100%) diff --git a/.github/workflows/deply.yml b/.github/workflows/deploy.yml similarity index 100% rename from .github/workflows/deply.yml rename to .github/workflows/deploy.yml From 8812d0a4be7b2c78711e9b041f601dc0626120a1 Mon Sep 17 00:00:00 2001 From: mateusz-solecki <62967142+mateusz-solecki@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:03:10 +0200 Subject: [PATCH 3/4] original-mods (#1) --- .htaccess | 17 +-- availability.cache.json | 1 + availability.ph | 185 ++++++++++++++++++++++++++ availability.php | 278 ++++++++++++++++++++++++++++++++++++++++ tpl/dashboard.tpl | 42 ++++++ tpl/globalfooter.tpl | 20 +++ tpl/globalheader.tpl | 2 +- 7 files changed, 531 insertions(+), 14 deletions(-) create mode 100644 availability.cache.json create mode 100644 availability.ph create mode 100644 availability.php diff --git a/.htaccess b/.htaccess index 8ad2970b7..4c1097d00 100644 --- a/.htaccess +++ b/.htaccess @@ -1,15 +1,6 @@ RewriteEngine On -# Set RewriteBase if your physical path is different from the URL. For example, if using an alias -# Uncomment the following two lines to force HTTPS -#RewriteCond %{HTTPS} off -#RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] - -# Redirect requests that are not already under /Web to /Web. -RewriteCond %{REQUEST_URI} !^/Web(/|$) -RewriteRule ^(.*)$ /Web/$1 [R=301,L] - -#Header Set Access-Control-Allow-Origin "*" -#Header add Access-Control-Allow-Headers "origin, x-requested-with, content-type" -#Header add Access-Control-Allow-Methods "PUT, GET, POST, DELETE, OPTIONS" -#php_value max_input_vars 10000 +# Do not redirect the read-only availability endpoint or its config/cache siblings. +RewriteCond %{REQUEST_URI} !^/rezerwacje/Web(/|$) +RewriteCond %{REQUEST_URI} !^/rezerwacje/availability\. +RewriteRule ^(.*)$ /rezerwacje/Web/$1 [R=301,L] diff --git a/availability.cache.json b/availability.cache.json new file mode 100644 index 000000000..05e31799b --- /dev/null +++ b/availability.cache.json @@ -0,0 +1 @@ +{"version":2,"generatedAt":"2026-10-11T15:17:48+02:00","timezone":"Europe/Warsaw","today":"2026-10-11","axis":{"fromHour":6,"toHour":22},"days":[{"date":"2026-10-05","weekday":"mon","isToday":false,"isPast":true,"open":{"openMin":360,"closeMin":1320},"rooms":[{"id":"sala-maszyn","resourceId":1,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"sala-bokserska","resourceId":2,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"strefa-cardio","resourceId":3,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]},{"id":"gabinet-fizjo","resourceId":4,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]}]},{"date":"2026-10-06","weekday":"tue","isToday":false,"isPast":true,"open":{"openMin":360,"closeMin":1320},"rooms":[{"id":"sala-maszyn","resourceId":1,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"sala-bokserska","resourceId":2,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"strefa-cardio","resourceId":3,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]},{"id":"gabinet-fizjo","resourceId":4,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]}]},{"date":"2026-10-07","weekday":"wed","isToday":false,"isPast":true,"open":{"openMin":360,"closeMin":1320},"rooms":[{"id":"sala-maszyn","resourceId":1,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"sala-bokserska","resourceId":2,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"strefa-cardio","resourceId":3,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]},{"id":"gabinet-fizjo","resourceId":4,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]}]},{"date":"2026-10-08","weekday":"thu","isToday":false,"isPast":true,"open":{"openMin":360,"closeMin":1320},"rooms":[{"id":"sala-maszyn","resourceId":1,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"sala-bokserska","resourceId":2,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"strefa-cardio","resourceId":3,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]},{"id":"gabinet-fizjo","resourceId":4,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]}]},{"date":"2026-10-09","weekday":"fri","isToday":false,"isPast":true,"open":{"openMin":360,"closeMin":1320},"rooms":[{"id":"sala-maszyn","resourceId":1,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"sala-bokserska","resourceId":2,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"strefa-cardio","resourceId":3,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]},{"id":"gabinet-fizjo","resourceId":4,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]}]},{"date":"2026-10-10","weekday":"sat","isToday":false,"isPast":true,"open":{"openMin":360,"closeMin":1320},"rooms":[{"id":"sala-maszyn","resourceId":1,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"sala-bokserska","resourceId":2,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"strefa-cardio","resourceId":3,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]},{"id":"gabinet-fizjo","resourceId":4,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]}]},{"date":"2026-10-11","weekday":"sun","isToday":true,"isPast":false,"open":{"openMin":360,"closeMin":1320},"rooms":[{"id":"sala-maszyn","resourceId":1,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"sala-bokserska","resourceId":2,"capacity":2,"hours":[{"h":6,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":7,"bookedMin":60,"capMin":120,"ratio":0.5,"level":"medium"},{"h":8,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":11,"bookedMin":15,"capMin":120,"ratio":0.125,"level":"low"},{"h":12,"bookedMin":120,"capMin":120,"ratio":1,"level":"high"},{"h":13,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":120,"ratio":0,"level":"low"}]},{"id":"strefa-cardio","resourceId":3,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":60,"capMin":60,"ratio":1,"level":"high"},{"h":15,"bookedMin":15,"capMin":60,"ratio":0.25,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]},{"id":"gabinet-fizjo","resourceId":4,"capacity":1,"hours":[{"h":6,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":7,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":8,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":9,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":10,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":11,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":12,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":13,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":14,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":15,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":16,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":17,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":18,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":19,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":20,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"},{"h":21,"bookedMin":0,"capMin":60,"ratio":0,"level":"low"}]}]}]} \ No newline at end of file diff --git a/availability.ph b/availability.ph new file mode 100644 index 000000000..7d9961854 --- /dev/null +++ b/availability.ph @@ -0,0 +1,185 @@ + CONTRACT_VERSION, 'error' => 'method_not_allowed']); + exit; +} + +try { + $config = require __DIR__ . '/availability.config.php'; + $cacheTtl = (int) ($config['cacheTtl'] ?? 300); + header('Cache-Control: public, max-age=' . $cacheTtl); + + $cacheFile = __DIR__ . '/availability.cache.json'; + if ($cacheTtl > 0 && is_file($cacheFile) && (time() - (int) filemtime($cacheFile)) < $cacheTtl) { + $cached = file_get_contents($cacheFile); + if ($cached !== false && $cached !== '') { + if ($method !== 'HEAD') { + echo $cached; + } + exit; + } + } + + $body = json_encode(buildFeed($config), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($cacheTtl > 0) { + // Atomic best-effort write; caching is skipped silently on read-only dirs. + $tmp = @tempnam(__DIR__, 'avail'); + if ($tmp !== false && @file_put_contents($tmp, $body) !== false && @rename($tmp, $cacheFile)) { + // cached + } elseif ($tmp !== false) { + @unlink($tmp); + } + } + + if ($method !== 'HEAD') { + echo $body; + } +} catch (Throwable $e) { + // Never leak error messages or credentials to the public response. + http_response_code(503); + echo json_encode(['version' => CONTRACT_VERSION, 'error' => 'unavailable']); +} + +function buildFeed(array $config): array +{ + $tz = new DateTimeZone(TIMEZONE); + $now = new DateTimeImmutable('now', $tz); + $weekday = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'][(int) $now->format('N') - 1]; + $window = $config['openingHours'][$weekday] ?? null; + + $rooms = []; + foreach ($config['rooms'] as $resourceId => $room) { + $rooms[(int) $resourceId] = [ + 'id' => (string) $room['id'], + 'name' => (string) $room['name'], + 'resourceId' => (int) $resourceId, + 'capacity' => max(1, (int) ($room['capacity'] ?? 1)), + 'bookedMinutes' => 0, + 'openMinutes' => 0, + 'ratio' => 0.0, + 'level' => 'low', + ]; + } + + $open = null; + if (is_array($window) && count($window) === 2) { + $openMin = hhmmToMin($window[0]); + $closeMin = hhmmToMin($window[1]); + if ($closeMin > $openMin) { + $open = ['openMin' => $openMin, 'closeMin' => $closeMin]; + $windowMinutes = $closeMin - $openMin; + + // LibreBooking stores reservation datetimes in UTC — convert the + // Warsaw opening window before comparing. + $utc = new DateTimeZone('UTC'); + $winStart = $now->setTime(intdiv($openMin, 60), $openMin % 60, 0); + $winEnd = $now->setTime(intdiv($closeMin, 60), $closeMin % 60, 0); + $booked = queryBookedMinutes( + $config['db'], + array_keys($rooms), + $winStart->setTimezone($utc)->format('Y-m-d H:i:s'), + $winEnd->setTimezone($utc)->format('Y-m-d H:i:s'), + ); + + foreach ($rooms as $resourceId => &$room) { + // Denominator is the total slot-time for this room today: the + // opening window multiplied by capacity. A capacity-2 room over + // a 16h day has 32 slot-hours, so one 16h booking = 50% busy. + $capacityMinutes = $windowMinutes * $room['capacity']; + $minutes = min($booked[$resourceId] ?? 0, $capacityMinutes); + $ratio = $capacityMinutes > 0 ? $minutes / $capacityMinutes : 0.0; + $room['bookedMinutes'] = $minutes; + $room['openMinutes'] = $capacityMinutes; + $room['ratio'] = round($ratio, 3); + $room['level'] = $ratio < 1 / 3 ? 'low' : ($ratio < 2 / 3 ? 'medium' : 'high'); + } + unset($room); + } + } + + return [ + 'version' => CONTRACT_VERSION, + 'generatedAt' => $now->format(DateTimeInterface::ATOM), + 'timezone' => TIMEZONE, + 'date' => $now->format('Y-m-d'), + 'open' => $open, + 'rooms' => array_values($rooms), + ]; +} + +/** + * Sum of booked minutes per resource inside [winStart, winEnd) UTC. + * status_id 2 = Deleted is excluded; Pending (3) still occupies the room. + * Repeating reservations are pre-expanded into reservation_instances, so no + * recurrence math is needed. Blackouts are intentionally not counted — they + * represent closures, not busyness. + */ +function queryBookedMinutes(array $db, array $resourceIds, string $winStartUtc, string $winEndUtc): array +{ + if ($resourceIds === []) { + return []; + } + $pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['name']), + $db['user'], + $db['password'], + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + + $in = implode(',', array_fill(0, count($resourceIds), '?')); + $sql = "SELECT rr.resource_id, + COALESCE(SUM(TIMESTAMPDIFF(MINUTE, + GREATEST(ri.start_date, ?), + LEAST(ri.end_date, ?))), 0) AS booked_minutes + FROM reservation_instances ri + INNER JOIN reservation_series rs ON rs.series_id = ri.series_id + INNER JOIN reservation_resources rr ON rr.series_id = rs.series_id + WHERE rs.status_id <> 2 + AND ri.start_date < ? + AND ri.end_date > ? + AND rr.resource_id IN ($in) + GROUP BY rr.resource_id"; + + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$winStartUtc, $winEndUtc, $winEndUtc, $winStartUtc], $resourceIds)); + + $booked = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $booked[(int) $row['resource_id']] = max(0, (int) $row['booked_minutes']); + } + return $booked; +} + +function hhmmToMin(string $hhmm): int +{ + [$h, $m] = array_map('intval', explode(':', $hhmm)); + return $h * 60 + $m; +} diff --git a/availability.php b/availability.php new file mode 100644 index 000000000..1ba4c9565 --- /dev/null +++ b/availability.php @@ -0,0 +1,278 @@ + h=6..21). Days closed on that weekday report + * open=null and hours=[]. + */ + +const CONTRACT_VERSION = 2; +const TIMEZONE = 'Europe/Warsaw'; + +header('Content-Type: application/json; charset=utf-8'); +header('Access-Control-Allow-Origin: *'); + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +if ($method !== 'GET' && $method !== 'HEAD') { + http_response_code(405); + header('Allow: GET, HEAD'); + echo json_encode(['version' => CONTRACT_VERSION, 'error' => 'method_not_allowed']); + exit; +} + +try { + $config = require __DIR__ . '/availability.config.php'; + $cacheTtl = (int) ($config['cacheTtl'] ?? 300); + header('Cache-Control: public, max-age=' . $cacheTtl); + + $cacheFile = __DIR__ . '/availability.cache.json'; + if ($cacheTtl > 0 && is_file($cacheFile) && (time() - (int) filemtime($cacheFile)) < $cacheTtl) { + $cached = file_get_contents($cacheFile); + if ($cached !== false && $cached !== '') { + if ($method !== 'HEAD') { + echo $cached; + } + exit; + } + } + + $body = json_encode(buildFeed($config), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($cacheTtl > 0) { + // Atomic best-effort write; caching is skipped silently on read-only dirs. + $tmp = @tempnam(__DIR__, 'avail'); + if ($tmp !== false && @file_put_contents($tmp, $body) !== false && @rename($tmp, $cacheFile)) { + // cached + } elseif ($tmp !== false) { + @unlink($tmp); + } + } + + if ($method !== 'HEAD') { + echo $body; + } +} catch (Throwable $e) { + // Never leak error messages or credentials to the public response. + http_response_code(503); + echo json_encode(['version' => CONTRACT_VERSION, 'error' => 'unavailable']); +} + +function buildFeed(array $config): array +{ + $tz = new DateTimeZone(TIMEZONE); + $utc = new DateTimeZone('UTC'); + $now = new DateTimeImmutable('now', $tz); + $today = $now->setTime(0, 0, 0); + + // Current calendar week Mon-Sun in Warsaw TZ. (ISO-8601: 1=Mon..7=Sun.) + $monday = $today->modify('-' . ((int) $today->format('N') - 1) . ' days'); + $weekEnd = $monday->modify('+7 days'); + + $weekdayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; + + // Union of opening hours across the week determines the hour axis. + // Rooms are listed once (config['rooms']) and the shape stays constant + // across days for consistent client rendering. + [$axisFromHour, $axisToHour] = axisHours($config['openingHours'] ?? []); + + $roomsMeta = []; + foreach ($config['rooms'] as $resourceId => $room) { + $roomsMeta[(int) $resourceId] = [ + 'id' => (string) $room['id'], + 'name' => (string) $room['name'], + 'capacity' => max(1, (int) ($room['capacity'] ?? 1)), + ]; + } + + // Single DB round-trip: all reservations touching the week for our rooms. + $reservations = queryReservations( + $config['db'], + array_keys($roomsMeta), + $monday->setTimezone($utc)->format('Y-m-d H:i:s'), + $weekEnd->setTimezone($utc)->format('Y-m-d H:i:s'), + ); + + $days = []; + for ($i = 0; $i < 7; $i++) { + $dayStart = $monday->modify("+$i days"); + $weekdayKey = $weekdayKeys[$i]; + $window = $config['openingHours'][$weekdayKey] ?? null; + + $open = null; + $openMin = null; + $closeMin = null; + if (is_array($window) && count($window) === 2) { + $openMin = hhmmToMin($window[0]); + $closeMin = hhmmToMin($window[1]); + if ($closeMin > $openMin) { + $open = ['openMin' => $openMin, 'closeMin' => $closeMin]; + } else { + $openMin = null; + $closeMin = null; + } + } + + $daysRooms = []; + foreach ($roomsMeta as $resourceId => $meta) { + $capMin = 60 * $meta['capacity']; + $hours = []; + for ($h = $axisFromHour; $h < $axisToHour; $h++) { + $inOpenWindow = $open !== null && $h * 60 >= $openMin && ($h + 1) * 60 <= $closeMin; + $bookedMin = 0; + if ($inOpenWindow) { + $hourStart = $dayStart->setTime($h, 0, 0); + $hourEnd = $dayStart->setTime($h + 1, 0, 0); + $bookedMin = min($capMin, sumOverlapMinutes( + $reservations, + $resourceId, + $hourStart->setTimezone($utc), + $hourEnd->setTimezone($utc), + )); + } + $ratio = $capMin > 0 && $bookedMin > 0 ? $bookedMin / $capMin : 0.0; + $hours[] = [ + 'h' => $h, + 'bookedMin' => $bookedMin, + 'capMin' => $inOpenWindow ? $capMin : 0, + 'ratio' => round($ratio, 3), + 'level' => $ratio < 1 / 3 ? 'low' : ($ratio < 2 / 3 ? 'medium' : 'high'), + ]; + } + + $daysRooms[] = [ + 'id' => $meta['id'], + 'resourceId' => (int) $resourceId, + 'capacity' => $meta['capacity'], + 'hours' => $hours, + ]; + } + + $days[] = [ + 'date' => $dayStart->format('Y-m-d'), + 'weekday' => $weekdayKey, + 'isToday' => $dayStart->format('Y-m-d') === $today->format('Y-m-d'), + 'isPast' => $dayStart < $today, + 'open' => $open, + 'rooms' => $daysRooms, + ]; + } + + return [ + 'version' => CONTRACT_VERSION, + 'generatedAt' => $now->format(DateTimeInterface::ATOM), + 'timezone' => TIMEZONE, + 'today' => $today->format('Y-m-d'), + 'axis' => ['fromHour' => $axisFromHour, 'toHour' => $axisToHour], + 'days' => $days, + ]; +} + +/** + * Widest opening window observed across the week, floor to hour. + * Defaults to 06:00-22:00 if nothing configured. + */ +function axisHours(array $openingHours): array +{ + $minOpen = null; + $maxClose = null; + foreach ($openingHours as $win) { + if (!is_array($win) || count($win) !== 2) { + continue; + } + $o = intdiv(hhmmToMin($win[0]), 60); + $c = (int) ceil(hhmmToMin($win[1]) / 60); + $minOpen = $minOpen === null ? $o : min($minOpen, $o); + $maxClose = $maxClose === null ? $c : max($maxClose, $c); + } + return [$minOpen ?? 6, $maxClose ?? 22]; +} + +/** + * Fetch all reservation instances for the given resources overlapping the + * requested UTC window. Deleted (status_id 2) excluded. Pending (3) still + * occupies the room. reservation_instances already expands recurring series + * so no client-side recurrence math is needed. + */ +function queryReservations(array $db, array $resourceIds, string $winStartUtc, string $winEndUtc): array +{ + if ($resourceIds === []) { + return []; + } + $pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['name']), + $db['user'], + $db['password'], + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + + $in = implode(',', array_fill(0, count($resourceIds), '?')); + $sql = "SELECT rr.resource_id, ri.start_date, ri.end_date + FROM reservation_instances ri + INNER JOIN reservation_series rs ON rs.series_id = ri.series_id + INNER JOIN reservation_resources rr ON rr.series_id = rs.series_id + WHERE rs.status_id <> 2 + AND ri.start_date < ? + AND ri.end_date > ? + AND rr.resource_id IN ($in)"; + + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$winEndUtc, $winStartUtc], $resourceIds)); + + $utc = new DateTimeZone('UTC'); + $byResource = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $rid = (int) $row['resource_id']; + $byResource[$rid] ??= []; + $byResource[$rid][] = [ + 'start' => new DateTimeImmutable($row['start_date'], $utc), + 'end' => new DateTimeImmutable($row['end_date'], $utc), + ]; + } + return $byResource; +} + +function sumOverlapMinutes(array $reservations, int $resourceId, DateTimeImmutable $winStart, DateTimeImmutable $winEnd): int +{ + $total = 0; + foreach ($reservations[$resourceId] ?? [] as $r) { + $start = $r['start'] > $winStart ? $r['start'] : $winStart; + $end = $r['end'] < $winEnd ? $r['end'] : $winEnd; + if ($end > $start) { + $total += (int) (($end->getTimestamp() - $start->getTimestamp()) / 60); + } + } + return $total; +} + +function hhmmToMin(string $hhmm): int +{ + [$h, $m] = array_map('intval', explode(':', $hhmm)); + return $h * 60 + $m; +} diff --git a/tpl/dashboard.tpl b/tpl/dashboard.tpl index b56f3a240..15935738e 100644 --- a/tpl/dashboard.tpl +++ b/tpl/dashboard.tpl @@ -42,3 +42,45 @@ {include file='globalfooter.tpl'} + + diff --git a/tpl/globalfooter.tpl b/tpl/globalfooter.tpl index e744bfaa3..b0987b0ab 100644 --- a/tpl/globalfooter.tpl +++ b/tpl/globalfooter.tpl @@ -73,3 +73,23 @@ + + diff --git a/tpl/globalheader.tpl b/tpl/globalheader.tpl index 5e7f0dcfb..08484c922 100644 --- a/tpl/globalheader.tpl +++ b/tpl/globalheader.tpl @@ -351,7 +351,7 @@ {/if} {if isset($CanViewAdmin) && $CanViewAdmin} -