Files
infoscreen/dashboard/src/apiAcademicPeriods.ts
Olaf 4d652f0554 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
2026-04-02 14:16:53 +00:00

157 lines
5.0 KiB
TypeScript

export type AcademicPeriod = {
id: number;
name: string;
displayName?: string | null;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
periodType: 'schuljahr' | 'semester' | 'trimester';
isActive: boolean;
isArchived: boolean;
archivedAt?: string | null;
archivedBy?: number | null;
createdAt?: string;
updatedAt?: string;
};
export type PeriodUsage = {
linked_events: number;
has_active_recurrence: boolean;
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', cache: 'no-store', ...init });
if (!res.ok) {
const text = await res.text();
try {
const err = JSON.parse(text);
throw new Error(err.error || `HTTP ${res.status}`);
} catch {
throw new Error(`HTTP ${res.status}: ${text}`);
}
}
return res.json();
}
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
const iso = date.toISOString().slice(0, 10);
const { period } = await api<{ period: any | null }>(
`/api/academic_periods/for_date?date=${iso}`
);
return period ? normalizeAcademicPeriod(period) : null;
}
export async function listAcademicPeriods(options?: {
includeArchived?: boolean;
archivedOnly?: boolean;
}): Promise<AcademicPeriod[]> {
const params = new URLSearchParams();
if (options?.includeArchived) {
params.set('includeArchived', '1');
}
if (options?.archivedOnly) {
params.set('archivedOnly', '1');
}
const query = params.toString();
const { periods } = await api<{ periods: any[] }>(
`/api/academic_periods${query ? `?${query}` : ''}`
);
return Array.isArray(periods) ? periods.map(normalizeAcademicPeriod) : [];
}
export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> {
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: any | null }>(`/api/academic_periods/active`);
return period ? normalizeAcademicPeriod(period) : null;
}
export async function createAcademicPeriod(payload: {
name: string;
displayName?: string;
startDate: string;
endDate: string;
periodType: 'schuljahr' | 'semester' | 'trimester';
}): Promise<AcademicPeriod> {
const { period } = await api<{ period: any }>(`/api/academic_periods`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return normalizeAcademicPeriod(period);
}
export async function updateAcademicPeriod(
id: number,
payload: Partial<{
name: string;
displayName: string | null;
startDate: string;
endDate: string;
periodType: 'schuljahr' | 'semester' | 'trimester';
}>
): Promise<AcademicPeriod> {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return normalizeAcademicPeriod(period);
}
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return normalizeAcademicPeriod(period);
}
export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/archive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return normalizeAcademicPeriod(period);
}
export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return normalizeAcademicPeriod(period);
}
export async function getAcademicPeriodUsage(id: number): Promise<PeriodUsage> {
const { usage } = await api<{ usage: PeriodUsage }>(`/api/academic_periods/${id}/usage`);
return usage;
}
export async function deleteAcademicPeriod(id: number): Promise<void> {
await api(`/api/academic_periods/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
}