docs/dev: sync backend rework, MQTT, and devcontainer hygiene

README: add Versioning (unified SemVer, pre-releases, build metadata); emphasize UTC handling and streaming endpoint; add Dev Container notes (UI-only Remote Containers, npm ci, idempotent aliases)
TECH-CHANGELOG: backend rework notes (serialization camelCase, UTC normalization, streaming metadata); add component build metadata template (image tags/SHAs)
Copilot instructions: integrate maintenance guardrails; reinforce UTC and camelCase conventions; document MQTT topics and scheduler retained payload behavior
Devcontainer: map Remote Containers to UI; remove in-container install; switch to npm ci; make aliases idempotent
This commit is contained in:
RobbStarkAustria
2025-11-29 15:35:13 +00:00
parent 6dcf93f0dd
commit df9f29bc6a
13 changed files with 399 additions and 42 deletions

View File

@@ -98,6 +98,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -385,6 +386,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -408,6 +410,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2052,6 +2055,7 @@
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -2125,6 +2129,7 @@
"integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.43.0",
"@typescript-eslint/types": "8.43.0",
@@ -2357,6 +2362,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2712,6 +2718,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -3475,6 +3482,7 @@
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3536,6 +3544,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -4990,18 +4999,6 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5717,6 +5714,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5783,6 +5781,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -5871,6 +5870,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5880,6 +5880,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -6784,6 +6785,7 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -7022,6 +7024,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7152,6 +7155,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7257,6 +7261,7 @@
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -7350,6 +7355,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7580,21 +7586,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",

View File

@@ -106,3 +106,34 @@ export async function updateSupplementTableSettings(
}
return response.json();
}
/**
* Get holiday banner setting
*/
export async function getHolidayBannerSetting(): Promise<{ enabled: boolean }> {
const response = await fetch(`/api/system-settings/holiday-banner`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch holiday banner setting: ${response.statusText}`);
}
return response.json();
}
/**
* Update holiday banner setting
*/
export async function updateHolidayBannerSetting(
enabled: boolean
): Promise<{ enabled: boolean; message: string }> {
const response = await fetch(`/api/system-settings/holiday-banner`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update holiday banner setting: ${response.statusText}`);
}
return response.json();
}

View File

@@ -3,7 +3,10 @@ import { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Group, Client } from './apiClients';
import { fetchEvents } from './apiEvents';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
import { ToastComponent, MessageComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays } from './apiHolidays';
import { getActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { getHolidayBannerSetting } from './apiSystemSettings';
const REFRESH_INTERVAL = 15000; // 15 Sekunden
@@ -31,6 +34,15 @@ const Dashboard: React.FC = () => {
const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
const toastRef = React.useRef<ToastComponent>(null);
// Holiday status state
const [holidayBannerEnabled, setHolidayBannerEnabled] = useState<boolean>(true);
const [activePeriod, setActivePeriod] = useState<AcademicPeriod | null>(null);
const [holidayOverlapCount, setHolidayOverlapCount] = useState<number>(0);
const [holidayFirst, setHolidayFirst] = useState<string | null>(null);
const [holidayLast, setHolidayLast] = useState<string | null>(null);
const [holidayLoading, setHolidayLoading] = useState<boolean>(false);
const [holidayError, setHolidayError] = useState<string | null>(null);
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
useEffect(() => {
let lastGroups: Group[] = [];
@@ -72,6 +84,62 @@ const Dashboard: React.FC = () => {
return () => clearInterval(interval);
}, []);
// Load academic period & holidays status
useEffect(() => {
const loadHolidayStatus = async () => {
// Check if banner is enabled first
try {
const bannerSetting = await getHolidayBannerSetting();
setHolidayBannerEnabled(bannerSetting.enabled);
if (!bannerSetting.enabled) {
return; // Skip loading if disabled
}
} catch (e) {
console.error('Fehler beim Laden der Banner-Einstellung:', e);
// Continue with default (enabled)
}
setHolidayLoading(true);
setHolidayError(null);
try {
const period = await getActiveAcademicPeriod();
setActivePeriod(period || null);
const holidayData = await listHolidays();
const list = holidayData.holidays || [];
if (period) {
// Check for holidays overlapping with active period
const ps = new Date(period.start_date + 'T00:00:00');
const pe = new Date(period.end_date + 'T23:59:59');
const overlapping = list.filter(h => {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
return hs <= pe && he >= ps;
});
setHolidayOverlapCount(overlapping.length);
if (overlapping.length > 0) {
const sorted = overlapping.slice().sort((a, b) => a.start_date.localeCompare(b.start_date));
setHolidayFirst(sorted[0].start_date);
setHolidayLast(sorted[sorted.length - 1].end_date);
} else {
setHolidayFirst(null);
setHolidayLast(null);
}
} else {
setHolidayOverlapCount(0);
setHolidayFirst(null);
setHolidayLast(null);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ferienstatus konnte nicht geladen werden';
setHolidayError(msg);
} finally {
setHolidayLoading(false);
}
};
loadHolidayStatus();
}, []);
// Fetch currently active events for all groups
const fetchActiveEventsForGroups = async (groupsList: Group[]) => {
const now = new Date();
@@ -344,6 +412,55 @@ const Dashboard: React.FC = () => {
});
};
// Format date for holiday display
const formatDate = (iso: string | null) => {
if (!iso) return '-';
try {
const d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString('de-DE');
} catch { return iso; }
};
// Holiday Status Banner Component
const HolidayStatusBanner = () => {
if (holidayLoading) {
return (
<MessageComponent severity="Info" variant="Filled">
Lade Ferienstatus ...
</MessageComponent>
);
}
if (holidayError) {
return (
<MessageComponent severity="Error" variant="Filled">
Fehler beim Laden des Ferienstatus: {holidayError}
</MessageComponent>
);
}
if (!activePeriod) {
return (
<MessageComponent severity="Warning" variant="Outlined">
Keine aktive akademische Periode gesetzt Ferienplan nicht verknüpft.
</MessageComponent>
);
}
if (holidayOverlapCount > 0) {
return (
<MessageComponent severity="Success" variant="Filled">
Ferienplan vorhanden für <strong>{activePeriod.display_name || activePeriod.name}</strong>: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'}
{holidayFirst && holidayLast && (
<> ({formatDate(holidayFirst)} {formatDate(holidayLast)})</>
)}
</MessageComponent>
);
}
return (
<MessageComponent severity="Warning" variant="Filled">
Kein Ferienplan für <strong>{activePeriod.display_name || activePeriod.name}</strong> importiert. Jetzt unter Einstellungen 📅 Kalender hochladen.
</MessageComponent>
);
};
return (
<div>
<ToastComponent
@@ -361,6 +478,13 @@ const Dashboard: React.FC = () => {
</p>
</header>
{/* Holiday Status Banner */}
{holidayBannerEnabled && (
<div style={{ marginBottom: '20px' }}>
<HolidayStatusBanner />
</div>
)}
{/* Global Statistics Summary */}
{(() => {
const globalStats = getGlobalStats();

View File

@@ -4,7 +4,7 @@ import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react
import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
import { getSupplementTableSettings, updateSupplementTableSettings } from './apiSystemSettings';
import { getSupplementTableSettings, updateSupplementTableSettings, getHolidayBannerSetting, updateHolidayBannerSetting } from './apiSystemSettings';
import { useAuth } from './useAuth';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
@@ -77,6 +77,10 @@ const Einstellungen: React.FC = () => {
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
const [supplementBusy, setSupplementBusy] = React.useState(false);
// Holiday banner state
const [holidayBannerEnabled, setHolidayBannerEnabled] = React.useState(true);
const [holidayBannerBusy, setHolidayBannerBusy] = React.useState(false);
// Video defaults state (Admin+)
const [videoAutoplay, setVideoAutoplay] = React.useState<boolean>(true);
const [videoLoop, setVideoLoop] = React.useState<boolean>(true);
@@ -116,6 +120,15 @@ const Einstellungen: React.FC = () => {
}
}, []);
const loadHolidayBannerSetting = React.useCallback(async () => {
try {
const data = await getHolidayBannerSetting();
setHolidayBannerEnabled(data.enabled);
} catch (e) {
console.error('Fehler beim Laden der Ferienbanner-Einstellung:', e);
}
}, []);
// Load video default settings (with fallbacks)
const loadVideoSettings = React.useCallback(async () => {
try {
@@ -156,6 +169,7 @@ const Einstellungen: React.FC = () => {
React.useEffect(() => {
refresh();
loadHolidayBannerSetting(); // Everyone can see this
if (user) {
// Academic periods for all users
loadAcademicPeriods();
@@ -166,7 +180,7 @@ const Einstellungen: React.FC = () => {
loadVideoSettings();
}
}
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, user]);
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, loadHolidayBannerSetting, user]);
const onUpload = async () => {
if (!file) return;
@@ -208,6 +222,19 @@ const Einstellungen: React.FC = () => {
}
};
const onSaveHolidayBannerSetting = async () => {
setHolidayBannerBusy(true);
try {
await updateHolidayBannerSetting(holidayBannerEnabled);
showToast('Ferienbanner-Einstellung gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern';
showToast(msg, 'e-toast-danger');
} finally {
setHolidayBannerBusy(false);
}
};
const onSaveVideoSettings = async () => {
setVideoBusy(true);
try {
@@ -291,6 +318,34 @@ const Einstellungen: React.FC = () => {
)}
</div>
</div>
{/* Dashboard Display Settings Card */}
<div className="e-card" style={{ marginTop: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Dashboard-Anzeige</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Ferienstatus-Banner auf Dashboard anzeigen"
checked={holidayBannerEnabled}
change={(e) => setHolidayBannerEnabled(e.checked || false)}
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4, marginLeft: 24 }}>
Zeigt eine Information an, ob ein Ferienplan für die aktive Periode importiert wurde.
</div>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveHolidayBannerSetting}
disabled={holidayBannerBusy}
>
{holidayBannerBusy ? 'Speichere…' : 'Einstellung speichern'}
</ButtonComponent>
</div>
</div>
</div>
);