Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 50 additions & 15 deletions zmsadmin/js/page/overallCalendar/overallCalendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ let lastUpdateAfter = null;
let autoRefreshTimer = null;
let currentRequest = null;
let SCOPE_COLORS = {};
let CLOSURES = new Set();

function buildScopeColorMap(days) {
const ids = [...new Set(days.flatMap(d => d.scopes.map(s => s.id)))];
Expand Down Expand Up @@ -31,6 +32,21 @@ function isSameRequest(a, b) {
return [...a.scopeIds].sort().join(',') === [...b.scopeIds].sort().join(',');
}

async function fetchClosures({scopeIds, dateFrom, dateUntil, fullReload = true}) {
const res = await fetch(`closureData/?${new URLSearchParams({
scopeIds: scopeIds.join(','),
dateFrom,
dateUntil
})}`);
if (!res.ok) throw new Error('Fehler beim Laden der Closures');
const { data } = await res.json();
const set = new Set();
for (const it of (data?.items || [])) {
set.add(`${it.date}|${it.scopeId}`);
}
CLOSURES = set;
}

document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('overall-calendar-form');
const btnRefresh = document.getElementById('refresh-calendar');
Expand All @@ -56,7 +72,11 @@ document.addEventListener('DOMContentLoaded', () => {
if (!currentRequest) return;
const {scopeIds, dateFrom, dateUntil} = currentRequest;
try {
await loadCalendar({scopeIds, dateFrom, dateUntil, fullReload: false});
await Promise.all([
fetchCalendar({ scopeIds, dateFrom, dateUntil, fullReload: false }),
fetchClosures({ scopeIds, dateFrom, dateUntil })
]);
renderMultiDayCalendar(calendarCache);
} catch (e) {
alert('Fehler beim Aktualisieren: ' + e.message);
}
Expand Down Expand Up @@ -101,7 +121,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
});

async function loadCalendar({scopeIds, dateFrom, dateUntil, fullReload = false}) {
async function fetchCalendar({scopeIds, dateFrom, dateUntil, fullReload = false}) {
const incremental = !fullReload && isSameRequest({scopeIds, dateFrom, dateUntil}, currentRequest);
const paramsObj = {scopeIds: scopeIds.join(','), dateFrom, dateUntil};
if (incremental && lastUpdateAfter) {
Expand All @@ -120,7 +140,6 @@ async function loadCalendar({scopeIds, dateFrom, dateUntil, fullReload = false})
calendarCache = json.data.days;
}

renderMultiDayCalendar(calendarCache);
currentRequest = {scopeIds, dateFrom, dateUntil};
lastUpdateAfter = serverTs;
}
Expand Down Expand Up @@ -156,7 +175,11 @@ async function handleSubmit(event) {
}

try {
await loadCalendar({scopeIds, dateFrom, dateUntil, fullReload: true});
await Promise.all([
fetchCalendar({ scopeIds, dateFrom, dateUntil, fullReload: true }),
fetchClosures({ scopeIds, dateFrom, dateUntil })
]);
renderMultiDayCalendar(calendarCache);
startAutoRefresh();
} catch (e) {
alert(e.message);
Expand All @@ -176,19 +199,21 @@ function startAutoRefresh() {

async function fetchIncrementalUpdate() {
if (!currentRequest || !lastUpdateAfter) return;
const {scopeIds, dateFrom, dateUntil} = currentRequest;
const params = new URLSearchParams({
const { scopeIds, dateFrom, dateUntil } = currentRequest;

const res = await fetch(`overallcalendarData/?${new URLSearchParams({
scopeIds: scopeIds.join(','),
dateFrom,
dateUntil,
updateAfter: lastUpdateAfter
});
const res = await fetch(`overallcalendarData/?${params}`);
if (!res.ok) return;
})}`);
if (res.ok) {
lastUpdateAfter = toMysql(res.headers.get('Last-Modified') || new Date());
const json = await res.json();
mergeDelta(json.data.days);
}

lastUpdateAfter = toMysql(res.headers.get('Last-Modified') || new Date());
const json = await res.json();
mergeDelta(json.data.days);
await fetchClosures({ scopeIds, dateFrom, dateUntil });
renderMultiDayCalendar(calendarCache);
}
Comment on lines 200 to 218
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Prevent overlapping auto-refresh calls.

Auto-refresh every 60s can overlap on slow networks. Add an in-flight guard with try/finally.

 async function fetchIncrementalUpdate() {
-    if (!currentRequest || !lastUpdateAfter) return;
+    if (!currentRequest || !lastUpdateAfter || incrementalRefreshInFlight) return;
+    incrementalRefreshInFlight = true;
     const { scopeIds, dateFrom, dateUntil } = currentRequest;
 
-    const res = await fetch(`overallcalendarData/?${new URLSearchParams({
-        scopeIds: scopeIds.join(','),
-        dateFrom,
-        dateUntil,
-        updateAfter: lastUpdateAfter
-    })}`);
-    if (res.ok) {
-        lastUpdateAfter = toMysql(res.headers.get('Last-Modified') || new Date());
-        const json = await res.json();
-        mergeDelta(json.data.days);
-    }
-
-    await fetchClosures({ scopeIds, dateFrom, dateUntil });
-    renderMultiDayCalendar(calendarCache);
+    try {
+      const res = await fetch(`overallcalendarData/?${new URLSearchParams({
+          scopeIds: scopeIds.join(','),
+          dateFrom,
+          dateUntil,
+          updateAfter: lastUpdateAfter
+      })}`);
+      if (res.ok) {
+          lastUpdateAfter = toMysql(res.headers.get('Last-Modified') || new Date());
+          const json = await res.json();
+          mergeDelta(json.data.days);
+      }
+      await fetchClosures({ scopeIds, dateFrom, dateUntil });
+      renderMultiDayCalendar(calendarCache);
+    } finally {
+      incrementalRefreshInFlight = false;
+    }
 }

Add once near the top with other module-level vars:

let incrementalRefreshInFlight = false;
🤖 Prompt for AI Agents
In zmsadmin/js/page/overallCalendar/overallCalendar.js around lines 200 to 218,
the incremental auto-refresh can start overlapping on slow networks; declare a
module-level boolean let incrementalRefreshInFlight = false near the other
top-level vars, then at the start of fetchIncrementalUpdate return immediately
if incrementalRefreshInFlight is true, set incrementalRefreshInFlight = true
before starting the async work, wrap the existing
fetch/merge/fetchClosures/render logic in a try block and set
incrementalRefreshInFlight = false in a finally block so the flag is always
cleared even on errors.


Expand Down Expand Up @@ -238,6 +263,8 @@ function renderMultiDayCalendar(days) {
return;
}

const ymdLocalFromUnix = (ts) => new Date(ts * 1000).toLocaleDateString('sv-SE');

SCOPE_COLORS = buildScopeColorMap(days);
const allTimes = [...new Set(
days.flatMap(day =>
Expand Down Expand Up @@ -303,13 +330,17 @@ function renderMultiDayCalendar(days) {

colCursor = 2;
days.forEach((day, dayIdx) => {
const dateIsoForDay = new Date(day.date * 1000).toLocaleDateString('sv-SE');
day.scopes.forEach((scope, scopeIdx) => {
const head = addCell({
text: scope.shortName || scope.name || `Scope ${scope.id}`,
className: 'overall-calendar-head overall-calendar-scope-header overall-calendar-stick-top',
row: 2, col: colCursor, colSpan: scope.maxSeats
});
head.style.background = SCOPE_COLORS[scope.id];
if (isScopeClosed(dateIsoForDay, scope.id)) {
head.classList.add('is-closed');
}
colCursor += scope.maxSeats;
if (scopeIdx < day.scopes.length - 1) {
addCell({
Expand Down Expand Up @@ -342,10 +373,10 @@ function renderMultiDayCalendar(days) {

let col = 2;
days.forEach((day, dayIdx) => {
const dateKey = new Date(day.date * 1000).toISOString().slice(0, 10);
const dateKey = ymdLocalFromUnix(day.date);
day.scopes.forEach((scope, scopeIdx) => {
const timeObj = scope.times.find(t => t.name === time) || {seats: []};

const closed = isScopeClosed(dateKey, scope.id);
for (let seatIdx = 0; seatIdx < scope.maxSeats; seatIdx++) {
if (occupied.has(`${gridRow}-${col}`)) { col++; continue; }

Expand All @@ -368,7 +399,7 @@ function renderMultiDayCalendar(days) {
for (let i = 0; i < span; i++) occupied.add(`${gridRow + i}-${col}`);
} else if (status !== 'skip') {
addCell({
className: `overall-calendar-seat overall-calendar-${status}`,
className: `overall-calendar-seat overall-calendar-${status}${closed ? ' overall-calendar-closed' : ''}`,
row: gridRow,
col,
id: cellId,
Expand All @@ -391,3 +422,7 @@ function togglePageScroll(disable) {
document.documentElement.classList.toggle('no-page-scroll', disable);
document.body.classList.toggle('no-page-scroll', disable);
}

function isScopeClosed(dateIso, scopeId) {
return CLOSURES.has(`${dateIso}|${scopeId}`);
}
6 changes: 6 additions & 0 deletions zmsadmin/routing.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@
\App::$slim->get('/overallcalendar/overallcalendarData/', \BO\Zmsadmin\OverallCalendarLoadData::class)
->setName("overallCalendarLoadData");

\App::$slim->get(
'/overallcalendar/closureData/',
\BO\Zmsadmin\OverallCalendarClosureLoadData::class
)->setName('overallCalendarClosureLoadData');


/*
* ---------------------------------------------------------------------------
* Config
Expand Down
15 changes: 15 additions & 0 deletions zmsadmin/scss/overallCalendar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,19 @@ body.no-page-scroll {
.overall-calendar-actions .btn {
margin-bottom: 0;
margin-top: 1rem;
}

.overall-calendar-closed {
border: 0 !important;
background: repeating-linear-gradient(
135deg,
rgba(220, 53, 69, .08),
rgba(220, 53, 69, .08) 6px,
rgba(220, 53, 69, .14) 6px 12px
);
}

.overall-calendar-scope-header.is-closed {
background: #fdecef !important;
border-color: #f3c1c8 !important;
}
65 changes: 65 additions & 0 deletions zmsadmin/src/Zmsadmin/OverallCalendarClosureLoadData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace BO\Zmsadmin;

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class OverallCalendarClosureLoadData extends BaseController
{
public function readResponse(
RequestInterface $request,
ResponseInterface $response,
array $args
) {
$scopeIds = $_GET['scopeIds'] ?? null;
$dateFrom = $_GET['dateFrom'] ?? null;
$dateUntil = $_GET['dateUntil'] ?? null;

if ($scopeIds === null && $dateFrom === null && $dateUntil === null) {
$response->getBody()->write(json_encode([]));
return $response->withHeader('Content-Type', 'application/json');
}

if (!$scopeIds || !$dateFrom || !$dateUntil) {
$error = [
'error' => true,
'message' => 'Missing required parameters: scopeIds, dateFrom, dateUntil',
];
$response->getBody()->write(json_encode($error));
return $response
->withStatus(400)
->withHeader('Content-Type', 'application/json');
}

$params = [
'scopeIds' => $scopeIds,
'dateFrom' => $dateFrom,
'dateUntil' => $dateUntil,
];

$apiResult = \App::$http->readGetResult('/closure/', $params);
$apiResponse = $apiResult->getResponse();

$lastMod = $apiResponse->getHeaderLine('Last-Modified');
if ($lastMod !== '') {
$response = $response->withHeader('Last-Modified', $lastMod);
}

$contentType = $apiResponse->getHeaderLine('Content-Type');
$response = $response->withHeader(
'Content-Type',
$contentType !== '' ? $contentType : 'application/json'
);

$bodyStream = $apiResponse->getBody();
if ($bodyStream->isSeekable()) {
$bodyStream->rewind();
}
$rawBody = (string) $bodyStream;

$response->getBody()->write($rawBody);

return $response->withStatus($apiResponse->getStatusCode());
}
}
12 changes: 12 additions & 0 deletions zmsadmin/templates/page/overallCalendar.twig
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@
</div>
</form>

<div class="overall-calendar-legend" style="margin:10px 0;">
<strong>Legende:</strong>
<span class="overall-calendar-seat overall-calendar-closed" style="display:inline-block; width:80px; height:18px; margin-left:8px; vertical-align:middle;"></span>
<span style="margin-right:12px;">gesperrt</span>

<span class="overall-calendar-seat overall-calendar-cancelled" style="display:inline-block; width:80px; height:18px; vertical-align:middle;"></span>
<span style="margin-right:12px;">storniert</span>

<span class="overall-calendar-seat overall-calendar-open" style="display:inline-block; width:80px; height:18px; vertical-align:middle;"></span>
<span>frei</span>
</div>
Comment on lines +75 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace inline styles and add accessible semantics to the legend

Move presentational styles to SCSS and add ARIA for screen readers. Also reuse Bootstrap spacing utilities for consistency.

Apply this diff to the Twig:

-    <div class="overall-calendar-legend" style="margin:10px 0;">
-        <strong>Legende:</strong>
-        <span class="overall-calendar-seat overall-calendar-closed" style="display:inline-block; width:80px; height:18px; margin-left:8px; vertical-align:middle;"></span>
-        <span style="margin-right:12px;">gesperrt</span>
-
-        <span class="overall-calendar-seat overall-calendar-cancelled" style="display:inline-block; width:80px; height:18px; vertical-align:middle;"></span>
-        <span style="margin-right:12px;">storniert</span>
-
-        <span class="overall-calendar-seat overall-calendar-open" style="display:inline-block; width:80px; height:18px; vertical-align:middle;"></span>
-        <span>frei</span>
-    </div>
+    <div class="overall-calendar-legend my-2" role="group" aria-labelledby="overall-calendar-legend-title">
+        <strong id="overall-calendar-legend-title" class="me-2">Legende:</strong>
+        <span class="overall-calendar-seat overall-calendar-closed overall-calendar-legend-swatch ms-2" aria-hidden="true"></span>
+        <span class="me-3">gesperrt</span>
+
+        <span class="overall-calendar-seat overall-calendar-cancelled overall-calendar-legend-swatch" aria-hidden="true"></span>
+        <span class="me-3">storniert</span>
+
+        <span class="overall-calendar-seat overall-calendar-open overall-calendar-legend-swatch" aria-hidden="true"></span>
+        <span>frei</span>
+    </div>

Add this to SCSS (overallCalendar.scss):

.overall-calendar-legend-swatch {
  display: inline-block;
  width: 80px;
  height: 18px;
  vertical-align: middle;
}
🤖 Prompt for AI Agents
In zmsadmin/templates/page/overallCalendar.twig around lines 75 to 85, remove
the inline style attributes on the legend container and swatch spans, replace
presentational spacing with Bootstrap spacing utility classes (e.g. me-3, ms-2)
and apply the new SCSS class overall-calendar-legend-swatch to each swatch span;
also add accessible semantics by marking the legend container as role="list" and
each legend item as role="listitem" (or add aria-label attributes) so screen
readers can understand the color key. Update the associated overallCalendar.scss
with the provided .overall-calendar-legend-swatch rules and ensure the existing
color modifier classes (overall-calendar-closed, -cancelled, -open) only set
background-color/border, not layout.


<div class="overall-calendar-wrapper">
<div id="overall-calendar" class="overall-calendar"></div>
</div>
Expand Down
116 changes: 116 additions & 0 deletions zmsadmin/tests/Zmsadmin/OverallCalendarClosureLoadDataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace BO\Zmsadmin\Tests;

class OverallCalendarClosureLoadDataTest extends Base
{
protected $arguments = [];
protected $parameters = [];
protected $classname = "OverallCalendarClosureLoadData";

public function testRenderingWithoutParameters()
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
$_GET = [];

$response = $this->render([], [], []);

$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
$body = json_decode((string)$response->getBody(), true);
$this->assertIsArray($body);
$this->assertEmpty($body);
}

public function testMissingParamsReturnsError()
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
$_GET = [
'scopeIds' => '58,59'
];

$response = $this->render([], [], []);

$this->assertEquals(400, $response->getStatusCode());
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));

$body = json_decode((string)$response->getBody(), true);
$this->assertIsArray($body);
$this->assertArrayHasKey('error', $body);
$this->assertTrue($body['error']);
$this->assertStringContainsString('dateFrom', $body['message']);
$this->assertStringContainsString('dateUntil', $body['message']);
}


public function testRendering()
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
$_GET = [
'scopeIds' => '58,59',
'dateFrom' => '2025-09-02',
'dateUntil' => '2025-09-05',
];

$this->setApiCalls([
[
'function' => 'readGetResult',
'url' => '/closure/',
'parameters' => [
'scopeIds' => '58,59',
'dateFrom' => '2025-09-02',
'dateUntil' => '2025-09-05',
],
'response' => $this->readFixture('GET_Closure_Data.json'),
],
]);

$response = $this->render([], [], []);

$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));

$body = json_decode((string)$response->getBody(), true);
$this->assertIsArray($body);
$this->assertArrayHasKey('data', $body);
$this->assertArrayHasKey('items', $body['data']);
$this->assertIsArray($body['data']['items']);
$this->assertNotEmpty($body['data']['items']);

$first = $body['data']['items'][0];
$this->assertArrayHasKey('scopeId', $first);
$this->assertArrayHasKey('date', $first);
}

public function testResponseStructure()
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
$_GET = [
'scopeIds' => '58,59',
'dateFrom' => '2025-09-02',
'dateUntil' => '2025-09-05',
];

$this->setApiCalls([
[
'function' => 'readGetResult',
'url' => '/closure/',
'parameters' => [
'scopeIds' => '58,59',
'dateFrom' => '2025-09-02',
'dateUntil' => '2025-09-05',
],
'response' => $this->readFixture('GET_Closure_Data.json'),
],
]);

$response = $this->render([], [], []);
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));

$body = json_decode((string)$response->getBody(), true);
$this->assertArrayHasKey('$schema', $body);
$this->assertArrayHasKey('meta', $body);
$this->assertArrayHasKey('data', $body);
$this->assertArrayHasKey('items', $body['data']);
}
}
Loading
Loading