feat: 2026.1.0-alpha.16 – dashboard banner refactor, period auto-activation, text & docs

Dashboard (dashboard/src/dashboard.tsx, settings.tsx, apiAcademicPeriods.ts):
- Refactor loadHolidayStatus to useCallback with stable empty-deps reference;
  removes location.pathname dependency that caused overlapping API calls at mount
  and left the banner unresolved via request-sequence cancellation
- Add key prop derived from severity:text to Syncfusion MessageComponent to force
  remount on state change, fixing stale banner that ignored React prop/children updates
- Correct German transliterated text to proper Umlauts throughout visible UI strings
  (fuer -> für, oe -> ö, ae -> ä etc. across dashboard and settings views)

Backend (server/init_academic_periods.py):
- Refactor to idempotent two-phase flow: seed default periods only when table is
  empty; on every run activate exactly the non-archived period covering date.today()
- Enforces single-active invariant by deactivating all periods before promoting match
- Emits explicit warning when no period covers current date instead of doing nothing

Deployment (docker-compose.prod.yml):
- Add init_academic_periods.py to server startup chain after migrations and defaults;
  eliminates manual post-deploy step to set an active academic period

Release docs:
- program-info.json: bump to 2026.1.0-alpha.16; fix JSON parse error caused by
  typographic curly quotes in the new changelog entry
- TECH-CHANGELOG.md: detailed alpha.16 section with root-cause motivation for both
  dashboard refactoring decisions (unstable callback ref + Syncfusion stale render)
- DEV-CHANGELOG.md: document dashboard refactor, Syncfusion key fix, Umlaut changes,
  and program-info JSON regression and fix
- README.md: add Latest Release Highlights section for alpha.16
- .github/copilot-instructions.md: sync file map, prod bootstrap note, backend and
  frontend pattern additions for academic period init and Syncfusion remount pattern
This commit is contained in:
2026-04-02 14:16:53 +00:00
parent 06411edfab
commit 4d652f0554
10 changed files with 1054 additions and 888 deletions

View File

@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
"version": "2026.1.0-alpha.15",
"version": "2026.1.0-alpha.16",
"copyright": "© 2026 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -30,6 +30,16 @@
"commitId": "9f2ae8b44c3a"
},
"changelog": [
{
"version": "2026.1.0-alpha.16",
"date": "2026-04-02",
"changes": [
"✅ Dashboard: Der Ferienstatus-Banner zeigt die aktive akademische Periode jetzt zuverlässig nach Hard-Refresh und beim Wechsel zwischen Dashboard und Einstellungen.",
"🧭 Navigation: Der Link vom Ferienstatus-Banner zu den Einstellungen bleibt stabil und funktioniert konsistent für Admin-Rollen.",
"🚀 Deployment: Akademische Perioden werden nach Initialisierung automatisch für das aktuelle Datum aktiviert (kein manueller Aktivierungsschritt direkt nach Rollout mehr nötig).",
"🔤 Sprache: Mehrere deutsche UI-Texte im Dashboard wurden auf korrekte Umlaute umgestellt (zum Beispiel für, prüfen, Vorfälle und Ausfälle)."
]
},
{
"version": "2026.1.0-alpha.15",
"date": "2026-03-31",

View File

@@ -19,8 +19,25 @@ export type PeriodUsage = {
blockers: string[];
};
function normalizeAcademicPeriod(period: any): AcademicPeriod {
return {
id: Number(period.id),
name: period.name,
displayName: period.displayName ?? period.display_name ?? null,
startDate: period.startDate ?? period.start_date,
endDate: period.endDate ?? period.end_date,
periodType: period.periodType ?? period.period_type,
isActive: Boolean(period.isActive ?? period.is_active),
isArchived: Boolean(period.isArchived ?? period.is_archived),
archivedAt: period.archivedAt ?? period.archived_at ?? null,
archivedBy: period.archivedBy ?? period.archived_by ?? null,
createdAt: period.createdAt ?? period.created_at,
updatedAt: period.updatedAt ?? period.updated_at,
};
}
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { credentials: 'include', ...init });
const res = await fetch(url, { credentials: 'include', cache: 'no-store', ...init });
if (!res.ok) {
const text = await res.text();
try {
@@ -35,10 +52,10 @@ async function api<T>(url: string, init?: RequestInit): Promise<T> {
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
const iso = date.toISOString().slice(0, 10);
const { period } = await api<{ period: AcademicPeriod | null }>(
const { period } = await api<{ period: any | null }>(
`/api/academic_periods/for_date?date=${iso}`
);
return period ?? null;
return period ? normalizeAcademicPeriod(period) : null;
}
export async function listAcademicPeriods(options?: {
@@ -53,20 +70,20 @@ export async function listAcademicPeriods(options?: {
params.set('archivedOnly', '1');
}
const query = params.toString();
const { periods } = await api<{ periods: AcademicPeriod[] }>(
const { periods } = await api<{ periods: any[] }>(
`/api/academic_periods${query ? `?${query}` : ''}`
);
return Array.isArray(periods) ? periods : [];
return Array.isArray(periods) ? periods.map(normalizeAcademicPeriod) : [];
}
export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`);
return period;
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`);
return normalizeAcademicPeriod(period);
}
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
return period ?? null;
const { period } = await api<{ period: any | null }>(`/api/academic_periods/active`);
return period ? normalizeAcademicPeriod(period) : null;
}
export async function createAcademicPeriod(payload: {
@@ -76,12 +93,12 @@ export async function createAcademicPeriod(payload: {
endDate: string;
periodType: 'schuljahr' | 'semester' | 'trimester';
}): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods`, {
const { period } = await api<{ period: any }>(`/api/academic_periods`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return period;
return normalizeAcademicPeriod(period);
}
export async function updateAcademicPeriod(
@@ -94,36 +111,36 @@ export async function updateAcademicPeriod(
periodType: 'schuljahr' | 'semester' | 'trimester';
}>
): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`, {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return period;
return normalizeAcademicPeriod(period);
}
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/activate`, {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return period;
return normalizeAcademicPeriod(period);
}
export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/archive`, {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/archive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return period;
return normalizeAcademicPeriod(period);
}
export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/restore`, {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return period;
return normalizeAcademicPeriod(period);
}
export async function getAcademicPeriodUsage(id: number): Promise<PeriodUsage> {

File diff suppressed because it is too large Load Diff

View File

@@ -21,12 +21,14 @@ import {
type PeriodUsage
} from './apiAcademicPeriods';
import { formatIsoDateForDisplay } from './dateFormatting';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
// Minimal event type for Syncfusion Tab 'selected' callback
type TabSelectedEvent = { selectedIndex?: number };
const Einstellungen: React.FC = () => {
const location = useLocation();
// Presentation settings state
const [presentationInterval, setPresentationInterval] = React.useState(10);
const [presentationPageProgress, setPresentationPageProgress] = React.useState(true);
@@ -670,6 +672,8 @@ const Einstellungen: React.FC = () => {
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
const isSuperadmin = !!(user && user.role === 'superadmin');
const [rootTabIndex, setRootTabIndex] = React.useState(0);
// Preserve selected nested-tab indices to avoid resets on parent re-render
const [academicTabIndex, setAcademicTabIndex] = React.useState(0);
const [displayTabIndex, setDisplayTabIndex] = React.useState(0);
@@ -678,6 +682,22 @@ const Einstellungen: React.FC = () => {
const [usersTabIndex, setUsersTabIndex] = React.useState(0);
const [systemTabIndex, setSystemTabIndex] = React.useState(0);
React.useEffect(() => {
const params = new URLSearchParams(location.search);
const focus = params.get('focus');
if (focus === 'holidays') {
setRootTabIndex(0);
setAcademicTabIndex(1);
return;
}
if (focus === 'academic-periods') {
setRootTabIndex(0);
setAcademicTabIndex(0);
}
}, [location.search]);
// ---------- Leaf content functions (second-level tabs) ----------
// Academic Calendar
// (Old separate Import/List tab contents removed in favor of combined tab)
@@ -1695,7 +1715,11 @@ const Einstellungen: React.FC = () => {
<h2 style={{ marginBottom: 20, fontSize: '24px', fontWeight: 600 }}>Einstellungen</h2>
<TabComponent heightAdjustMode="Auto">
<TabComponent
heightAdjustMode="Auto"
selectedItem={rootTabIndex}
selected={(e: TabSelectedEvent) => setRootTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={AcademicCalendarTabs} />
{isAdmin && (