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

@@ -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>
);