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

@@ -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> {