gute
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
scripts/.tmp-verify/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Berichtsheft-Generator (Web)
|
||||
|
||||
## Suggestions regenerieren
|
||||
|
||||
Aus den echten Heften unter `../1. Ausbildungsjahr` und `../2. Ausbildungsjahr`:
|
||||
|
||||
```bash
|
||||
npm run generate:suggestions
|
||||
```
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. `scripts/extract-phrases.mjs` – `.docx` → Rohdaten
|
||||
2. `scripts/clean-phrases.mjs` → `scripts/clean-phrases.json`
|
||||
3. `scripts/score-phrases.mjs` → `scripts/scored-phrases.json` (category + weight)
|
||||
4. `scripts/build-real-suggestions.mjs` → `src/data/suggestions.ts` (`SUGGESTIONS`, `WEEK_THEMES` nur echte Themen)
|
||||
5. `scripts/verify-suggestions.mjs` – Qualitätstor: Müll-Regex, gültiger `type`, `weight ≥ 1`, keine Duplikate, ≥70 % exakte Betrieb-Originale gegen `clean-phrases.json`
|
||||
|
||||
Nur das Tor erneut prüfen:
|
||||
|
||||
```bash
|
||||
npm run check:suggestions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Berichtsheft Generator</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Source+Sans+3:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "berichtsheft-generator",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/verify-calendar.mjs",
|
||||
"test:calendar": "node scripts/verify-calendar.mjs",
|
||||
"clean:phrases": "node scripts/clean-phrases.mjs",
|
||||
"check:suggestions": "node scripts/verify-suggestions.mjs",
|
||||
"generate:suggestions": "node scripts/extract-phrases.mjs && node scripts/clean-phrases.mjs && node scripts/score-phrases.mjs && node scripts/build-real-suggestions.mjs && node scripts/verify-suggestions.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"file-saver": "^2.0.5",
|
||||
"jspdf": "^4.2.1",
|
||||
"pizzip": "^3.2.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/pizzip": "^3.0.5",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"oxlint": "^1.71.0",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
@@ -0,0 +1,180 @@
|
||||
// src/data/holidays-bw.ts
|
||||
var BW_SCHOOL_HOLIDAYS = [
|
||||
// Schuljahr 2025/26
|
||||
{ from: "2025-12-22", to: "2026-01-05", name: "Weihnachtsferien" },
|
||||
{ from: "2026-03-30", to: "2026-04-11", name: "Osterferien" },
|
||||
{ from: "2026-05-26", to: "2026-06-05", name: "Pfingstferien" },
|
||||
{ from: "2026-07-30", to: "2026-09-12", name: "Sommerferien" },
|
||||
{ from: "2026-10-26", to: "2026-10-30", name: "Herbstferien" },
|
||||
{ from: "2026-10-31", to: "2026-10-31", name: "Reformationsfest" },
|
||||
// Schuljahr 2026/27
|
||||
{ from: "2026-12-23", to: "2027-01-09", name: "Weihnachtsferien" },
|
||||
{ from: "2027-03-25", to: "2027-03-25", name: "Gr\xFCndonnerstag" },
|
||||
{ from: "2027-03-30", to: "2027-04-03", name: "Osterferien" },
|
||||
{ from: "2027-05-18", to: "2027-05-29", name: "Pfingstferien" },
|
||||
{ from: "2027-07-29", to: "2027-09-11", name: "Sommerferien" },
|
||||
{ from: "2027-11-02", to: "2027-11-06", name: "Herbstferien" },
|
||||
// Schuljahr 2027/28
|
||||
{ from: "2027-12-23", to: "2028-01-08", name: "Weihnachtsferien" }
|
||||
];
|
||||
var BW_PUBLIC_HOLIDAYS = {
|
||||
"2026-01-01": "Neujahr",
|
||||
"2026-01-06": "Heilige Drei K\xF6nige",
|
||||
"2026-04-03": "Karfreitag",
|
||||
"2026-04-06": "Ostermontag",
|
||||
"2026-05-01": "Tag der Arbeit",
|
||||
"2026-05-14": "Christi Himmelfahrt",
|
||||
"2026-05-25": "Pfingstmontag",
|
||||
"2026-06-04": "Fronleichnam",
|
||||
"2026-10-03": "Tag der Deutschen Einheit",
|
||||
"2026-11-01": "Allerheiligen",
|
||||
"2026-12-25": "1. Weihnachtsfeiertag",
|
||||
"2026-12-26": "2. Weihnachtsfeiertag",
|
||||
"2027-01-01": "Neujahr",
|
||||
"2027-01-06": "Heilige Drei K\xF6nige",
|
||||
"2027-03-26": "Karfreitag",
|
||||
"2027-03-29": "Ostermontag",
|
||||
"2027-05-01": "Tag der Arbeit",
|
||||
"2027-05-06": "Christi Himmelfahrt",
|
||||
"2027-05-17": "Pfingstmontag",
|
||||
"2027-05-27": "Fronleichnam",
|
||||
"2027-10-03": "Tag der Deutschen Einheit",
|
||||
"2027-11-01": "Allerheiligen",
|
||||
"2027-12-25": "1. Weihnachtsfeiertag",
|
||||
"2027-12-26": "2. Weihnachtsfeiertag"
|
||||
};
|
||||
var LAST_COMPLETED_DATE = "2026-04-03";
|
||||
var START_WEEK_NUMBER = 79;
|
||||
|
||||
// src/lib/calendar.ts
|
||||
var WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
|
||||
function parseISO(iso) {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
function toISO(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
function formatDE(iso) {
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
function addDays(iso, days) {
|
||||
const d = parseISO(iso);
|
||||
d.setDate(d.getDate() + days);
|
||||
return toISO(d);
|
||||
}
|
||||
function isWeekend(iso) {
|
||||
const wd = parseISO(iso).getDay();
|
||||
return wd === 0 || wd === 6;
|
||||
}
|
||||
function isInSchoolHoliday(iso) {
|
||||
for (const h of BW_SCHOOL_HOLIDAYS) {
|
||||
if (iso >= h.from && iso <= h.to) return h.name;
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
function getPublicHoliday(iso) {
|
||||
return BW_PUBLIC_HOLIDAYS[iso];
|
||||
}
|
||||
function getDayType(iso) {
|
||||
if (getPublicHoliday(iso)) return "feiertag";
|
||||
if (isInSchoolHoliday(iso)) return "betrieb";
|
||||
const wd = parseISO(iso).getDay();
|
||||
if (wd === 2 || wd === 4) return "schule";
|
||||
return "betrieb";
|
||||
}
|
||||
function getMonday(iso) {
|
||||
const d = parseISO(iso);
|
||||
const wd = d.getDay();
|
||||
const diff = wd === 0 ? -6 : 1 - wd;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return toISO(d);
|
||||
}
|
||||
function getFriday(iso) {
|
||||
return addDays(getMonday(iso), 4);
|
||||
}
|
||||
function getWeekNumber(iso) {
|
||||
const monday = getMonday(iso);
|
||||
const baseMonday = getMonday(LAST_COMPLETED_DATE);
|
||||
const baseWeek = START_WEEK_NUMBER - 1;
|
||||
const days = (parseISO(monday).getTime() - parseISO(baseMonday).getTime()) / (24 * 60 * 60 * 1e3);
|
||||
return baseWeek + Math.round(days / 7);
|
||||
}
|
||||
function getYearLabel(iso) {
|
||||
if (iso < "2025-09-01") return "Erstes Ausbildungsjahr";
|
||||
if (iso < "2026-09-01") return "Zweites Ausbildungsjahr";
|
||||
return "Drittes Ausbildungsjahr";
|
||||
}
|
||||
function getDayInfo(iso) {
|
||||
const d = parseISO(iso);
|
||||
const weekday = d.getDay();
|
||||
const holidayName = getPublicHoliday(iso);
|
||||
const schoolHolidayName = isInSchoolHoliday(iso);
|
||||
return {
|
||||
date: iso,
|
||||
weekday,
|
||||
weekdayLabel: WEEKDAYS[weekday],
|
||||
type: getDayType(iso),
|
||||
holidayName,
|
||||
schoolHolidayName,
|
||||
weekNumber: getWeekNumber(iso),
|
||||
weekMonday: getMonday(iso),
|
||||
weekFriday: getFriday(iso),
|
||||
yearLabel: getYearLabel(iso)
|
||||
};
|
||||
}
|
||||
function nextWorkday(iso) {
|
||||
let cur = addDays(iso, 1);
|
||||
while (isWeekend(cur)) cur = addDays(cur, 1);
|
||||
return cur;
|
||||
}
|
||||
function prevWorkday(iso) {
|
||||
let cur = addDays(iso, -1);
|
||||
while (isWeekend(cur)) cur = addDays(cur, -1);
|
||||
return cur;
|
||||
}
|
||||
function firstOpenDay() {
|
||||
return nextWorkday(LAST_COMPLETED_DATE);
|
||||
}
|
||||
function weekWorkdays(monday) {
|
||||
return [0, 1, 2, 3, 4].map((i) => addDays(monday, i));
|
||||
}
|
||||
function weekNeedsSchool(monday) {
|
||||
return weekWorkdays(monday).some((d) => getDayType(d) === "schule");
|
||||
}
|
||||
function feiertagActivityLine(iso) {
|
||||
const info = getDayInfo(iso);
|
||||
if (info.type !== "feiertag") return void 0;
|
||||
return `${info.weekdayLabel} Feiertag`;
|
||||
}
|
||||
function typeLabel(type) {
|
||||
if (type === "betrieb") return "Betrieb";
|
||||
if (type === "schule") return "Berufsschule";
|
||||
return "Feiertag";
|
||||
}
|
||||
export {
|
||||
addDays,
|
||||
feiertagActivityLine,
|
||||
firstOpenDay,
|
||||
formatDE,
|
||||
getDayInfo,
|
||||
getDayType,
|
||||
getFriday,
|
||||
getMonday,
|
||||
getPublicHoliday,
|
||||
getWeekNumber,
|
||||
getYearLabel,
|
||||
isInSchoolHoliday,
|
||||
isWeekend,
|
||||
nextWorkday,
|
||||
parseISO,
|
||||
prevWorkday,
|
||||
toISO,
|
||||
typeLabel,
|
||||
weekNeedsSchool,
|
||||
weekWorkdays
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* S04 – Stilgleiche Varianten (sparsam) + Ausgabe suggestions.ts
|
||||
* Input: scored-phrases.json (S03), Themes aus clean-phrases.json (S02)
|
||||
*
|
||||
* Regeln:
|
||||
* - ≥ 70 % Betrieb = exakte Originale
|
||||
* - ≤ 30 % stilgleiche Varianten
|
||||
* - Varianten-weight = max(1, originalWeight - 2)
|
||||
* - Schule: nur Originale
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const scoredPath = join(__dirname, "scored-phrases.json");
|
||||
const cleanPath = join(__dirname, "clean-phrases.json");
|
||||
const outTs = join(__dirname, "..", "src", "data", "suggestions.ts");
|
||||
|
||||
if (!existsSync(scoredPath)) {
|
||||
console.error("Fehlt scored-phrases.json – zuerst S03 (score-phrases.mjs) ausführen.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scored = JSON.parse(readFileSync(scoredPath, "utf8"));
|
||||
const clean = existsSync(cleanPath)
|
||||
? JSON.parse(readFileSync(cleanPath, "utf8"))
|
||||
: { themes: [], themesWeighted: [] };
|
||||
|
||||
/** @param {string} s */
|
||||
function normKey(s) {
|
||||
return String(s)
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.…]+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur Varianten, die klar aus belegten Mustern kommen (keine neuen Themen).
|
||||
* `from` muss exakt (normiert) in den Originalen vorkommen.
|
||||
* @type {{ from: string, text: string }[]}
|
||||
*/
|
||||
const VARIANT_SPECS = [
|
||||
// Beispiele aus S04
|
||||
{ from: "Termine vereinbart", text: "Termine telefonisch vereinbart" },
|
||||
{ from: "Termine vereinbart", text: "Termine mit Kunden vereinbart" },
|
||||
{ from: "Ablage einsortiert", text: "Ablage sortiert und archiviert" },
|
||||
{ from: "Rechnungen geschrieben", text: "Rechnungen per E-Mail verschickt" },
|
||||
{ from: "Getränke geholt", text: "Getränke für unsere Belegschaft geholt" },
|
||||
|
||||
// Nah an belegten Formulierungen (Wortstellung / Kürzung / Tippfehler)
|
||||
{ from: "Telefonisch Termine vereinbart", text: "Termine telefonisch vergeben" },
|
||||
{ from: "Kunden telefonisch betreut", text: "Kunden per Telefon betreut" },
|
||||
{ from: "Kasse gezählt", text: "Kassenbuch geführt" },
|
||||
{
|
||||
from: "Kassenbuch geführt und die Kasse gezählt auf Vollständigkeit",
|
||||
text: "Kassenbuch geführt und die Kasse gezählt",
|
||||
},
|
||||
{ from: "Reifen bestellt", text: "Reifen für Kunden bestellt" },
|
||||
{ from: "Spülmaschine ausgeräumt", text: "Spülmaschine ein- und ausgeräumt" },
|
||||
{
|
||||
from: "Bewertungen gesammelt für autoservice.com",
|
||||
text: "Bewertungen für autoservice.com gesammelt",
|
||||
},
|
||||
{
|
||||
from: "Lieferung angenommen und im Lager vorsortiert.",
|
||||
text: "Lieferung angenommen und vorsortiert",
|
||||
},
|
||||
{
|
||||
from: "Mietfahrzeug verliehen an Kunden",
|
||||
text: "Mietfahrzeug an Kunden verliehen",
|
||||
},
|
||||
{
|
||||
from: "Kundenauto nach Feuchtwangen fortgebracht.",
|
||||
text: "Kundenauto nach Feuchtwangen gebracht",
|
||||
},
|
||||
{
|
||||
from: "Eingangsrechnung überprüft und telefonisch korrigieren lassen.",
|
||||
text: "Eingangsrechnung überprüft",
|
||||
},
|
||||
{
|
||||
from: "Termin wünsche per E-Mail abgearbeitet",
|
||||
text: "Terminwünsche per E-Mail abgearbeitet",
|
||||
},
|
||||
{
|
||||
from: "Auto Ersatzteil beim WM in Crailsheim abgeholt",
|
||||
text: "Teil beim WM in Crailsheim abgeholt",
|
||||
},
|
||||
{
|
||||
from: "Cybersicherheitsherausforderungen absolviert bei „Guardey“",
|
||||
text: "Cybersicherheitsherausforderungen bei Guardey absolviert",
|
||||
},
|
||||
{
|
||||
from: "Aufträge für die kommende Woche vorbereitet und einsortiert.",
|
||||
text: "Aufträge für die kommende Woche vorbereitet und einsortiert",
|
||||
},
|
||||
{ from: "Kassenbuch führen", text: "Kassenbuch kontrolliert" },
|
||||
{
|
||||
from: "Restliche Termine für den Radwechseltag vergeben",
|
||||
text: "Termine für Radwechseltag vergeben",
|
||||
},
|
||||
{
|
||||
from: "Rechnungen per E-Mail verschickt an Geschäftskunden.",
|
||||
text: "Rechnungen an Geschäftskunden per E-Mail verschickt",
|
||||
},
|
||||
{
|
||||
from: "Kunden per Telefon betreut und Termine vereinbart",
|
||||
text: "Kunden telefonisch betreut und Termine koordiniert",
|
||||
},
|
||||
{
|
||||
from: "Beim WM in Crailsheim Teile abgeholt",
|
||||
text: "Teile beim WM in Crailsheim abgeholt",
|
||||
},
|
||||
];
|
||||
|
||||
const phrases = Array.isArray(scored.phrases) ? scored.phrases : [];
|
||||
const originals = phrases.map((p) => ({
|
||||
text: String(p.text).replace(/\s+/g, " ").trim(),
|
||||
type: p.type === "schule" ? "schule" : "betrieb",
|
||||
category: String(p.category || (p.type === "schule" ? "Berufsschule" : "Betrieb")),
|
||||
weight: Math.max(1, Number(p.weight) || 1),
|
||||
original: true,
|
||||
}));
|
||||
|
||||
const byKey = new Map();
|
||||
for (const p of originals) {
|
||||
byKey.set(`${p.type}|${normKey(p.text)}`, p);
|
||||
}
|
||||
|
||||
/** @type {{ text: string, type: string, category: string, weight: number, original: boolean, from: string }[]} */
|
||||
const variants = [];
|
||||
|
||||
for (const spec of VARIANT_SPECS) {
|
||||
const fromKey = normKey(spec.from);
|
||||
const source = originals.find((p) => p.type === "betrieb" && normKey(p.text) === fromKey);
|
||||
if (!source) {
|
||||
console.warn(`S04: Original für Variante fehlt → übersprungen: "${spec.from}"`);
|
||||
continue;
|
||||
}
|
||||
const text = spec.text.replace(/\s+/g, " ").trim();
|
||||
if (text.length < 6 || text.length > 160) continue;
|
||||
const key = `betrieb|${normKey(text)}`;
|
||||
if (byKey.has(key)) continue; // schon Original oder früher angelegt
|
||||
if (variants.some((v) => normKey(v.text) === normKey(text))) continue;
|
||||
|
||||
const weight = Math.max(1, source.weight - 2);
|
||||
const item = {
|
||||
text,
|
||||
type: "betrieb",
|
||||
category: source.category,
|
||||
weight,
|
||||
original: false,
|
||||
from: source.text,
|
||||
};
|
||||
variants.push(item);
|
||||
byKey.set(key, item);
|
||||
}
|
||||
|
||||
const betriebOriginals = originals.filter((p) => p.type === "betrieb");
|
||||
const schoolOriginals = originals.filter((p) => p.type === "schule");
|
||||
|
||||
// Cap: Varianten ≤ 30 % des finalen Betrieb-Pools
|
||||
const maxVariants = Math.floor((betriebOriginals.length * 0.3) / 0.7);
|
||||
const keptVariants = variants.slice(0, maxVariants);
|
||||
|
||||
const all = [...betriebOriginals, ...keptVariants, ...schoolOriginals];
|
||||
all.sort(
|
||||
(a, b) =>
|
||||
(a.type === b.type ? 0 : a.type === "betrieb" ? -1 : 1) ||
|
||||
b.weight - a.weight ||
|
||||
a.text.localeCompare(b.text, "de"),
|
||||
);
|
||||
|
||||
const suggestions = all.map((s, i) => ({
|
||||
id: i + 1,
|
||||
text: s.text,
|
||||
type: s.type,
|
||||
category: s.category,
|
||||
weight: s.weight,
|
||||
}));
|
||||
|
||||
const themesWeighted = clean.themesWeighted ?? (clean.themes ?? []).map((t) =>
|
||||
typeof t === "string" ? { text: t, count: 1 } : t,
|
||||
);
|
||||
const themeSeen = new Set();
|
||||
const allThemes = [];
|
||||
for (const t of themesWeighted) {
|
||||
const cleaned = String(typeof t === "string" ? t : t.text)
|
||||
.replace(/>/g, ">")
|
||||
.replace(/,\s*$/, "")
|
||||
.trim();
|
||||
if (!cleaned || cleaned === "-" || cleaned.length < 3 || cleaned.length > 55) continue;
|
||||
if (/ferien|vorschriften/i.test(cleaned)) continue;
|
||||
if (/\b(abgeholt|gefahren|gebucht|auffüllen|einsortieren)\b/i.test(cleaned)) continue;
|
||||
const key = cleaned.toLowerCase();
|
||||
if (themeSeen.has(key)) continue;
|
||||
themeSeen.add(key);
|
||||
allThemes.push(cleaned);
|
||||
}
|
||||
|
||||
const betriebN = suggestions.filter((s) => s.type === "betrieb").length;
|
||||
const schoolN = suggestions.filter((s) => s.type === "schule").length;
|
||||
const originalShare = betriebN ? betriebOriginals.length / betriebN : 0;
|
||||
const variantShare = betriebN ? keptVariants.length / betriebN : 0;
|
||||
|
||||
let sourceFiles = 0;
|
||||
for (const n of [
|
||||
scored?.meta?.files,
|
||||
scored?.meta?.sourceFiles,
|
||||
scored?.meta?.hefte,
|
||||
clean?.meta?.files,
|
||||
clean?.meta?.hefte,
|
||||
]) {
|
||||
const v = Number(n);
|
||||
if (Number.isFinite(v) && v > 0) {
|
||||
sourceFiles = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!sourceFiles) {
|
||||
try {
|
||||
const rawMeta = JSON.parse(readFileSync(join(__dirname, "raw-phrases.json"), "utf8")).meta;
|
||||
const v = Number(rawMeta?.files || rawMeta?.hefte);
|
||||
if (Number.isFinite(v) && v > 0) sourceFiles = v;
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: suggestions.length,
|
||||
betrieb: betriebN,
|
||||
school: schoolN,
|
||||
originalsBetrieb: betriebOriginals.length,
|
||||
variants: keptVariants.length,
|
||||
originalShareBetrieb: Number(originalShare.toFixed(3)),
|
||||
variantShareBetrieb: Number(variantShare.toFixed(3)),
|
||||
sourceFiles,
|
||||
source: "scored-phrases.json",
|
||||
};
|
||||
|
||||
const content = `/* AUTO-GENERATED aus scored-phrases + sparsame Varianten (S04) · ${suggestions.length} Vorschläge · ${new Date().toISOString().slice(0, 10)} */
|
||||
export type SuggestionType = "betrieb" | "schule";
|
||||
|
||||
export type Suggestion = {
|
||||
id: number;
|
||||
text: string;
|
||||
type: SuggestionType;
|
||||
category: string;
|
||||
/** Häufigkeit/Qualität aus den Originalheften (Varianten: originalWeight - 2) */
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export const SUGGESTIONS: Suggestion[] = ${JSON.stringify(suggestions, null, 2)};
|
||||
|
||||
export const WEEK_THEMES: string[] = ${JSON.stringify(allThemes, null, 2)};
|
||||
|
||||
export const CATEGORIES = [...new Set(SUGGESTIONS.map((s) => s.category))].sort();
|
||||
|
||||
export const SUGGESTION_STATS = ${JSON.stringify(stats, null, 2)} as const;
|
||||
`;
|
||||
|
||||
writeFileSync(outTs, content, "utf8");
|
||||
|
||||
console.log(`Wrote ${suggestions.length} suggestions → ${outTs}`);
|
||||
console.log(
|
||||
`Betrieb: ${betriebN} (Originale ${betriebOriginals.length} = ${(originalShare * 100).toFixed(1)}%, Varianten ${keptVariants.length} = ${(variantShare * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(`Schule: ${schoolN} (nur Originale, 0 Varianten)`);
|
||||
console.log(`Themes: ${allThemes.length}`);
|
||||
|
||||
if (originalShare < 0.7 || variantShare > 0.3) {
|
||||
console.error("FAIL: Mengenregel verletzt (Originale ≥70 %, Varianten ≤30 %).");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("OK: Mengenregel Originale ≥70 % / Varianten ≤30 %");
|
||||
}
|
||||
|
||||
// Abnahme: Verhältnis + Stichprobe Varianten vs. Original
|
||||
console.log("\n--- S04 Stichprobe: Varianten ↔ Original ---");
|
||||
const sample = [...keptVariants].sort(() => Math.random() - 0.5).slice(0, 20);
|
||||
if (sample.length < 20) {
|
||||
console.log(`(nur ${sample.length} Varianten im Pool – alle gelistet)`);
|
||||
}
|
||||
for (const [i, v] of sample.entries()) {
|
||||
console.log(
|
||||
`${String(i + 1).padStart(2)}. „${v.text}“ (w=${v.weight}) ← „${v.from}“`,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* S02 – Säubert + dedupliziert Phrasen aus raw-phrases.json → clean-phrases.json
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const RAW_PATH = join(__dirname, "raw-phrases.json");
|
||||
const OUT_PATH = join(__dirname, "clean-phrases.json");
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
||||
}
|
||||
|
||||
/** trim + Whitespace normalisieren + optionale Endpunkte entfernen (für Dedup-Key) */
|
||||
function normalizeKey(text) {
|
||||
return decodeEntities(String(text))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\.+$/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Anzeigeform: Whitespace normalisieren, Endpunkt entfernen (Heft-Stil ohne Punkt) */
|
||||
function displayText(text) {
|
||||
return decodeEntities(String(text))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\.+$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const HEADER_RE =
|
||||
/^(Abteilung|Ausbildungsnachweis|Für die Zeit|Name|Datum|Zweites|Erstes|Drittes|Betriebliche|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr|Ausbilderin|Ausbilder)\b/i;
|
||||
|
||||
const JUNK_EXACT_RE =
|
||||
/^(43180|Nico|Baumann|Ferien|Feiertag|Urlaub|Schultag|Krank|Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag|Additive,?$|\d{4,}|\d+)$/i;
|
||||
|
||||
function isJunk(text) {
|
||||
const s = displayText(text);
|
||||
if (!s || s.length < 3) return true;
|
||||
if (HEADER_RE.test(s)) return true;
|
||||
if (JUNK_EXACT_RE.test(s)) return true;
|
||||
if (/^[.\-_\s…·•]+$/.test(s)) return true;
|
||||
if (/…{2,}|\.{3,}/.test(s)) return true;
|
||||
if (/Unterschrift/i.test(s)) return true;
|
||||
if (/Ausbildungsnachweis|Für die Zeit|Betriebliche Tätig|Zuordnung zum Lernziel|Thema der Woche|Berufsschule|Lfd\.?\s*Nr/i.test(s))
|
||||
return true;
|
||||
if (/^\d{5,}/.test(s)) return true;
|
||||
if (/^[\d\s./-]+$/.test(s)) return true; // reine Zahlen / Ziffernblöcke
|
||||
if (/<\/?w:|xml|xmlns/i.test(s)) return true;
|
||||
if (!/[a-zäöüß]/i.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFerienActivity(s) {
|
||||
return /^ferien\b/i.test(s) || /^ferien$/i.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string, { text: string, count: number, sources?: Set<string> }>} map
|
||||
* @param {string} raw
|
||||
* @param {string | null} sourceFile
|
||||
* @param {{ trackSources?: boolean, minLen?: number, maxLen?: number }} opts
|
||||
*/
|
||||
function addPhrase(map, raw, sourceFile, opts = {}) {
|
||||
const { trackSources = false, minLen = 4, maxLen = 180 } = opts;
|
||||
if (isJunk(raw)) return;
|
||||
const text = displayText(raw);
|
||||
if (!text || text.length < minLen || text.length > maxLen) return;
|
||||
if (trackSources && isFerienActivity(text)) return;
|
||||
|
||||
const key = normalizeKey(text);
|
||||
if (!key) return;
|
||||
|
||||
const prev = map.get(key);
|
||||
if (prev) {
|
||||
prev.count += 1;
|
||||
// kürzere / punktlose Form bevorzugen
|
||||
if (text.length < prev.text.length || (!/\.$/.test(raw) && /\.$/.test(prev.text))) {
|
||||
prev.text = text;
|
||||
}
|
||||
if (trackSources && sourceFile) prev.sources.add(sourceFile);
|
||||
} else {
|
||||
map.set(key, {
|
||||
text,
|
||||
count: 1,
|
||||
...(trackSources ? { sources: new Set(sourceFile ? [sourceFile] : []) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toSortedList(map, { withSources = false } = {}) {
|
||||
return [...map.values()]
|
||||
.map((entry) => {
|
||||
if (withSources) {
|
||||
return {
|
||||
text: entry.text,
|
||||
count: entry.count,
|
||||
sources: [...entry.sources].sort((a, b) => a.localeCompare(b, "de")),
|
||||
};
|
||||
}
|
||||
return { text: entry.text, count: entry.count };
|
||||
})
|
||||
.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(RAW_PATH, "utf8"));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"raw-phrases.json fehlt oder ist ungültig. Zuerst S01 (extract-phrases.mjs) ausführen.",
|
||||
);
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hefte = raw.hefte ?? [];
|
||||
if (!hefte.length) {
|
||||
console.error("raw-phrases.json enthält keine Hefte.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const activities = new Map();
|
||||
const themes = new Map();
|
||||
const school = new Map();
|
||||
|
||||
for (const heft of hefte) {
|
||||
const source = heft.sourceFile ?? null;
|
||||
|
||||
// Pro Heft einmal pro normalisierter Phrase zählen (Tabellen-Duplikate)
|
||||
const seenAct = new Set();
|
||||
for (const a of heft.activities ?? []) {
|
||||
const key = normalizeKey(a);
|
||||
if (!key || seenAct.has(key)) continue;
|
||||
seenAct.add(key);
|
||||
addPhrase(activities, a, source, {
|
||||
trackSources: true,
|
||||
minLen: 6,
|
||||
maxLen: 160,
|
||||
});
|
||||
}
|
||||
|
||||
const seenTheme = new Set();
|
||||
for (const t of heft.weekThemes ?? []) {
|
||||
const key = normalizeKey(t);
|
||||
if (!key || seenTheme.has(key)) continue;
|
||||
seenTheme.add(key);
|
||||
if (/ferien/i.test(displayText(t))) continue;
|
||||
addPhrase(themes, t, null, { minLen: 3, maxLen: 80 });
|
||||
}
|
||||
|
||||
const seenSchool = new Set();
|
||||
for (const s of heft.schoolTopics ?? []) {
|
||||
const key = normalizeKey(s);
|
||||
if (!key || seenSchool.has(key)) continue;
|
||||
seenSchool.add(key);
|
||||
// Ferien gehört ins Berufsschule-Feld der Woche, nicht als Suggestion-Phrase
|
||||
if (/ferien/i.test(displayText(s))) continue;
|
||||
addPhrase(school, s, null, { minLen: 4, maxLen: 160 });
|
||||
}
|
||||
}
|
||||
|
||||
const actList = toSortedList(activities, { withSources: true }).filter(
|
||||
(a) =>
|
||||
!/^\d/.test(a.text) &&
|
||||
!/Lfd/i.test(a.text) &&
|
||||
/[a-zäöüß]{3,}/i.test(a.text),
|
||||
);
|
||||
const themeList = toSortedList(themes).filter(
|
||||
(t) => t.text !== "-" && !/^[.\-]+$/.test(t.text),
|
||||
);
|
||||
const schoolList = toSortedList(school).filter(
|
||||
(s) => !/^krank$/i.test(s.text) && !/stillarbeit$/i.test(s.text),
|
||||
);
|
||||
|
||||
const clean = {
|
||||
activities: actList,
|
||||
themes: themeList,
|
||||
school: schoolList,
|
||||
meta: {
|
||||
source: "raw-phrases.json",
|
||||
hefte: hefte.length,
|
||||
activities: actList.length,
|
||||
themes: themeList.length,
|
||||
school: schoolList.length,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(clean, null, 2), "utf8");
|
||||
|
||||
console.log(
|
||||
`Clean: ${hefte.length} Hefte → ${actList.length} Activities, ${themeList.length} Themes, ${schoolList.length} School`,
|
||||
);
|
||||
console.log(
|
||||
"Top Activities:",
|
||||
actList
|
||||
.slice(0, 12)
|
||||
.map((a) => `${a.text} (${a.count})`)
|
||||
.join(" · "),
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* S01 – Extrahiert Rohtext + strukturierte Phrasen aus allen Berichtsheft-.docx.
|
||||
* Ausgabe: extracted-all.txt, raw-phrases.json (+ Aggregat für spätere Schritte).
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync, statSync } from "fs";
|
||||
import { dirname, join, resolve, basename } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import PizZip from "pizzip";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..", "..");
|
||||
const YEAR_DIRS = [
|
||||
join(ROOT, "1. Ausbildungsjahr"),
|
||||
join(ROOT, "2. Ausbildungsjahr"),
|
||||
];
|
||||
|
||||
function listDocx(dir) {
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.toLowerCase().endsWith(".docx") && !f.startsWith("~$"))
|
||||
.map((f) => join(dir, f))
|
||||
.filter((p) => statSync(p).isFile());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function docxToLines(path) {
|
||||
const zip = new PizZip(readFileSync(path));
|
||||
const file = zip.file("word/document.xml");
|
||||
if (!file) return [];
|
||||
const xml = file.asText();
|
||||
return xml
|
||||
.split(/<\/w:p>/)
|
||||
.map((p) =>
|
||||
[...p.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)]
|
||||
.map((m) => m[1])
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function dedupeConsecutive(lines) {
|
||||
/** Content is duplicated (two table columns) – keep first occurrence. */
|
||||
const out = [];
|
||||
const seenInDoc = new Set();
|
||||
for (const line of lines) {
|
||||
const key = line.toLowerCase();
|
||||
if (seenInDoc.has(key)) continue;
|
||||
seenInDoc.add(key);
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sliceBetween(lines, startRe, endRe) {
|
||||
const start = lines.findIndex((l) => startRe.test(l));
|
||||
if (start < 0) return [];
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((l) => endRe.test(l));
|
||||
return end < 0 ? rest : rest.slice(0, end);
|
||||
}
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
||||
}
|
||||
|
||||
function stripLeadingJunk(s) {
|
||||
return decodeEntities(s)
|
||||
.replace(/^[\d\s]+/, "")
|
||||
.replace(/^[\s\-•·]+/, "")
|
||||
.replace(/^\d{4,}/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/[,\s]+$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isHeaderNoise(s) {
|
||||
return /^(Abteilung|Ausbildungsnachweis|Für die Zeit|Name|Datum|Zweites|Erstes|Drittes|Betriebliche|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr|Ausbilderin|Ausbilder)/i.test(
|
||||
s,
|
||||
);
|
||||
}
|
||||
|
||||
const JUNK_RE =
|
||||
/^(43180|Nico|Baumann|Ferien|Feiertag|Urlaub|Schultag|Krank|Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag|………………|…………|\.+|-+|_+|Additive,?$|\d{4,}|\d+$)/i;
|
||||
|
||||
function isJunk(s) {
|
||||
if (!s || s.length < 3) return true;
|
||||
if (JUNK_RE.test(s)) return true;
|
||||
if (/^[.\-_\s…]+$/.test(s)) return true;
|
||||
if (/Unterschrift/i.test(s)) return true;
|
||||
if (
|
||||
/Ausbildungsnachweis|Für die Zeit|Betriebliche Tätig|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr/i.test(
|
||||
s,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
if (/^\d{5,}/.test(s)) return true;
|
||||
if (/Zwischenprüfung\s+Freitag/i.test(s)) return true;
|
||||
if (!/[a-zäöüß]/i.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function cleanPhrase(text) {
|
||||
const t = stripLeadingJunk(text);
|
||||
if (isJunk(t) || isHeaderNoise(t)) return null;
|
||||
if (t.length < 3 || t.length > 180) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function isFrameworkRef(s) {
|
||||
return (
|
||||
/^Lfd\.?\s*Nr/i.test(s) ||
|
||||
/^\d+(\.\d+)+$/.test(s) ||
|
||||
/Ausbildungsrahmenplan/i.test(s) ||
|
||||
/^Zuordnung der Tätigkeit/i.test(s)
|
||||
);
|
||||
}
|
||||
|
||||
function isPlausibleTheme(s) {
|
||||
if (!s || s.length < 3 || s.length > 55) return false;
|
||||
if (/[.]{2,}/.test(s)) return false;
|
||||
if (/\b(abgeholt|gefahren|gebucht|auffüllen|einsortieren|anbieten|festhalten)\b/i.test(s))
|
||||
return false;
|
||||
if (/^(bei |zum |was |welche |wenn |mehr |immer )/i.test(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseSections(lines) {
|
||||
const unique = dedupeConsecutive(lines);
|
||||
|
||||
const betriebIdx = unique.findIndex((l) => /^Betriebliche Tätigkeiten/i.test(l));
|
||||
let activityLines = [];
|
||||
if (betriebIdx > 0) {
|
||||
let start = 0;
|
||||
for (let i = 0; i < betriebIdx; i++) {
|
||||
if (/^Name\b/i.test(unique[i]) || /^Datum\b/i.test(unique[i])) start = i + 1;
|
||||
}
|
||||
activityLines = unique.slice(start, betriebIdx);
|
||||
}
|
||||
|
||||
// Rahmenplan-Refs zwischen „Betriebliche Tätigkeiten“ und „Thema der Woche“
|
||||
const frameworkRaw = sliceBetween(
|
||||
unique,
|
||||
/^Betriebliche Tätigkeiten/i,
|
||||
/^Thema der Woche/i,
|
||||
);
|
||||
|
||||
const themeAfter = sliceBetween(
|
||||
unique,
|
||||
/^Thema der Woche/i,
|
||||
/^(Zuordnung zum Lernziel|Berufsschule)/i,
|
||||
);
|
||||
|
||||
const schoolLines = sliceBetween(
|
||||
unique,
|
||||
/^Berufsschule/i,
|
||||
/^(Datum und Unterschrift|\.{3,}|…{2,})/i,
|
||||
).filter((l) => !/^(Datum und Unterschrift|\.{3,}|…)/i.test(l));
|
||||
|
||||
return {
|
||||
activityLines,
|
||||
frameworkRaw,
|
||||
themeLines: themeAfter,
|
||||
schoolLines,
|
||||
};
|
||||
}
|
||||
|
||||
function addCount(map, text) {
|
||||
const t = cleanPhrase(text);
|
||||
if (!t) return;
|
||||
const key = t.toLowerCase();
|
||||
const prev = map.get(key);
|
||||
if (prev) prev.count += 1;
|
||||
else map.set(key, { text: t, count: 1 });
|
||||
}
|
||||
|
||||
function toSortedList(map, { minLen = 4, maxLen = 180 } = {}) {
|
||||
return [...map.values()]
|
||||
.filter((x) => x.text.length >= minLen && x.text.length <= maxLen)
|
||||
.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de"));
|
||||
}
|
||||
|
||||
function structureHeft(path, lines) {
|
||||
const { activityLines, frameworkRaw, themeLines, schoolLines } = parseSections(lines);
|
||||
|
||||
const activities = [];
|
||||
for (const a of activityLines) {
|
||||
const t = cleanPhrase(a);
|
||||
if (t) activities.push(t);
|
||||
}
|
||||
|
||||
const frameworkRefs = [];
|
||||
for (const f of frameworkRaw) {
|
||||
const raw = decodeEntities(f).replace(/\s+/g, " ").trim();
|
||||
if (!raw) continue;
|
||||
if (isFrameworkRef(raw) || /^Lfd/i.test(raw)) {
|
||||
frameworkRefs.push(raw);
|
||||
continue;
|
||||
}
|
||||
// kurze Stichworte vor „Thema der Woche“ oft Rahmenplan-/Themenreste – behalten zum Filtern
|
||||
const cleaned = stripLeadingJunk(raw);
|
||||
if (
|
||||
cleaned &&
|
||||
cleaned.length <= 40 &&
|
||||
!isJunk(cleaned) &&
|
||||
!/^[.\-…]+$/.test(cleaned)
|
||||
) {
|
||||
frameworkRefs.push(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
const weekThemes = [];
|
||||
for (const t of themeLines) {
|
||||
const cleaned = stripLeadingJunk(t);
|
||||
if (!cleaned || cleaned === "-") continue;
|
||||
if (/ferien/i.test(cleaned)) continue;
|
||||
if (!isPlausibleTheme(cleaned)) continue;
|
||||
if (isJunk(cleaned) || isHeaderNoise(cleaned)) continue;
|
||||
weekThemes.push(cleaned);
|
||||
}
|
||||
|
||||
const schoolTopics = [];
|
||||
for (const s of schoolLines) {
|
||||
if (/ferien/i.test(s)) continue;
|
||||
const t = cleanPhrase(s);
|
||||
if (t) schoolTopics.push(t);
|
||||
}
|
||||
|
||||
return {
|
||||
sourceFile: basename(path),
|
||||
activities,
|
||||
weekThemes,
|
||||
schoolTopics,
|
||||
frameworkRefs,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = YEAR_DIRS.flatMap(listDocx).sort((a, b) =>
|
||||
basename(a).localeCompare(basename(b), "de"),
|
||||
);
|
||||
if (!files.length) {
|
||||
console.error("Keine .docx in 1./2. Ausbildungsjahr gefunden.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const activities = new Map();
|
||||
const themes = new Map();
|
||||
const school = new Map();
|
||||
const dump = [];
|
||||
const rawPhrases = [];
|
||||
|
||||
for (const path of files) {
|
||||
const lines = docxToLines(path);
|
||||
const name = basename(path);
|
||||
dump.push(`===== ${name} =====`, "", ...lines, "", "");
|
||||
|
||||
const structured = structureHeft(path, lines);
|
||||
rawPhrases.push(structured);
|
||||
|
||||
for (const a of structured.activities) addCount(activities, a);
|
||||
for (const t of structured.weekThemes) addCount(themes, t);
|
||||
for (const s of structured.schoolTopics) addCount(school, s);
|
||||
}
|
||||
|
||||
const actList = toSortedList(activities, { minLen: 6, maxLen: 160 }).filter(
|
||||
(a) =>
|
||||
!/^\d/.test(a.text) &&
|
||||
!/Lfd/i.test(a.text) &&
|
||||
/[a-zäöüß]{3,}/i.test(a.text),
|
||||
);
|
||||
const themeList = toSortedList(themes, { minLen: 3, maxLen: 80 }).filter(
|
||||
(t) => !/^[.\-]+$/.test(t.text) && t.text !== "-",
|
||||
);
|
||||
const schoolList = toSortedList(school, { minLen: 4, maxLen: 160 }).filter(
|
||||
(s) => !/^krank$/i.test(s.text) && !/stillarbeit$/i.test(s.text),
|
||||
);
|
||||
|
||||
const clean = {
|
||||
activities: actList,
|
||||
themes: themeList.map((t) => t.text),
|
||||
themesWeighted: themeList,
|
||||
school: schoolList.map((s) => s.text),
|
||||
schoolWeighted: schoolList,
|
||||
meta: {
|
||||
files: files.length,
|
||||
activities: actList.length,
|
||||
themes: themeList.length,
|
||||
school: schoolList.length,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const rawOut = {
|
||||
meta: {
|
||||
files: files.length,
|
||||
hefte: rawPhrases.length,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
hefte: rawPhrases,
|
||||
};
|
||||
|
||||
writeFileSync(join(__dirname, "extracted-all.txt"), dump.join("\n"), "utf8");
|
||||
writeFileSync(join(__dirname, "raw-phrases.json"), JSON.stringify(rawOut, null, 2), "utf8");
|
||||
// Aggregat nur als Zwischenstand; saubere Form kommt aus S02 (clean-phrases.mjs)
|
||||
writeFileSync(join(__dirname, "parsed-phrases.json"), JSON.stringify(clean, null, 2), "utf8");
|
||||
|
||||
const withActs = rawPhrases.filter((h) => h.activities.length > 0).length;
|
||||
const emptyActs = rawPhrases.filter((h) => h.activities.length === 0);
|
||||
console.log(
|
||||
`Parsed ${files.length} docx → raw-phrases.json (${withActs} mit Activities, ${emptyActs.length} ohne)`,
|
||||
);
|
||||
console.log(
|
||||
`Aggregat: ${actList.length} Tätigkeiten, ${themeList.length} Themen, ${schoolList.length} Schule`,
|
||||
);
|
||||
if (emptyActs.length) {
|
||||
console.log(
|
||||
"Ohne Activities:",
|
||||
emptyActs.map((h) => h.sourceFile).join(" · "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @deprecated Nutze `npm run generate:suggestions`
|
||||
* (extract-phrases.mjs → clean-phrases.mjs → build-real-suggestions.mjs).
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const steps = ["extract-phrases.mjs", "clean-phrases.mjs", "build-real-suggestions.mjs"];
|
||||
for (const step of steps) {
|
||||
const r = spawnSync(process.execPath, [join(dir, step)], { stdio: "inherit" });
|
||||
if (r.status) process.exit(r.status ?? 1);
|
||||
}
|
||||
process.exit(0);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Beste Suggestions aus allen echten Berichtsheften.
|
||||
* - Extrahiert aus 1./2. Ausbildungsjahr
|
||||
* - Wirft Müll / KI-Kunstsätze raus
|
||||
* - Gewichtet nach Häufigkeit
|
||||
* - Max. ~25% stilgleiche Varianten
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import JSZip from "jszip";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const YEAR1 = join(ROOT, "1. Ausbildungsjahr");
|
||||
const YEAR2 = join(ROOT, "2. Ausbildungsjahr");
|
||||
const OUT_TS = join(__dirname, "..", "src", "data", "suggestions.ts");
|
||||
const OUT_JSON = join(__dirname, "clean-phrases.json");
|
||||
|
||||
function listDocx(dir) {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.toLowerCase().endsWith(".docx") && !f.startsWith("~$"))
|
||||
.map((f) => join(dir, f));
|
||||
}
|
||||
|
||||
async function extractTextFromDocx(filePath) {
|
||||
try {
|
||||
const buf = readFileSync(filePath);
|
||||
const zip = await JSZip.loadAsync(buf);
|
||||
const file = zip.file("word/document.xml");
|
||||
if (!file) return "";
|
||||
let xml = await file.async("string");
|
||||
xml = xml.replace(/<\/w:p>/g, "\n");
|
||||
xml = xml.replace(/<[^>]+>/g, "");
|
||||
return xml
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#\d+;/g, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(s) {
|
||||
return s
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^[\s\-•–—]+/, "")
|
||||
.replace(/[.]+$/, (m) => (m.length > 1 ? "" : "."))
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normKey(s) {
|
||||
return normalize(s)
|
||||
.toLowerCase()
|
||||
.replace(/[.…]+$/g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
const JUNK =
|
||||
/^(abteilung|ausbildungsnachweis|für die zeit|name|datum|zweites|erstes|drittes|betriebliche|zuordnung|thema der woche|berufsschule|lfd\.?\s*nr|ausbilderin|nico|baumann|ferien|feiertag|urlaub|schultag|montag|dienstag|mittwoch|donnerstag|freitag)(\b|$)/i;
|
||||
|
||||
const JUNK_ANY =
|
||||
/unterschrift|lfd\.?\s*nr|ausbildungsrahmenplan|…………|\.{5,}|^\d{5,}|43180|76835|88900|31115|60325|69850|zuordnung zum lernziel|tägliche tätigkeiten/i;
|
||||
|
||||
const BAD_ARTIFICIAL =
|
||||
/\bNr\.\s*\d{1,4}\b|Rechnung\s+\d{3,}|Auftrag\s+\d{3,}|Unit\s+\d+|Aufgabe\s+\d+|Übungseinheit\s+\d+|Position\s+\d+|Charge\s+\d+|BWL:\s|BFK:\s|Buchhaltung:\s|Englisch:\s|Deutsch:\s|Controlling Kennzahlen|Optimierung der internen|Kommunikationsprozesse|ZUGFeRD|Proformarechnung für Export/i;
|
||||
|
||||
function isSchoolLine(text) {
|
||||
const t = text.toLowerCase();
|
||||
if (
|
||||
/^(bwl|bfk|englisch|deutsch|excel|word|geschichte|politik|religion|textverarbeitung|projekt|introducing|polypol|kritik am|aufgaben zum|prüfungs|stillarbeit|besprechung der|beschaffung|erstellen einer|diagramme|briefe im|nachhaltigkeit|belastung|das-\/dass|optimale bestell|bestellverfahren|beispielrechnung|lagerkennzahlen|formatvorlage|funktion der|demograf|tarifvertrag|arbeitsschutz|listening|klassenarbeit)/i.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/\b(bwl|englisch|excel|klassenarbeit|textverarbeitung|listening|polypol|projektarbeit|sozialabgaben|ergonomie)\b/i.test(
|
||||
t,
|
||||
) &&
|
||||
!/\b(kunde|rechnung geschrieben|termine|ablage|reifen|kasse|lagerware)\b/i.test(t)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function categorizeBetrieb(text) {
|
||||
const t = text.toLowerCase();
|
||||
if (/rechnung|gutschrift|zahlungs|mahnung|avis|skonto|betrag|bar |ec-/.test(t))
|
||||
return "Rechnungswesen";
|
||||
if (/termin|kunde|kundin|telefon|bewertung|mobilit|angebot/.test(t)) return "Kunden";
|
||||
if (/reifen|lager|lieferung|bestell|ware|artikel|wm |retour|einbuch/.test(t))
|
||||
return "Lager";
|
||||
if (/kasse|kassenbuch/.test(t)) return "Kasse";
|
||||
if (/fahrzeug|abmeld|zulassung|versicherung|unfall|schaden|getriebe|mietfahrzeug|tüv|kilometer/.test(t))
|
||||
return "Fahrzeuge";
|
||||
if (/gefahren|gebracht|abgeholt|feuchtwangen|crailsheim|rothenburg|getränke/.test(t))
|
||||
return "Fahrten";
|
||||
if (/ablage|spül|auftrag|büro|cyber|guardey|kalender|archiv|e-mail|emails/.test(t))
|
||||
return "Organisation";
|
||||
return "Betrieb";
|
||||
}
|
||||
|
||||
function categorizeSchool(text) {
|
||||
const t = text.toLowerCase();
|
||||
if (/bwl|kalkulation|beschaffung|lagerkenn|polypol|bruttoinlands|bestellverfahren|sozialabgaben/.test(t))
|
||||
return "BWL";
|
||||
if (/buchhaltung|umsatzsteuer|rechnung bestandteil/.test(t)) return "Buchhaltung";
|
||||
if (/englisch|introducing|listening|dialogues|briefe im englischen|telefonate/.test(t))
|
||||
return "Englisch";
|
||||
if (/deutsch|das-\/dass|stellenanzeige|rechtschreib/.test(t)) return "Deutsch";
|
||||
if (/excel|word|textverarbeitung|formatvorlage|diagramm|ergonomie/.test(t)) return "IT";
|
||||
if (/geschichte|migration|medien|politik|tarif|arbeitsschutz|demograf/.test(t))
|
||||
return "Gesellschaft";
|
||||
if (/projekt|präsentation|kommunikation|meeting|checkliste|e-mail/.test(t)) return "BFK";
|
||||
return "Berufsschule";
|
||||
}
|
||||
|
||||
function acceptActivity(text) {
|
||||
const t = normalize(text);
|
||||
if (t.length < 8 || t.length > 140) return false;
|
||||
if (JUNK.test(t) || JUNK_ANY.test(t) || BAD_ARTIFICIAL.test(t)) return false;
|
||||
if (!/[a-zäöüß]/i.test(t)) return false;
|
||||
if (/^bis\s+\d{2}\.\d{2}/i.test(t)) return false;
|
||||
// drop pure "Ferien" as activity
|
||||
if (/^ferien$/i.test(t)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function acceptSchool(text) {
|
||||
const t = normalize(text);
|
||||
if (t.length < 4 || t.length > 120) return false;
|
||||
if (JUNK.test(t) || JUNK_ANY.test(t) || BAD_ARTIFICIAL.test(t)) return false;
|
||||
if (/^ferien$/i.test(t)) return false;
|
||||
if (!/[a-zäöüß]/i.test(t)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function acceptTheme(text) {
|
||||
const t = normalize(text).replace(/,$/, "");
|
||||
if (t.length < 3 || t.length > 55) return false;
|
||||
if (JUNK.test(t) || JUNK_ANY.test(t) || /^\d/.test(t) || t === "-") return false;
|
||||
if (/^(planung|vorschriften)$/i.test(t) && t.length < 5) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseSections(raw) {
|
||||
const lines = raw
|
||||
.split(/\n+/)
|
||||
.map((l) => normalize(l.replace(/^\d{5,}/, "")))
|
||||
.filter(Boolean);
|
||||
|
||||
const activities = [];
|
||||
const themes = [];
|
||||
const school = [];
|
||||
let section = "act";
|
||||
|
||||
for (const line of lines) {
|
||||
if (/thema der woche/i.test(line)) {
|
||||
section = "theme";
|
||||
continue;
|
||||
}
|
||||
if (/berufsschule/i.test(line)) {
|
||||
section = "school";
|
||||
continue;
|
||||
}
|
||||
if (/zuordnung|ausbildungsrahmenplan|betriebliche tätigkeiten/i.test(line)) {
|
||||
section = "fw";
|
||||
continue;
|
||||
}
|
||||
if (/ausbildungsnachweis nr|für die zeit vom/i.test(line)) {
|
||||
section = "act";
|
||||
continue;
|
||||
}
|
||||
if (section === "act" && acceptActivity(line) && !isSchoolLine(line)) {
|
||||
activities.push(normalize(line.replace(/\.$/, "")));
|
||||
} else if (section === "theme" && acceptTheme(line)) {
|
||||
themes.push(normalize(line.replace(/,$/, "")));
|
||||
} else if (section === "school" && acceptSchool(line)) {
|
||||
school.push(normalize(line));
|
||||
}
|
||||
}
|
||||
return { activities, themes, school };
|
||||
}
|
||||
|
||||
function bump(map, text, file) {
|
||||
const key = normKey(text);
|
||||
if (!key) return;
|
||||
const prev = map.get(key) || { text, count: 0, sources: new Set() };
|
||||
// prefer shorter/cleaner display text when similar
|
||||
if (text.length < prev.text.length) prev.text = text;
|
||||
prev.count += 1;
|
||||
prev.sources.add(file);
|
||||
map.set(key, prev);
|
||||
}
|
||||
|
||||
/** Nur stilgleiche Varianten aus echten Mustern */
|
||||
const VARIANT_RULES = [
|
||||
{ ifIncludes: "termine vereinbart", add: ["Termine telefonisch vereinbart", "Termine mit Kunden vereinbart"] },
|
||||
{ ifIncludes: "ablage einsortiert", add: ["Ablage sortiert", "Ablage organisiert"] },
|
||||
{ ifIncludes: "ablage verwaltet", add: ["Ablage archiviert"] },
|
||||
{ ifIncludes: "rechnungen geschrieben", add: ["Rechnungen erstellt", "Rechnungen fertiggestellt"] },
|
||||
{ ifIncludes: "rechnungen erstellt", add: ["Rechnungen geschrieben"] },
|
||||
{ ifIncludes: "getränke geholt", add: ["Getränke für die Belegschaft geholt", "Getränke besorgt"] },
|
||||
{ ifIncludes: "kasse gezählt", add: ["Kassenbuch geführt", "Kassenbuch geführt und die Kasse gezählt"] },
|
||||
{ ifIncludes: "kassenbuch geführt", add: ["Kasse gezählt"] },
|
||||
{ ifIncludes: "reifen bestellt", add: ["Reifen für Kunden bestellt"] },
|
||||
{ ifIncludes: "aufträge vorbereitet", add: ["Aufträge für die kommende Woche vorbereitet", "Aufträge bearbeitet"] },
|
||||
{ ifIncludes: "kunden per telefon betreut", add: ["Kundentelefonate durchgeführt zwecks Termine", "Telefonisch Termine vereinbart"] },
|
||||
{ ifIncludes: "spülmaschine", add: ["Spülmaschine ausgeräumt", "Spülmaschine ein- und ausgeräumt"] },
|
||||
{ ifIncludes: "mobilitätsgarant", add: ["Mobilitätsgarantien abgeschlossen", "Bosch-Mobilitätsgarantie abgeschlossen"] },
|
||||
{ ifIncludes: "bewertungen gesammelt", add: ["Bewertungen für autoservice.com gesammelt"] },
|
||||
{ ifIncludes: "lieferung", add: ["Lieferung angenommen und vorsortiert", "Lieferung überprüft, vorsortiert und eingebucht"] },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const files = [...listDocx(YEAR1), ...listDocx(YEAR2)];
|
||||
console.log(`Parsing ${files.length} docx…`);
|
||||
|
||||
const actMap = new Map();
|
||||
const themeMap = new Map();
|
||||
const schoolMap = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.split(/[/\\]/).pop();
|
||||
const raw = await extractTextFromDocx(file);
|
||||
if (!raw) {
|
||||
console.warn("empty:", name);
|
||||
continue;
|
||||
}
|
||||
const { activities, themes, school } = parseSections(raw);
|
||||
for (const a of activities) bump(actMap, a, name);
|
||||
for (const t of themes) bump(themeMap, t, name);
|
||||
for (const s of school) bump(schoolMap, s, name);
|
||||
}
|
||||
|
||||
// Drop very weak one-offs that look like noise (optional keep count>=1 but filter quality)
|
||||
let activities = [...actMap.values()]
|
||||
.filter((a) => acceptActivity(a.text) && !isSchoolLine(a.text))
|
||||
.filter((a) => !BAD_ARTIFICIAL.test(a.text))
|
||||
.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de"));
|
||||
|
||||
let school = [...schoolMap.values()]
|
||||
.filter((s) => acceptSchool(s.text))
|
||||
.filter((s) => isSchoolLine(s.text) || s.count >= 1)
|
||||
.filter((s) => !BAD_ARTIFICIAL.test(s.text))
|
||||
.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de"));
|
||||
|
||||
// Prefer school lines that look like school; demote misclassified
|
||||
school = school.filter((s) => {
|
||||
// if it looks strongly betrieb, skip
|
||||
if (/termine vereinbart|ablage |reifen bestellt|kasse gezählt|getränke/i.test(s.text))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const themes = [...themeMap.values()]
|
||||
.filter((t) => acceptTheme(t.text))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((t) => t.text);
|
||||
|
||||
// Boost multi-year / high-frequency
|
||||
for (const a of activities) {
|
||||
a.weight = a.count + (a.sources.size > 1 ? 1 : 0);
|
||||
if (a.count >= 4) a.weight += 2;
|
||||
if (a.count >= 8) a.weight += 3;
|
||||
}
|
||||
for (const s of school) {
|
||||
s.weight = s.count + (s.sources.size > 1 ? 1 : 0);
|
||||
if (s.count >= 3) s.weight += 2;
|
||||
}
|
||||
|
||||
// Sparse variants from real patterns only
|
||||
const originalKeys = new Set(activities.map((a) => normKey(a.text)));
|
||||
const variants = [];
|
||||
for (const a of activities) {
|
||||
if (a.count < 2) continue;
|
||||
for (const rule of VARIANT_RULES) {
|
||||
if (!normKey(a.text).includes(rule.ifIncludes)) continue;
|
||||
for (const v of rule.add) {
|
||||
const k = normKey(v);
|
||||
if (originalKeys.has(k)) continue;
|
||||
if (variants.some((x) => normKey(x.text) === k)) continue;
|
||||
if (!acceptActivity(v)) continue;
|
||||
variants.push({
|
||||
text: v,
|
||||
count: 0,
|
||||
weight: Math.max(1, a.weight - 2),
|
||||
sources: new Set(["variant"]),
|
||||
variant: true,
|
||||
});
|
||||
originalKeys.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxVariants = Math.floor(activities.length * 0.28);
|
||||
const keptVariants = variants.slice(0, maxVariants);
|
||||
|
||||
const allBetrieb = [...activities, ...keptVariants];
|
||||
const suggestions = [];
|
||||
|
||||
for (const a of allBetrieb) {
|
||||
suggestions.push({
|
||||
text: a.text,
|
||||
type: "betrieb",
|
||||
category: categorizeBetrieb(a.text),
|
||||
weight: Math.max(1, a.weight || 1),
|
||||
original: !a.variant,
|
||||
});
|
||||
}
|
||||
for (const s of school) {
|
||||
suggestions.push({
|
||||
text: s.text,
|
||||
type: "schule",
|
||||
category: categorizeSchool(s.text),
|
||||
weight: Math.max(1, s.weight || 1),
|
||||
original: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Final dedupe
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const s of suggestions.sort((a, b) => b.weight - a.weight)) {
|
||||
const k = normKey(s.text) + "|" + s.type;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
unique.push(s);
|
||||
}
|
||||
|
||||
const withIds = unique.map((s, i) => ({
|
||||
id: i + 1,
|
||||
text: s.text,
|
||||
type: s.type,
|
||||
category: s.category,
|
||||
weight: s.weight,
|
||||
}));
|
||||
|
||||
const betriebN = withIds.filter((s) => s.type === "betrieb").length;
|
||||
const schoolN = withIds.filter((s) => s.type === "schule").length;
|
||||
const originals = unique.filter((s) => s.type === "betrieb" && s.original).length;
|
||||
const originalShare = betriebN ? originals / betriebN : 0;
|
||||
|
||||
writeFileSync(
|
||||
OUT_JSON,
|
||||
JSON.stringify(
|
||||
{
|
||||
activities: activities.map((a) => ({
|
||||
text: a.text,
|
||||
count: a.count,
|
||||
weight: a.weight,
|
||||
})),
|
||||
school: school.map((s) => ({ text: s.text, count: s.count, weight: s.weight })),
|
||||
themes,
|
||||
stats: {
|
||||
files: files.length,
|
||||
betrieb: betriebN,
|
||||
school: schoolN,
|
||||
variants: keptVariants.length,
|
||||
originalShareBetrieb: Number(originalShare.toFixed(3)),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const themeList = [...new Set(themes)].filter(Boolean);
|
||||
|
||||
const ts = `/* AUTO-GENERATED – echte Berichtshefte · ${withIds.length} Vorschläge · ${new Date().toISOString().slice(0, 10)} */
|
||||
export type SuggestionType = "betrieb" | "schule";
|
||||
|
||||
export type Suggestion = {
|
||||
id: number;
|
||||
text: string;
|
||||
type: SuggestionType;
|
||||
category: string;
|
||||
/** Häufigkeit/Qualität aus den Originalheften */
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export const SUGGESTIONS: Suggestion[] = ${JSON.stringify(withIds, null, 2)};
|
||||
|
||||
export const WEEK_THEMES: string[] = ${JSON.stringify(themeList, null, 2)};
|
||||
|
||||
export const CATEGORIES = [...new Set(SUGGESTIONS.map((s) => s.category))].sort();
|
||||
|
||||
export const SUGGESTION_STATS = ${JSON.stringify(
|
||||
{
|
||||
total: withIds.length,
|
||||
betrieb: betriebN,
|
||||
school: schoolN,
|
||||
variants: keptVariants.length,
|
||||
originalShareBetrieb: Number(originalShare.toFixed(3)),
|
||||
sourceFiles: files.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)} as const;
|
||||
`;
|
||||
|
||||
writeFileSync(OUT_TS, ts, "utf8");
|
||||
|
||||
console.log("Wrote", OUT_TS);
|
||||
console.log(
|
||||
`total=${withIds.length} betrieb=${betriebN} schule=${schoolN} variants=${keptVariants.length} originalShare=${(originalShare * 100).toFixed(1)}%`,
|
||||
);
|
||||
console.log("Top 15 betrieb:");
|
||||
withIds
|
||||
.filter((s) => s.type === "betrieb")
|
||||
.slice(0, 15)
|
||||
.forEach((s) => console.log(` w${s.weight} ${s.text}`));
|
||||
console.log("Top 10 schule:");
|
||||
withIds
|
||||
.filter((s) => s.type === "schule")
|
||||
.slice(0, 10)
|
||||
.forEach((s) => console.log(` w${s.weight} ${s.text}`));
|
||||
|
||||
if (originalShare < 0.7) {
|
||||
console.warn("WARN: original share < 70%");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* S03 – Kategorisieren + Gewichten
|
||||
* Liest clean-phrases.json → schreibt scored-phrases.json
|
||||
* (jede Phrase: type, category, weight).
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const cleanPath = join(__dirname, "clean-phrases.json");
|
||||
const dumpPath = join(__dirname, "extracted-all.txt");
|
||||
const outPath = join(__dirname, "scored-phrases.json");
|
||||
|
||||
const raw = JSON.parse(readFileSync(cleanPath, "utf8"));
|
||||
|
||||
/** @param {string} text */
|
||||
function categorizeBetrieb(text) {
|
||||
const t = text.toLowerCase();
|
||||
if (
|
||||
/rechnung|gutschrift|zahlungs|mahnung|avis|skonto|betrag|\bbar\b|ec-|kassenladenabschluss|eingangsrechnung|lieferschein/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Rechnungswesen";
|
||||
if (
|
||||
/termin|kunde|kundin|telefon|bewertung|mobilita|mobilitäts|angebot|autoservice|digitale kundenkarte/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Kunden";
|
||||
if (
|
||||
/reifen|lager|lieferung|bestell|ware|artikel|\bwm\b|retour|teile|batterien|additive|warenrückgabe|lieferscheine/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Lager";
|
||||
if (/kasse|kassenbuch/.test(t)) return "Kasse";
|
||||
if (
|
||||
/fahrzeug|abmeld|zulassung|versicherung|unfall|schaden|getriebe|mietfahrzeug|tanken|ausgeputzt|rausgeputzt/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Fahrzeuge";
|
||||
if (
|
||||
/gefahren|gebracht|abgeholt|feuchtwangen|crailsheim|rothenburg|getränke|getranke|bosch|guardey|karlsruhe/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Fahrten";
|
||||
if (
|
||||
/ablage|spül|auftr[aä]ge?|büro|cyber|kalender|archiv|papier|wartebereich|betriebsurlaub|organisiert|einsortiert/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Organisation";
|
||||
return "Betrieb";
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function categorizeSchool(text) {
|
||||
const t = text.toLowerCase();
|
||||
if (
|
||||
/bwl|kalkulation|beschaffung|lagerkenn|polypol|bruttoinlands|bestell|markt|preis|abc-analyse|abc-kunden|wettbewerbs|absatzpolit|bip\b|wohlstands|werbung|personalauswahl/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "BWL";
|
||||
if (
|
||||
/buchhaltung|buchung|rechnung bestandteil|umsatzsteuer|sozialabgaben|inventar|bilanz|guv|konten/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Buchhaltung";
|
||||
if (
|
||||
/englisch|introducing|listening|dialogues|briefe im englischen|telefonate|mediation|enquiry|hörverstehen/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Englisch";
|
||||
if (
|
||||
/deutsch|das-\/dass|rechtschreib|stellenanzeige|leserbrief|argumentation|protokoll|ausdrucksvermögen|deutschbuch/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Deutsch";
|
||||
if (
|
||||
/excel|word|textverarbeitung|formatvorlage|diagramm|ergonomie|seriendruck|powerpoint|summen-funktionen|min-funktionen/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "IT";
|
||||
if (
|
||||
/geschichte|migration|medien|politik|tarif|arbeitsschutz|demograf|bundestag|mauer|religion|demokratie|volksentscheid|integration|assimilation|homeoffice/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "Gesellschaft";
|
||||
if (
|
||||
/projekt|präsentation|kommunikation|meeting|checkliste|e-mail|büromanagement|\bbfk\b|zwischenprüfung|zeitmanagement|zeugnis/.test(
|
||||
t,
|
||||
)
|
||||
)
|
||||
return "BFK";
|
||||
return "Berufsschule";
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Phrasen, die in beiden Ausbildungsjahren vorkommen → weight +1..+2
|
||||
* Quelle: extracted-all.txt (Jahr-Marker „Erstes/Zweites Ausbildungsjahr“).
|
||||
* @returns {Map<string, Set<number>>}
|
||||
*/
|
||||
function buildYearPresence() {
|
||||
/** @type {Map<string, Set<number>>} */
|
||||
const yearsByKey = new Map();
|
||||
if (!existsSync(dumpPath)) return yearsByKey;
|
||||
|
||||
const dump = readFileSync(dumpPath, "utf8");
|
||||
const blocks = dump.split(/^===== .+ =====$/m).slice(1);
|
||||
|
||||
for (const block of blocks) {
|
||||
let year = 0;
|
||||
if (/Erstes Ausbildungsjahr/i.test(block)) year = 1;
|
||||
else if (/Zweites Ausbildungsjahr/i.test(block)) year = 2;
|
||||
if (!year) continue;
|
||||
|
||||
const lines = block.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const key = line.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
if (key.length < 4) continue;
|
||||
let set = yearsByKey.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
yearsByKey.set(key, set);
|
||||
}
|
||||
set.add(year);
|
||||
}
|
||||
}
|
||||
return yearsByKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} count
|
||||
* @param {Set<number>|undefined} years
|
||||
*/
|
||||
function computeWeight(count, years) {
|
||||
let weight = Math.max(1, count || 1);
|
||||
if (years && years.size >= 2) {
|
||||
// leichte Anhebung: häufige Phrasen +2, sonst +1
|
||||
weight += weight >= 3 ? 2 : 1;
|
||||
}
|
||||
return weight;
|
||||
}
|
||||
|
||||
/** @param {unknown} item */
|
||||
function asEntry(item) {
|
||||
if (typeof item === "string") return { text: item.trim(), count: 1 };
|
||||
if (item && typeof item === "object" && "text" in item) {
|
||||
const text = String(/** @type {{text: unknown}} */ (item).text).trim();
|
||||
const count =
|
||||
"count" in item && typeof /** @type {{count: unknown}} */ (item).count === "number"
|
||||
? /** @type {{count: number}} */ (item).count
|
||||
: 1;
|
||||
return { text, count };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const yearPresence = buildYearPresence();
|
||||
|
||||
/** @type {{ text: string, type: 'betrieb'|'schule', category: string, weight: number, count: number, years: number[] }[]} */
|
||||
const scored = [];
|
||||
const seen = new Set();
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {'betrieb'|'schule'} type
|
||||
* @param {string} category
|
||||
* @param {number} count
|
||||
*/
|
||||
function pushPhrase(text, type, category, count) {
|
||||
const t = text.replace(/\s+/g, " ").trim();
|
||||
if (!t) return;
|
||||
const key = `${type}::${t.toLowerCase()}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const years = yearPresence.get(t.toLowerCase());
|
||||
const yearList = years ? [...years].sort() : [];
|
||||
scored.push({
|
||||
text: t,
|
||||
type,
|
||||
category,
|
||||
weight: computeWeight(count, years),
|
||||
count: Math.max(1, count || 1),
|
||||
years: yearList,
|
||||
});
|
||||
}
|
||||
|
||||
const activities = raw.activities ?? [];
|
||||
for (const a of activities) {
|
||||
const e = asEntry(a);
|
||||
if (!e) continue;
|
||||
pushPhrase(e.text, "betrieb", categorizeBetrieb(e.text), e.count);
|
||||
}
|
||||
|
||||
const schoolSource =
|
||||
Array.isArray(raw.schoolWeighted) && raw.schoolWeighted.length
|
||||
? raw.schoolWeighted
|
||||
: (raw.school ?? []);
|
||||
for (const s of schoolSource) {
|
||||
const e = asEntry(s);
|
||||
if (!e) continue;
|
||||
pushPhrase(e.text, "schule", categorizeSchool(e.text), e.count);
|
||||
}
|
||||
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
(a.type === b.type ? 0 : a.type === "betrieb" ? -1 : 1) ||
|
||||
b.weight - a.weight ||
|
||||
a.text.localeCompare(b.text, "de"),
|
||||
);
|
||||
|
||||
const out = {
|
||||
phrases: scored.map(({ text, type, category, weight }) => ({
|
||||
text,
|
||||
type,
|
||||
category,
|
||||
weight,
|
||||
})),
|
||||
meta: {
|
||||
source: "clean-phrases.json",
|
||||
total: scored.length,
|
||||
betrieb: scored.filter((p) => p.type === "betrieb").length,
|
||||
schule: scored.filter((p) => p.type === "schule").length,
|
||||
multiYearBoosted: scored.filter((p) => p.years.length >= 2).length,
|
||||
yearPresenceFrom: existsSync(dumpPath) ? "extracted-all.txt" : null,
|
||||
generatedAt: new Date().toISOString(),
|
||||
// Debug-Hilfen für Abnahme (nicht Teil des Datenmodells)
|
||||
topBetrieb: scored
|
||||
.filter((p) => p.type === "betrieb")
|
||||
.slice(0, 10)
|
||||
.map((p) => `${p.text}×${p.weight} [${p.category}]`),
|
||||
topSchule: scored
|
||||
.filter((p) => p.type === "schule")
|
||||
.slice(0, 10)
|
||||
.map((p) => `${p.text}×${p.weight} [${p.category}]`),
|
||||
categoryCounts: scored.reduce((acc, p) => {
|
||||
acc[p.category] = (acc[p.category] || 0) + 1;
|
||||
return acc;
|
||||
}, /** @type {Record<string, number>} */ ({})),
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(outPath, JSON.stringify(out, null, 2), "utf8");
|
||||
|
||||
// Abnahme-Checks
|
||||
const missing = out.phrases.filter((p) => !p.type || !p.category || !p.weight);
|
||||
const schoolAsBetrieb = out.phrases.filter(
|
||||
(p) =>
|
||||
p.type === "betrieb" &&
|
||||
/bwl|englisch|polypol|introducing|klassenarbeit|berufsschule|excel klassen|hörverstehen|umsatzsteuer|das-\/dass/i.test(
|
||||
p.text,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Wrote ${out.phrases.length} scored phrases (betrieb ${out.meta.betrieb}, schule ${out.meta.schule}) → ${outPath}`,
|
||||
);
|
||||
console.log(`Multi-year boost: ${out.meta.multiYearBoosted}`);
|
||||
console.log("Top Betrieb:", out.meta.topBetrieb.slice(0, 6).join(" · "));
|
||||
console.log("Top Schule:", out.meta.topSchule.slice(0, 6).join(" · "));
|
||||
if (missing.length) {
|
||||
console.error(`FAIL: ${missing.length} Phrasen ohne type/category/weight`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (schoolAsBetrieb.length) {
|
||||
console.warn(
|
||||
`WARN Stichprobe: ${schoolAsBetrieb.length} mögliche Schule-als-Betrieb:`,
|
||||
schoolAsBetrieb.slice(0, 5).map((p) => p.text),
|
||||
);
|
||||
} else {
|
||||
console.log("OK: Schule-Stichworte nicht als betrieb gelabelt");
|
||||
}
|
||||
console.log("OK: jede Phrase hat type, category, weight");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Smoke-Checks für Teilaufgabe 04 (Kalenderregeln).
|
||||
* Bündelt die echten Module via esbuild und prüft die Abnahmekriterien.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = join(__dirname, "..");
|
||||
const outDir = join(__dirname, ".tmp-verify");
|
||||
const entry = join(outDir, "entry.mjs");
|
||||
const bundle = join(outDir, "bundle.mjs");
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(`FAIL: ${msg}`);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(
|
||||
entry,
|
||||
`
|
||||
export {
|
||||
feiertagActivityLine,
|
||||
firstOpenDay,
|
||||
getDayType,
|
||||
getPublicHoliday,
|
||||
getWeekNumber,
|
||||
isInSchoolHoliday,
|
||||
isWeekend,
|
||||
weekNeedsSchool,
|
||||
weekWorkdays,
|
||||
} from "../../src/lib/calendar.ts";
|
||||
export { createEmptyWeek } from "../../src/lib/storage.ts";
|
||||
export { pickRandomSuggestions } from "../../src/lib/suggest.ts";
|
||||
export { buildActivityLines, buildSchoolTopics } from "../../src/lib/weekExport.ts";
|
||||
`,
|
||||
);
|
||||
|
||||
execSync(
|
||||
`npx esbuild "${entry}" --bundle --platform=node --format=esm --outfile="${bundle}"`,
|
||||
{ cwd: webRoot, stdio: "inherit" },
|
||||
);
|
||||
|
||||
const m = await import(pathToFileURL(bundle).href);
|
||||
|
||||
// 1) Normale Schulwoche nach Osterferien
|
||||
const schoolWeek = "2026-04-13";
|
||||
assert(
|
||||
JSON.stringify(m.weekWorkdays(schoolWeek).map(m.getDayType)) ===
|
||||
JSON.stringify(["betrieb", "schule", "betrieb", "schule", "betrieb"]),
|
||||
"Schulwoche 13.04.2026: Di/Do = Schule",
|
||||
);
|
||||
assert(m.weekNeedsSchool(schoolWeek) === true, "Schulwoche braucht Schule");
|
||||
|
||||
// 2) Ferien → Betrieb auch Di/Do
|
||||
assert(m.isInSchoolHoliday("2026-04-07") === "Osterferien", "Di in Osterferien");
|
||||
assert(m.getDayType("2026-04-07") === "betrieb", "Di Ferien = Betrieb");
|
||||
assert(m.getDayType("2026-04-09") === "betrieb", "Do Ferien = Betrieb");
|
||||
|
||||
// 3) Wochenende überspringen
|
||||
assert(m.isWeekend("2026-04-11") && m.isWeekend("2026-04-12"), "Sa/So");
|
||||
assert(
|
||||
!m.weekWorkdays("2026-04-06").includes("2026-04-11"),
|
||||
"Sa nicht in Workdays",
|
||||
);
|
||||
|
||||
// 4) Abnahme Woche 79
|
||||
const w79 = "2026-04-06";
|
||||
assert(m.getWeekNumber(w79) === 79, "Wochennummer 79");
|
||||
assert(m.firstOpenDay() === "2026-04-06", "Starttag nach Heft 78");
|
||||
assert(m.getDayType(w79) === "feiertag", "Ostermontag Feiertag");
|
||||
assert(m.getPublicHoliday(w79) === "Ostermontag", "Ostermontag Name");
|
||||
assert(
|
||||
m.weekWorkdays(w79).slice(1).every((d) => m.getDayType(d) === "betrieb"),
|
||||
"Rest Woche 79 = Betrieb",
|
||||
);
|
||||
assert(m.weekNeedsSchool(w79) === false, "Woche 79 keine Schule");
|
||||
assert(
|
||||
JSON.stringify(m.createEmptyWeek(w79).schoolTopics) ===
|
||||
JSON.stringify(["Ferien"]),
|
||||
"Berufsschule-Feld = Ferien",
|
||||
);
|
||||
assert(m.feiertagActivityLine(w79) === "Montag Feiertag", "Feiertag-Zeile");
|
||||
|
||||
// 5) Export-Zeilen
|
||||
const week = m.createEmptyWeek(w79);
|
||||
week.activities = ["Reifenlager inventarisiert"];
|
||||
assert(
|
||||
JSON.stringify(m.buildActivityLines(week)) ===
|
||||
JSON.stringify(["Reifenlager inventarisiert", "Montag Feiertag"]),
|
||||
"Export enthält Montag Feiertag",
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(m.buildSchoolTopics(week)) === JSON.stringify(["Ferien"]),
|
||||
"Export Schule = Ferien",
|
||||
);
|
||||
|
||||
// 6) Vorschlags-Pool
|
||||
const ferienPool = m.pickRandomSuggestions(w79, 8, [], []);
|
||||
assert(
|
||||
ferienPool.every((s) => s.type === "betrieb"),
|
||||
"Ferienwoche: nur Betrieb-Vorschläge",
|
||||
);
|
||||
const schulPool = m.pickRandomSuggestions(schoolWeek, 8, [], []);
|
||||
assert(
|
||||
schulPool.some((s) => s.type === "schule") &&
|
||||
schulPool.some((s) => s.type === "betrieb"),
|
||||
"Schulwoche: Betrieb + Schule im Pool",
|
||||
);
|
||||
|
||||
// 7) Wochennummern relativ zu 78
|
||||
assert(m.getWeekNumber("2026-03-30") === 78, "Woche 78");
|
||||
assert(m.getWeekNumber("2026-04-13") === 80, "Woche 80");
|
||||
|
||||
// 8) Pflicht-Feiertage 2026
|
||||
for (const iso of [
|
||||
"2026-01-01",
|
||||
"2026-01-06",
|
||||
"2026-04-03",
|
||||
"2026-04-06",
|
||||
"2026-05-01",
|
||||
"2026-05-14",
|
||||
"2026-05-25",
|
||||
"2026-06-04",
|
||||
"2026-10-03",
|
||||
"2026-11-01",
|
||||
"2026-12-25",
|
||||
"2026-12-26",
|
||||
]) {
|
||||
assert(Boolean(m.getPublicHoliday(iso)), `Feiertag ${iso}`);
|
||||
assert(m.getDayType(iso) === "feiertag", `Typ Feiertag ${iso}`);
|
||||
}
|
||||
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
console.log("OK – alle Kalender-Smoke-Checks bestanden.");
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Qualitätstor für Suggestions (S06).
|
||||
* Failt bei Müll, ungültigem type/weight, Duplikaten oder zu wenig Originalen.
|
||||
*
|
||||
* Usage: npm run check:suggestions
|
||||
*/
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..");
|
||||
const SUGGESTIONS_PATH = join(ROOT, "src", "data", "suggestions.ts");
|
||||
const CLEAN_PATH = join(__dirname, "clean-phrases.json");
|
||||
const MIN_ORIGINAL_SHARE = 0.7;
|
||||
const VALID_TYPES = new Set(["betrieb", "schule"]);
|
||||
|
||||
/** Müll: Header/Signatur/Nummern-Artefakte – nicht echte Sätze mit „zur Unterschrift …“. */
|
||||
const JUNK_RE =
|
||||
/Datum und Unterschrift|\bLfd\.?\s*Nr\b|……|…………|\.{4,}|\d{5,}|Ausbildungsnachweis|Für die Zeit|^Unterschrift\b|^Abteilung\b|Zuordnung zum Lernziel/i;
|
||||
|
||||
/**
|
||||
* @param {string} src
|
||||
* @returns {{ suggestions: any[], themes: string[] }}
|
||||
*/
|
||||
function parseSuggestionsTs(src) {
|
||||
const sugKey = "export const SUGGESTIONS: Suggestion[] = ";
|
||||
const themeKey = "export const WEEK_THEMES: string[] = ";
|
||||
const sugStart = src.indexOf(sugKey);
|
||||
if (sugStart < 0) throw new Error("SUGGESTIONS-Export nicht gefunden");
|
||||
const afterSug = sugStart + sugKey.length;
|
||||
const themeStart = src.indexOf(themeKey, afterSug);
|
||||
if (themeStart < 0) throw new Error("WEEK_THEMES-Export nicht gefunden");
|
||||
const sugJson = src.slice(afterSug, themeStart).trim().replace(/;$/, "");
|
||||
const afterTheme = themeStart + themeKey.length;
|
||||
const nextExport = src.indexOf("\nexport const ", afterTheme);
|
||||
if (nextExport < 0) throw new Error("Ende von WEEK_THEMES nicht gefunden");
|
||||
const themeJson = src.slice(afterTheme, nextExport).trim().replace(/;$/, "");
|
||||
return {
|
||||
suggestions: JSON.parse(sugJson),
|
||||
themes: JSON.parse(themeJson),
|
||||
};
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`FAIL: ${msg}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors = [];
|
||||
const { suggestions, themes } = parseSuggestionsTs(
|
||||
readFileSync(SUGGESTIONS_PATH, "utf8"),
|
||||
);
|
||||
const clean = JSON.parse(readFileSync(CLEAN_PATH, "utf8"));
|
||||
const originalTexts = new Set(
|
||||
(clean.activities ?? []).map((a) =>
|
||||
(typeof a === "string" ? a : a.text).toLowerCase().replace(/\s+/g, " ").trim(),
|
||||
),
|
||||
);
|
||||
const themeText = (t) => (typeof t === "string" ? t : t?.text);
|
||||
const realThemes = new Set(
|
||||
[...(clean.themes ?? []), ...(clean.themesWeighted ?? [])]
|
||||
.map(themeText)
|
||||
.map((t) =>
|
||||
String(t ?? "")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/,\s*$/, "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
if (!Array.isArray(suggestions) || suggestions.length === 0) {
|
||||
errors.push("SUGGESTIONS leer oder ungültig");
|
||||
}
|
||||
|
||||
const seen = new Map();
|
||||
let junkCount = 0;
|
||||
let badType = 0;
|
||||
let badWeight = 0;
|
||||
let dupCount = 0;
|
||||
|
||||
for (const s of suggestions) {
|
||||
const text = String(s?.text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (JUNK_RE.test(text)) {
|
||||
junkCount++;
|
||||
if (junkCount <= 8) fail(`Müll-Regex: id=${s.id} "${text}"`);
|
||||
}
|
||||
if (!VALID_TYPES.has(s?.type)) {
|
||||
badType++;
|
||||
if (badType <= 8) fail(`type fehlt/ungültig: id=${s?.id} type=${s?.type}`);
|
||||
}
|
||||
if (!(Number(s?.weight) >= 1)) {
|
||||
badWeight++;
|
||||
if (badWeight <= 8) fail(`weight < 1: id=${s?.id} weight=${s?.weight}`);
|
||||
}
|
||||
const key = text.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
dupCount++;
|
||||
if (dupCount <= 8) fail(`Duplikat: "${text}" (ids ${seen.get(key)}, ${s.id})`);
|
||||
} else {
|
||||
seen.set(key, s.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (junkCount) errors.push(`${junkCount} Phrase(n) matchen Müll-Regex`);
|
||||
if (badType) errors.push(`${badType} Phrase(n) ohne gültigen type`);
|
||||
if (badWeight) errors.push(`${badWeight} Phrase(n) mit weight < 1`);
|
||||
if (dupCount) errors.push(`${dupCount} case-insensitive Duplikat(e)`);
|
||||
|
||||
const betrieb = suggestions.filter((s) => s.type === "betrieb");
|
||||
const schule = suggestions.filter((s) => s.type === "schule");
|
||||
const exactBetrieb = betrieb.filter((s) =>
|
||||
originalTexts.has(String(s.text).toLowerCase().replace(/\s+/g, " ").trim()),
|
||||
);
|
||||
const originalShare = betrieb.length ? exactBetrieb.length / betrieb.length : 0;
|
||||
if (originalShare < MIN_ORIGINAL_SHARE) {
|
||||
errors.push(
|
||||
`Original-Anteil Betrieb ${(originalShare * 100).toFixed(1)}% < ${MIN_ORIGINAL_SHARE * 100}% (${exactBetrieb.length}/${betrieb.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const fakeThemes = themes.filter((t) => !realThemes.has(String(t).trim().toLowerCase()));
|
||||
if (fakeThemes.length) {
|
||||
errors.push(
|
||||
`${fakeThemes.length} WEEK_THEMES nicht in clean-phrases: ${fakeThemes.slice(0, 5).join(" · ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const top10 = [...betrieb]
|
||||
.sort((a, b) => b.weight - a.weight || a.text.localeCompare(b.text, "de"))
|
||||
.slice(0, 10);
|
||||
|
||||
console.log("=== Suggestions Qualitätstor ===");
|
||||
console.log(`Gesamt: ${suggestions.length} (Betrieb ${betrieb.length}, Schule ${schule.length})`);
|
||||
console.log(
|
||||
`Original-Anteil Betrieb: ${(originalShare * 100).toFixed(1)}% (${exactBetrieb.length}/${betrieb.length})`,
|
||||
);
|
||||
console.log(`WEEK_THEMES: ${themes.length} (echte Themen)`);
|
||||
console.log("Top-10 Betrieb:");
|
||||
for (const s of top10) console.log(` ${s.weight}× ${s.text}`);
|
||||
|
||||
if (errors.length) {
|
||||
console.error("\nQualitätstor FEHLGESCHLAGEN:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nQualitätstor OK");
|
||||
}
|
||||
|
||||
main();
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
:root {
|
||||
--ink: #1a2228;
|
||||
--muted: #5c6b75;
|
||||
--paper: #f3f0e8;
|
||||
--panel: #fffdf8;
|
||||
--line: #c9c2b4;
|
||||
--accent: #0d6e6e;
|
||||
--accent-deep: #094f4f;
|
||||
--schule: #b45309;
|
||||
--feiertag: #7c3aed;
|
||||
--shadow: 0 18px 40px rgba(26, 34, 40, 0.08);
|
||||
--font-display: "Fraunces", "Iowan Old Style", Georgia, serif;
|
||||
--font-body: "Source Sans 3", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(13, 110, 110, 0.14), transparent 55%),
|
||||
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(180, 83, 9, 0.1), transparent 50%),
|
||||
linear-gradient(180deg, #e8e4d8 0%, var(--paper) 40%, #ebe6dc 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(920px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 4rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 1.25rem;
|
||||
animation: rise 0.55s ease-out both;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2.4rem, 6vw, 3.5rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0.55rem 0 0.2rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 20;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
animation: rise 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Eine klare Arbeitsfläche statt Dashboard-Karten */
|
||||
.workspace {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 1.35rem 1.45rem 1.55rem;
|
||||
box-shadow: var(--shadow);
|
||||
animation: rise 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.week-nav {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.week-title {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.week-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.week-title h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 3.5vw, 2rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.done-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(13, 110, 110, 0.14);
|
||||
color: var(--accent-deep);
|
||||
border: 1px solid rgba(13, 110, 110, 0.28);
|
||||
}
|
||||
|
||||
.week-title p {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.week-title .year {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.day-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
margin: 1rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(13, 110, 110, 0.1);
|
||||
color: var(--accent-deep);
|
||||
border-color: rgba(13, 110, 110, 0.22);
|
||||
}
|
||||
|
||||
.pill.type-schule {
|
||||
background: rgba(180, 83, 9, 0.12);
|
||||
color: var(--schule);
|
||||
border-color: rgba(180, 83, 9, 0.25);
|
||||
}
|
||||
|
||||
.pill.type-feiertag {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: var(--feiertag);
|
||||
border-color: rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
|
||||
.theme-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1.15rem 0 1rem;
|
||||
}
|
||||
|
||||
.offer-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.offer-head h2,
|
||||
.selected-block h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.offer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(26, 34, 40, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-deep));
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.offer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.offer {
|
||||
text-align: left;
|
||||
padding: 0.85rem 0.9rem;
|
||||
background: #faf7f0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
line-height: 1.35;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.offer:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.offer.selected {
|
||||
background: rgba(13, 110, 110, 0.12);
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.offer.type-schule.selected {
|
||||
background: rgba(180, 83, 9, 0.12);
|
||||
border-color: var(--schule);
|
||||
box-shadow: inset 0 0 0 1px var(--schule);
|
||||
}
|
||||
|
||||
.offer-cat {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.selected-block {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.selected-list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.selected-list li {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: #faf7f0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.tag.type-schule {
|
||||
color: var(--schule);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.hint.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.actions .primary {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 0.55rem 0.9rem;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-deep);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #f4f1ea;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
width: 2rem;
|
||||
padding: 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.linkish {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent-deep);
|
||||
text-decoration: underline;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.app {
|
||||
width: min(100%, calc(100% - 1.25rem));
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 1.1rem 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.week-nav {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.offer-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions button,
|
||||
.offer-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SUGGESTION_STATS, WEEK_THEMES, type Suggestion } from "./data/suggestions";
|
||||
import {
|
||||
formatDE,
|
||||
getDayInfo,
|
||||
typeLabel,
|
||||
weekWorkdays,
|
||||
} from "./lib/calendar";
|
||||
import { exportWeekDocx } from "./lib/exportDocx";
|
||||
import { exportWeekPdf } from "./lib/exportPdf";
|
||||
import {
|
||||
pickRandomSuggestions,
|
||||
pickWeekSet,
|
||||
suggestionCount,
|
||||
weekNeedsSchool,
|
||||
} from "./lib/suggest";
|
||||
import {
|
||||
createEmptyWeek,
|
||||
ensureWeek,
|
||||
loadState,
|
||||
nextWeekMonday,
|
||||
pickTheme,
|
||||
prevWeekMonday,
|
||||
saveState,
|
||||
STORAGE_KEY,
|
||||
STORAGE_KEY_LEGACY,
|
||||
type AppState,
|
||||
type WeekEntry,
|
||||
} from "./lib/storage";
|
||||
import { missingExportFields } from "./lib/weekExport";
|
||||
import { TRAINEE } from "./data/holidays-bw";
|
||||
import "./App.css";
|
||||
|
||||
const OFFER_COUNT = 4;
|
||||
const TARGET_ACTIVITIES = 8;
|
||||
const MIN_BETRIEB = 4;
|
||||
|
||||
function withWeek(state: AppState, monday: string): AppState {
|
||||
if (state.weeks[monday]) return state;
|
||||
return {
|
||||
...state,
|
||||
weeks: { ...state.weeks, [monday]: createEmptyWeek(monday) },
|
||||
};
|
||||
}
|
||||
|
||||
function selectionFromWeek(w: WeekEntry | undefined): Suggestion[] {
|
||||
if (!w) return [];
|
||||
return [
|
||||
...w.activities.map((text, i) => ({
|
||||
id: -1000 - i,
|
||||
text,
|
||||
type: "betrieb" as const,
|
||||
category: "Gespeichert",
|
||||
weight: 1,
|
||||
})),
|
||||
...w.schoolTopics
|
||||
.filter((t) => t !== "Ferien")
|
||||
.map((text, i) => ({
|
||||
id: -2000 - i,
|
||||
text,
|
||||
type: "schule" as const,
|
||||
category: "Gespeichert",
|
||||
weight: 1,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function draftFields(monday: string, selected: Suggestion[]) {
|
||||
const activities = selected.filter((s) => s.type === "betrieb").map((s) => s.text);
|
||||
let schoolTopics = selected.filter((s) => s.type === "schule").map((s) => s.text);
|
||||
if (!weekNeedsSchool(monday)) schoolTopics = ["Ferien"];
|
||||
return { activities, schoolTopics };
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<AppState>(() => {
|
||||
const initial = loadState();
|
||||
return withWeek(initial, initial.currentWeekMonday);
|
||||
});
|
||||
const [offer, setOffer] = useState<Suggestion[]>([]);
|
||||
const [selected, setSelected] = useState<Suggestion[]>([]);
|
||||
const [flash, setFlash] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const monday = state.currentWeekMonday;
|
||||
const week = state.weeks[monday] ?? createEmptyWeek(monday);
|
||||
const needsSchool = weekNeedsSchool(monday);
|
||||
const betriebCount = selected.filter((s) => s.type === "betrieb").length;
|
||||
const canExport = betriebCount >= MIN_BETRIEB && !exporting;
|
||||
|
||||
useEffect(() => {
|
||||
setState((prev) => withWeek(prev, prev.currentWeekMonday));
|
||||
}, [state.currentWeekMonday]);
|
||||
|
||||
useEffect(() => {
|
||||
saveState(state);
|
||||
}, [state]);
|
||||
|
||||
// Auswahl + Vorschläge laden, wenn die offene Woche wechselt
|
||||
useEffect(() => {
|
||||
const w = state.weeks[monday];
|
||||
const fromSaved = selectionFromWeek(w);
|
||||
setSelected(fromSaved);
|
||||
setOffer(
|
||||
pickRandomSuggestions(
|
||||
monday,
|
||||
OFFER_COUNT,
|
||||
state.usedSuggestionIds,
|
||||
fromSaved.map((s) => s.text),
|
||||
),
|
||||
);
|
||||
// nur bei Wochenwechsel
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [monday]);
|
||||
|
||||
const dayPills = useMemo(
|
||||
() =>
|
||||
weekWorkdays(monday).map((d) => {
|
||||
const info = getDayInfo(d);
|
||||
return { date: d, info };
|
||||
}),
|
||||
[monday],
|
||||
);
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setFlash(msg);
|
||||
window.setTimeout(() => setFlash(""), 2200);
|
||||
};
|
||||
|
||||
const persistDraft = (nextSelected: Suggestion[]) => {
|
||||
setSelected(nextSelected);
|
||||
const { activities, schoolTopics } = draftFields(monday, nextSelected);
|
||||
setState((prev) => {
|
||||
const base = withWeek(prev, monday);
|
||||
return {
|
||||
...base,
|
||||
weeks: {
|
||||
...base.weeks,
|
||||
[monday]: {
|
||||
...base.weeks[monday],
|
||||
activities,
|
||||
schoolTopics,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const refreshOffer = (count = OFFER_COUNT) => {
|
||||
setOffer(
|
||||
pickRandomSuggestions(
|
||||
monday,
|
||||
count,
|
||||
state.usedSuggestionIds,
|
||||
selected.map((s) => s.text),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelect = (s: Suggestion) => {
|
||||
const exists = selected.some((x) => x.text === s.text);
|
||||
const next = exists
|
||||
? selected.filter((x) => x.text !== s.text)
|
||||
: [...selected, s];
|
||||
persistDraft(next);
|
||||
};
|
||||
|
||||
const removeSelected = (text: string) => {
|
||||
persistDraft(selected.filter((s) => s.text !== text));
|
||||
};
|
||||
|
||||
const autoFillWeek = () => {
|
||||
const picks = pickWeekSet(
|
||||
monday,
|
||||
state.usedSuggestionIds,
|
||||
selected.map((s) => s.text),
|
||||
);
|
||||
const texts = new Set(selected.map((s) => s.text));
|
||||
const merged = [...selected];
|
||||
for (const p of picks) {
|
||||
if (!texts.has(p.text)) {
|
||||
merged.push(p);
|
||||
texts.add(p.text);
|
||||
}
|
||||
}
|
||||
persistDraft(merged);
|
||||
setOffer(
|
||||
pickRandomSuggestions(
|
||||
monday,
|
||||
OFFER_COUNT,
|
||||
state.usedSuggestionIds,
|
||||
merged.map((s) => s.text),
|
||||
),
|
||||
);
|
||||
showToast("Woche mit Vorschlägen gefüllt");
|
||||
};
|
||||
|
||||
const buildWeekFromSelection = (): WeekEntry => {
|
||||
const { activities, schoolTopics: schoolFromSel } = draftFields(monday, selected);
|
||||
let schoolTopics = schoolFromSel;
|
||||
if (needsSchool && schoolTopics.length === 0) {
|
||||
const schoolPick = pickRandomSuggestions(
|
||||
monday,
|
||||
4,
|
||||
state.usedSuggestionIds,
|
||||
[],
|
||||
).filter((s) => s.type === "schule");
|
||||
schoolTopics = schoolPick.slice(0, 3).map((s) => s.text);
|
||||
}
|
||||
return {
|
||||
...week,
|
||||
activities,
|
||||
schoolTopics,
|
||||
weekTheme: week.weekTheme || pickTheme(),
|
||||
done: true,
|
||||
};
|
||||
};
|
||||
|
||||
const finishWeek = async (andExport: "docx" | "pdf" | "both") => {
|
||||
if (betriebCount < MIN_BETRIEB) {
|
||||
showToast(`Mindestens ${MIN_BETRIEB} Betriebs-Vorschläge auswählen`);
|
||||
return;
|
||||
}
|
||||
|
||||
setExporting(true);
|
||||
try {
|
||||
const finished = buildWeekFromSelection();
|
||||
if (!finished.weekTheme.trim()) finished.weekTheme = pickTheme();
|
||||
if (!finished.frameworkRefs.length) {
|
||||
finished.frameworkRefs = [...week.frameworkRefs];
|
||||
}
|
||||
if (!needsSchool && finished.schoolTopics.length === 0) {
|
||||
finished.schoolTopics = ["Ferien"];
|
||||
}
|
||||
|
||||
const missing = missingExportFields(finished);
|
||||
if (missing.length) {
|
||||
showToast(`Export unvollständig: ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const usedIds = [...state.usedSuggestionIds];
|
||||
for (const s of selected) {
|
||||
if (s.id > 0 && !usedIds.includes(s.id)) usedIds.push(s.id);
|
||||
}
|
||||
|
||||
const nextMon = nextWeekMonday(monday);
|
||||
setState((prev) => {
|
||||
const base = {
|
||||
...prev,
|
||||
usedSuggestionIds: usedIds,
|
||||
weeks: { ...prev.weeks },
|
||||
};
|
||||
ensureWeek(base, monday);
|
||||
base.weeks[monday] = finished;
|
||||
base.currentWeekMonday = nextMon;
|
||||
ensureWeek(base, nextMon);
|
||||
return base;
|
||||
});
|
||||
|
||||
// Ein Klick: gleicher Inhalt (WeekExportContent) in DOCX und PDF
|
||||
if (andExport === "docx" || andExport === "both") await exportWeekDocx(finished);
|
||||
if (andExport === "pdf" || andExport === "both") exportWeekPdf(finished);
|
||||
|
||||
showToast(`Woche ${finished.number} exportiert – weiter zur nächsten`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast("Export fehlgeschlagen – bitte erneut versuchen");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goWeek = (mon: string) => {
|
||||
setState((prev) => withWeek({ ...prev, currentWeekMonday: mon }, mon));
|
||||
};
|
||||
|
||||
const rerollTheme = () => {
|
||||
setState((prev) => {
|
||||
const base = withWeek(prev, monday);
|
||||
const themes = WEEK_THEMES.filter((t) => t !== base.weeks[monday].weekTheme);
|
||||
const next = themes[Math.floor(Math.random() * themes.length)] || pickTheme();
|
||||
return {
|
||||
...base,
|
||||
weeks: {
|
||||
...base.weeks,
|
||||
[monday]: { ...base.weeks[monday], weekTheme: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const progressPct = Math.min(100, Math.round((betriebCount / TARGET_ACTIVITIES) * 100));
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="hero">
|
||||
<div className="brand">Berichtsheft</div>
|
||||
<p className="sub">
|
||||
{TRAINEE.name} · Büromanagement · Kfz · Abt. {TRAINEE.department}
|
||||
</p>
|
||||
<p className="meta">
|
||||
{suggestionCount()} Vorschläge aus {SUGGESTION_STATS.sourceFiles} Heften ·{" "}
|
||||
{Math.round(SUGGESTION_STATS.originalShareBetrieb * 100)}% Originalton
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{flash && <div className="toast">{flash}</div>}
|
||||
|
||||
<main className="workspace">
|
||||
<div className="week-nav">
|
||||
<button type="button" className="nav-btn" onClick={() => goWeek(prevWeekMonday(monday))}>
|
||||
← Vorherige
|
||||
</button>
|
||||
<div className="week-title">
|
||||
<div className="week-heading">
|
||||
<h1>Woche Nr. {week.number}</h1>
|
||||
{week.done && <span className="done-badge">Fertig</span>}
|
||||
</div>
|
||||
<p>
|
||||
{formatDE(week.from)} – {formatDE(week.to)}
|
||||
</p>
|
||||
<p className="year">{week.yearLabel}</p>
|
||||
</div>
|
||||
<button type="button" className="nav-btn" onClick={() => goWeek(nextWeekMonday(monday))}>
|
||||
Nächste →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="day-pills" aria-label="Wochentage">
|
||||
{dayPills.map(({ date, info }) => (
|
||||
<span key={date} className={`pill type-${info.type}`}>
|
||||
{info.weekdayLabel.slice(0, 2)} · {typeLabel(info.type)}
|
||||
{info.holidayName ? ` (${info.holidayName})` : ""}
|
||||
{info.schoolHolidayName && info.type !== "feiertag" ? " · Ferien" : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="theme-row">
|
||||
<span>
|
||||
Thema der Woche: <strong>{week.weekTheme}</strong>
|
||||
</span>
|
||||
<button type="button" className="linkish" onClick={rerollTheme}>
|
||||
Anderes Thema
|
||||
</button>
|
||||
</div>
|
||||
<p className="hint center">
|
||||
{needsSchool
|
||||
? "Di/Do Berufsschule – Schul-Vorschläge können mit gewählt werden"
|
||||
: "Ferien / keine Schule diese Woche (alles Betrieb)"}
|
||||
</p>
|
||||
|
||||
<hr className="divider" />
|
||||
|
||||
<div className="offer-head">
|
||||
<h2>Vorschläge wählen</h2>
|
||||
<div className="offer-actions">
|
||||
<button type="button" className="secondary" onClick={() => refreshOffer(4)}>
|
||||
4 neue
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => refreshOffer(8)}>
|
||||
8 neue
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={autoFillWeek}>
|
||||
Woche automatisch füllen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-row" aria-live="polite">
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<span className="progress-label">
|
||||
Betrieb {betriebCount}/{TARGET_ACTIVITIES}
|
||||
{betriebCount < MIN_BETRIEB ? ` · mind. ${MIN_BETRIEB} für Export` : " · bereit"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="hint">Antippen zum Auswählen oder Abwählen · kein Tippen nötig</p>
|
||||
|
||||
<div className="offer-grid">
|
||||
{offer.map((s) => {
|
||||
const active = selected.some((x) => x.text === s.text);
|
||||
return (
|
||||
<button
|
||||
key={`${s.id}-${s.text}`}
|
||||
type="button"
|
||||
className={`offer ${active ? "selected" : ""} type-${s.type}`}
|
||||
onClick={() => toggleSelect(s)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="offer-cat">
|
||||
{s.type === "schule" ? "Schule" : s.category}
|
||||
</span>
|
||||
{s.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="selected-block">
|
||||
<h2>Gewählt ({selected.length})</h2>
|
||||
{selected.length === 0 ? (
|
||||
<p className="hint">Noch nichts gewählt – Vorschläge antippen oder automatisch füllen.</p>
|
||||
) : (
|
||||
<ul className="selected-list">
|
||||
{selected.map((s) => (
|
||||
<li key={s.text}>
|
||||
<span className={`tag type-${s.type}`}>
|
||||
{s.type === "schule" ? "Schule" : "Betrieb"}
|
||||
</span>
|
||||
<span className="txt">{s.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon"
|
||||
aria-label="Entfernen"
|
||||
onClick={() => removeSelected(s.text)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => finishWeek("both")}
|
||||
disabled={!canExport}
|
||||
>
|
||||
{exporting
|
||||
? "Erstelle …"
|
||||
: "Berichtsheft erstellen (DOCX + PDF) & nächste Woche"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => finishWeek("docx")}
|
||||
disabled={!canExport}
|
||||
>
|
||||
Nur DOCX
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => finishWeek("pdf")}
|
||||
disabled={!canExport}
|
||||
>
|
||||
Nur PDF
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="footer">
|
||||
<button
|
||||
type="button"
|
||||
className="linkish"
|
||||
onClick={() => {
|
||||
if (!confirm("Fortschritt zurücksetzen?")) return;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(STORAGE_KEY_LEGACY);
|
||||
const fresh = loadState();
|
||||
setState(fresh);
|
||||
setSelected([]);
|
||||
showToast("Zurückgesetzt");
|
||||
}}
|
||||
>
|
||||
Fortschritt zurücksetzen
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,63 @@
|
||||
/** Baden-Württemberg Schulferien und gesetzliche Feiertage
|
||||
* Quellen: Kultusministerium BW / KMK (Angaben ohne Gewähr).
|
||||
*/
|
||||
|
||||
export type DateRange = { from: string; to: string; name: string };
|
||||
|
||||
/** ISO date YYYY-MM-DD inclusive ranges */
|
||||
export const BW_SCHOOL_HOLIDAYS: DateRange[] = [
|
||||
// Schuljahr 2025/26
|
||||
{ from: "2025-12-22", to: "2026-01-05", name: "Weihnachtsferien" },
|
||||
{ from: "2026-03-30", to: "2026-04-11", name: "Osterferien" },
|
||||
{ from: "2026-05-26", to: "2026-06-05", name: "Pfingstferien" },
|
||||
{ from: "2026-07-30", to: "2026-09-12", name: "Sommerferien" },
|
||||
{ from: "2026-10-26", to: "2026-10-30", name: "Herbstferien" },
|
||||
{ from: "2026-10-31", to: "2026-10-31", name: "Reformationsfest" },
|
||||
// Schuljahr 2026/27
|
||||
{ from: "2026-12-23", to: "2027-01-09", name: "Weihnachtsferien" },
|
||||
{ from: "2027-03-25", to: "2027-03-25", name: "Gründonnerstag" },
|
||||
{ from: "2027-03-30", to: "2027-04-03", name: "Osterferien" },
|
||||
{ from: "2027-05-18", to: "2027-05-29", name: "Pfingstferien" },
|
||||
{ from: "2027-07-29", to: "2027-09-11", name: "Sommerferien" },
|
||||
{ from: "2027-11-02", to: "2027-11-06", name: "Herbstferien" },
|
||||
// Schuljahr 2027/28
|
||||
{ from: "2027-12-23", to: "2028-01-08", name: "Weihnachtsferien" },
|
||||
];
|
||||
|
||||
/** Fixed + computed BW public holidays as ISO dates */
|
||||
export const BW_PUBLIC_HOLIDAYS: Record<string, string> = {
|
||||
"2026-01-01": "Neujahr",
|
||||
"2026-01-06": "Heilige Drei Könige",
|
||||
"2026-04-03": "Karfreitag",
|
||||
"2026-04-06": "Ostermontag",
|
||||
"2026-05-01": "Tag der Arbeit",
|
||||
"2026-05-14": "Christi Himmelfahrt",
|
||||
"2026-05-25": "Pfingstmontag",
|
||||
"2026-06-04": "Fronleichnam",
|
||||
"2026-10-03": "Tag der Deutschen Einheit",
|
||||
"2026-11-01": "Allerheiligen",
|
||||
"2026-12-25": "1. Weihnachtsfeiertag",
|
||||
"2026-12-26": "2. Weihnachtsfeiertag",
|
||||
"2027-01-01": "Neujahr",
|
||||
"2027-01-06": "Heilige Drei Könige",
|
||||
"2027-03-26": "Karfreitag",
|
||||
"2027-03-29": "Ostermontag",
|
||||
"2027-05-01": "Tag der Arbeit",
|
||||
"2027-05-06": "Christi Himmelfahrt",
|
||||
"2027-05-17": "Pfingstmontag",
|
||||
"2027-05-27": "Fronleichnam",
|
||||
"2027-10-03": "Tag der Deutschen Einheit",
|
||||
"2027-11-01": "Allerheiligen",
|
||||
"2027-12-25": "1. Weihnachtsfeiertag",
|
||||
"2027-12-26": "2. Weihnachtsfeiertag",
|
||||
};
|
||||
|
||||
export const TRAINEE = {
|
||||
name: "Nico Baumann",
|
||||
department: "43180",
|
||||
startDate: "2024-09-02",
|
||||
};
|
||||
|
||||
/** Last completed day in existing folder (Karfreitag, Woche 78) */
|
||||
export const LAST_COMPLETED_DATE = "2026-04-03";
|
||||
export const START_WEEK_NUMBER = 79;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
BW_PUBLIC_HOLIDAYS,
|
||||
BW_SCHOOL_HOLIDAYS,
|
||||
LAST_COMPLETED_DATE,
|
||||
START_WEEK_NUMBER,
|
||||
} from "../data/holidays-bw";
|
||||
|
||||
export type DayType = "betrieb" | "schule" | "feiertag";
|
||||
|
||||
export type DayInfo = {
|
||||
date: string;
|
||||
weekday: number; // 0=So … 6=Sa
|
||||
weekdayLabel: string;
|
||||
type: DayType;
|
||||
holidayName?: string;
|
||||
schoolHolidayName?: string;
|
||||
weekNumber: number;
|
||||
weekMonday: string;
|
||||
weekFriday: string;
|
||||
yearLabel: string;
|
||||
};
|
||||
|
||||
const WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
|
||||
|
||||
export function parseISO(iso: string): Date {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function toISO(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function formatDE(iso: string): string {
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
export function addDays(iso: string, days: number): string {
|
||||
const d = parseISO(iso);
|
||||
d.setDate(d.getDate() + days);
|
||||
return toISO(d);
|
||||
}
|
||||
|
||||
export function isWeekend(iso: string): boolean {
|
||||
const wd = parseISO(iso).getDay();
|
||||
return wd === 0 || wd === 6;
|
||||
}
|
||||
|
||||
export function isInSchoolHoliday(iso: string): string | undefined {
|
||||
for (const h of BW_SCHOOL_HOLIDAYS) {
|
||||
if (iso >= h.from && iso <= h.to) return h.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getPublicHoliday(iso: string): string | undefined {
|
||||
return BW_PUBLIC_HOLIDAYS[iso];
|
||||
}
|
||||
|
||||
/** Di/Do = Schule, außer in BW-Ferien (= Betrieb). Feiertage haben Vorrang. */
|
||||
export function getDayType(iso: string): DayType {
|
||||
if (getPublicHoliday(iso)) return "feiertag";
|
||||
if (isInSchoolHoliday(iso)) return "betrieb";
|
||||
const wd = parseISO(iso).getDay();
|
||||
if (wd === 2 || wd === 4) return "schule";
|
||||
return "betrieb";
|
||||
}
|
||||
|
||||
export function getMonday(iso: string): string {
|
||||
const d = parseISO(iso);
|
||||
const wd = d.getDay();
|
||||
const diff = wd === 0 ? -6 : 1 - wd;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return toISO(d);
|
||||
}
|
||||
|
||||
export function getFriday(iso: string): string {
|
||||
return addDays(getMonday(iso), 4);
|
||||
}
|
||||
|
||||
/** Week number relative to last completed week 78 ending 2026-04-03 */
|
||||
export function getWeekNumber(iso: string): number {
|
||||
const monday = getMonday(iso);
|
||||
const baseMonday = getMonday(LAST_COMPLETED_DATE); // 2026-03-30
|
||||
const baseWeek = START_WEEK_NUMBER - 1; // 78
|
||||
const days =
|
||||
(parseISO(monday).getTime() - parseISO(baseMonday).getTime()) / (24 * 60 * 60 * 1000);
|
||||
return baseWeek + Math.round(days / 7);
|
||||
}
|
||||
|
||||
export function getYearLabel(iso: string): string {
|
||||
// Ausbildung start 02.09.2024 → 1. Jahr bis 29.08.2025, 2. Jahr ab 01.09.2025, 3. ab 01.09.2026
|
||||
if (iso < "2025-09-01") return "Erstes Ausbildungsjahr";
|
||||
if (iso < "2026-09-01") return "Zweites Ausbildungsjahr";
|
||||
return "Drittes Ausbildungsjahr";
|
||||
}
|
||||
|
||||
export function getDayInfo(iso: string): DayInfo {
|
||||
const d = parseISO(iso);
|
||||
const weekday = d.getDay();
|
||||
const holidayName = getPublicHoliday(iso);
|
||||
const schoolHolidayName = isInSchoolHoliday(iso);
|
||||
return {
|
||||
date: iso,
|
||||
weekday,
|
||||
weekdayLabel: WEEKDAYS[weekday],
|
||||
type: getDayType(iso),
|
||||
holidayName,
|
||||
schoolHolidayName,
|
||||
weekNumber: getWeekNumber(iso),
|
||||
weekMonday: getMonday(iso),
|
||||
weekFriday: getFriday(iso),
|
||||
yearLabel: getYearLabel(iso),
|
||||
};
|
||||
}
|
||||
|
||||
/** Next workday (Mo–Fr), skipping weekends. Feiertage are still "days" that can be saved. */
|
||||
export function nextWorkday(iso: string): string {
|
||||
let cur = addDays(iso, 1);
|
||||
while (isWeekend(cur)) cur = addDays(cur, 1);
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function prevWorkday(iso: string): string {
|
||||
let cur = addDays(iso, -1);
|
||||
while (isWeekend(cur)) cur = addDays(cur, -1);
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function firstOpenDay(): string {
|
||||
return nextWorkday(LAST_COMPLETED_DATE);
|
||||
}
|
||||
|
||||
export function weekWorkdays(monday: string): string[] {
|
||||
return [0, 1, 2, 3, 4].map((i) => addDays(monday, i));
|
||||
}
|
||||
|
||||
/** True if the week has at least one Berufsschule day (Di/Do outside Ferien). */
|
||||
export function weekNeedsSchool(monday: string): boolean {
|
||||
return weekWorkdays(monday).some((d) => getDayType(d) === "schule");
|
||||
}
|
||||
|
||||
/** Activity line like originals, e.g. "Freitag Feiertag". */
|
||||
export function feiertagActivityLine(iso: string): string | undefined {
|
||||
const info = getDayInfo(iso);
|
||||
if (info.type !== "feiertag") return undefined;
|
||||
return `${info.weekdayLabel} Feiertag`;
|
||||
}
|
||||
|
||||
export function typeLabel(type: DayType): string {
|
||||
if (type === "betrieb") return "Betrieb";
|
||||
if (type === "schule") return "Berufsschule";
|
||||
return "Feiertag";
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
buildWeekExportContent,
|
||||
type WeekExportContent,
|
||||
} from "./weekExport";
|
||||
import type { WeekEntry } from "./storage";
|
||||
|
||||
/** Bekannte Platzhalter-Werte aus der Vorlagen-DOCX (Heft 77). */
|
||||
const TPL = {
|
||||
weekNumber: "77",
|
||||
from: "23.03.2026",
|
||||
to: "27.03.2026",
|
||||
signDate: "28.03.2026",
|
||||
yearFirst: "Zweites",
|
||||
name: "Nico Baumann",
|
||||
} as const;
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function wt(text: string): string {
|
||||
const esc = escapeXml(text);
|
||||
if (text !== text.trim() || text.includes(" ") || text.endsWith(" ")) {
|
||||
return `<w:t xml:space="preserve">${esc}</w:t>`;
|
||||
}
|
||||
return `<w:t>${esc}</w:t>`;
|
||||
}
|
||||
|
||||
function splitParagraphs(inner: string): string[] {
|
||||
return inner.match(/<w:p[\s>][\s\S]*?<\/w:p>/g) ?? [];
|
||||
}
|
||||
|
||||
function hasText(para: string): boolean {
|
||||
return /<w:t[\s>]/.test(para);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt den sichtbaren Text eines Absatzes, behält pPr und die Run-Eigenschaften
|
||||
* des ersten Text-Runs exakt bei (Schrift/Listenstil der Vorlage).
|
||||
*/
|
||||
function setParagraphText(para: string, text: string): string {
|
||||
const runs = [...para.matchAll(/<w:r\b[\s\S]*?<\/w:r>/g)].map((m) => m[0]);
|
||||
const textRunIdx = runs.findIndex((r) => /<w:t[\s>]/.test(r));
|
||||
if (textRunIdx < 0) {
|
||||
return para.replace(/<\/w:p>/, `<w:r>${wt(text)}</w:r></w:p>`);
|
||||
}
|
||||
|
||||
const textRun = runs[textRunIdx];
|
||||
const rPr = textRun.match(/<w:rPr>[\s\S]*?<\/w:rPr>/)?.[0] ?? "";
|
||||
const rsid = textRun.match(/<w:r([^>]*)>/)?.[1] ?? "";
|
||||
const newRun = `<w:r${rsid}>${rPr}${wt(text)}</w:r>`;
|
||||
|
||||
let out = para;
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
if (i === textRunIdx) continue;
|
||||
if (
|
||||
/<w:t[\s>]/.test(runs[i]) &&
|
||||
!/<w:tab\/>|<w:br\b|<w:drawing\b/.test(runs[i])
|
||||
) {
|
||||
out = out.replace(runs[i], "");
|
||||
}
|
||||
}
|
||||
out = out.replace(textRun, newRun);
|
||||
return out;
|
||||
}
|
||||
|
||||
let paraSeq = 0x50000000;
|
||||
function nextParaId(): string {
|
||||
paraSeq += 1;
|
||||
return paraSeq.toString(16).toUpperCase();
|
||||
}
|
||||
|
||||
function withUniqueParaId(para: string): string {
|
||||
if (/w14:paraId="[^"]+"/.test(para)) {
|
||||
return para.replace(/w14:paraId="[^"]+"/, `w14:paraId="${nextParaId()}"`);
|
||||
}
|
||||
return para;
|
||||
}
|
||||
|
||||
function fillMultilineBox(inner: string, lines: string[]): string {
|
||||
const paras = splitParagraphs(inner);
|
||||
const content = paras.filter(hasText);
|
||||
const trailing = paras.filter((p) => !hasText(p));
|
||||
const shells = content.length > 0 ? content : paras;
|
||||
if (shells.length === 0) {
|
||||
return lines
|
||||
.map(
|
||||
(line) =>
|
||||
`<w:p><w:pPr><w:ind w:left="0"/></w:pPr><w:r>${wt(line)}</w:r></w:p>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const filled = lines.map((line, i) => {
|
||||
const shell = shells[Math.min(i, shells.length - 1)];
|
||||
const base = i < shells.length ? shell : withUniqueParaId(shell);
|
||||
return setParagraphText(base, line);
|
||||
});
|
||||
return filled.join("") + trailing.join("");
|
||||
}
|
||||
|
||||
function fillSingleBox(inner: string, text: string): string {
|
||||
const paras = splitParagraphs(inner);
|
||||
if (paras.length === 0) {
|
||||
return `<w:p><w:pPr><w:ind w:left="0"/></w:pPr><w:r>${wt(text)}</w:r></w:p>`;
|
||||
}
|
||||
const idx = paras.findIndex(hasText);
|
||||
const target = idx >= 0 ? idx : 0;
|
||||
return paras
|
||||
.map((p, i) => (i === target ? setParagraphText(p, text) : p))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function fillHeaderBox(inner: string, c: WeekExportContent): string {
|
||||
let out = inner;
|
||||
out = out.replace(
|
||||
`<w:t>${TPL.weekNumber}</w:t>`,
|
||||
`<w:t>${escapeXml(String(c.number))}</w:t>`,
|
||||
);
|
||||
out = out.replace(
|
||||
`<w:t xml:space="preserve">${TPL.from} </w:t>`,
|
||||
`<w:t xml:space="preserve">${escapeXml(c.fromLabel)} </w:t>`,
|
||||
);
|
||||
out = out.replace(
|
||||
`<w:t>${TPL.to}</w:t>`,
|
||||
`<w:t>${escapeXml(c.toLabel)}</w:t>`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function yearFirstWord(yearLabel: string): string {
|
||||
return yearLabel.split(/\s+/)[0] || TPL.yearFirst;
|
||||
}
|
||||
|
||||
function fillBodyFields(xml: string, c: WeekExportContent): string {
|
||||
let out = xml;
|
||||
|
||||
out = out.replace(
|
||||
/(Abteilung <\/w:t><\/w:r><w:r><w:rPr><w:sz w:val="18"\/><\/w:rPr><w:tab\/><w:t xml:space="preserve">) {6}(<\/w:t>)/,
|
||||
`$1${escapeXml(c.department)}$2`,
|
||||
);
|
||||
|
||||
out = out.replace(
|
||||
`<w:t>${TPL.yearFirst}</w:t>`,
|
||||
`<w:t>${escapeXml(yearFirstWord(c.yearLabel))}</w:t>`,
|
||||
);
|
||||
|
||||
out = out.replace(
|
||||
`<w:t>${TPL.name}</w:t>`,
|
||||
`<w:t>${escapeXml(c.name)}</w:t>`,
|
||||
);
|
||||
|
||||
out = out.replace(
|
||||
`<w:t>${TPL.signDate}</w:t>`,
|
||||
`<w:t>${escapeXml(c.signDate)}</w:t>`,
|
||||
);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Befüllt document.xml der echten IHK-Vorlage – Struktur bleibt erhalten. */
|
||||
export function fillDocumentXml(xml: string, week: WeekEntry): string {
|
||||
const c = buildWeekExportContent(week);
|
||||
const lernziel = "-";
|
||||
|
||||
const fillers: Array<(inner: string) => string> = [
|
||||
(inner) => fillHeaderBox(inner, c),
|
||||
(inner) => fillMultilineBox(inner, c.activities),
|
||||
(inner) => fillMultilineBox(inner, c.frameworkLines),
|
||||
(inner) => fillSingleBox(inner, c.weekTheme),
|
||||
(inner) => fillSingleBox(inner, lernziel),
|
||||
(inner) => fillMultilineBox(inner, c.schoolTopics),
|
||||
];
|
||||
|
||||
let box = 0;
|
||||
let out = xml.replace(
|
||||
/<w:txbxContent>([\s\S]*?)<\/w:txbxContent>/g,
|
||||
(_, inner) => {
|
||||
const fill = fillers[Math.floor(box / 2)] ?? ((x: string) => x);
|
||||
box += 1;
|
||||
return `<w:txbxContent>${fill(inner)}</w:txbxContent>`;
|
||||
},
|
||||
);
|
||||
|
||||
out = fillBodyFields(out, c);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import PizZip from "pizzip";
|
||||
import { saveAs } from "file-saver";
|
||||
import { fillDocumentXml } from "./docxFill";
|
||||
import { weekFilename } from "./weekExport";
|
||||
import type { WeekEntry } from "./storage";
|
||||
|
||||
/** Echte IHK-Vorlage (Kopie aus 2. Ausbildungsjahr, Heft 77). */
|
||||
const TEMPLATE_URL = `${import.meta.env?.BASE_URL ?? "/"}templates/ausbildungsnachweis.docx`;
|
||||
|
||||
export { weekFilename, fillDocumentXml };
|
||||
|
||||
export async function exportWeekDocx(week: WeekEntry): Promise<void> {
|
||||
const res = await fetch(TEMPLATE_URL);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Word-Vorlage nicht gefunden (${TEMPLATE_URL}). Erwartet: public/templates/ausbildungsnachweis.docx`,
|
||||
);
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
const zip = new PizZip(buf);
|
||||
const docFile = zip.file("word/document.xml");
|
||||
if (!docFile) {
|
||||
throw new Error("Ungültige Vorlage: word/document.xml fehlt");
|
||||
}
|
||||
|
||||
const filled = fillDocumentXml(docFile.asText(), week);
|
||||
zip.file("word/document.xml", filled);
|
||||
|
||||
const blob = zip.generate({
|
||||
type: "blob",
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
compression: "DEFLATE",
|
||||
});
|
||||
saveAs(blob, weekFilename(week, "docx"));
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { jsPDF } from "jspdf";
|
||||
import {
|
||||
buildWeekExportContent,
|
||||
weekFilename,
|
||||
type WeekExportContent,
|
||||
} from "./weekExport";
|
||||
import type { WeekEntry } from "./storage";
|
||||
|
||||
const PAGE_W = 210;
|
||||
const PAGE_H = 297;
|
||||
const MARGIN = 12;
|
||||
const INNER_W = PAGE_W - MARGIN * 2;
|
||||
|
||||
type PdfDoc = jsPDF;
|
||||
|
||||
function drawRect(
|
||||
doc: PdfDoc,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
opts?: { fill?: string },
|
||||
) {
|
||||
if (opts?.fill) {
|
||||
doc.setFillColor(opts.fill);
|
||||
doc.rect(x, y, w, h, "FD");
|
||||
} else {
|
||||
doc.rect(x, y, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
function wrapLines(
|
||||
doc: PdfDoc,
|
||||
text: string,
|
||||
maxW: number,
|
||||
fontSize: number,
|
||||
): string[] {
|
||||
doc.setFontSize(fontSize);
|
||||
return doc.splitTextToSize(text, maxW) as string[];
|
||||
}
|
||||
|
||||
function textBlockHeight(
|
||||
lines: string[],
|
||||
fontSize: number,
|
||||
lineGap = 1.1,
|
||||
): number {
|
||||
return Math.max(lines.length, 1) * fontSize * 0.352778 * lineGap;
|
||||
}
|
||||
|
||||
function drawWrapped(
|
||||
doc: PdfDoc,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxW: number,
|
||||
fontSize: number,
|
||||
opts?: { bold?: boolean; lineGap?: number },
|
||||
): number {
|
||||
const lineGap = opts?.lineGap ?? 1.15;
|
||||
doc.setFont("helvetica", opts?.bold ? "bold" : "normal");
|
||||
doc.setFontSize(fontSize);
|
||||
const lines = wrapLines(doc, text, maxW, fontSize);
|
||||
const lineH = fontSize * 0.352778 * lineGap;
|
||||
let cy = y;
|
||||
for (const l of lines) {
|
||||
doc.text(l, x, cy);
|
||||
cy += lineH;
|
||||
}
|
||||
return cy - y;
|
||||
}
|
||||
|
||||
function drawBulletList(
|
||||
doc: PdfDoc,
|
||||
items: string[],
|
||||
x: number,
|
||||
y: number,
|
||||
maxW: number,
|
||||
fontSize: number,
|
||||
): number {
|
||||
let cy = y;
|
||||
const bulletIndent = 3.5;
|
||||
for (const item of items) {
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(fontSize);
|
||||
doc.text("•", x, cy);
|
||||
const h = drawWrapped(doc, item, x + bulletIndent, cy, maxW - bulletIndent, fontSize, {
|
||||
lineGap: 1.2,
|
||||
});
|
||||
cy += Math.max(h, fontSize * 0.42) + 1.2;
|
||||
}
|
||||
return cy - y;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF in IHK-/Vorlagen-Struktur (gleiche Inhalte wie DOCX aus WeekEntry):
|
||||
* Kopf → Tätigkeiten + Lfd.Nr. → Thema → Berufsschule → Unterschriften
|
||||
*/
|
||||
function renderWeekPdf(doc: PdfDoc, c: WeekExportContent): void {
|
||||
doc.setDrawColor(0);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.setTextColor(0);
|
||||
|
||||
let y = MARGIN;
|
||||
|
||||
// --- Kopfzeile: Abteilung | Nr. + Zeitraum | Ausbildungsjahr ---
|
||||
const headH = 16;
|
||||
const colA = INNER_W * 0.22;
|
||||
const colB = INNER_W * 0.48;
|
||||
const colC = INNER_W * 0.3;
|
||||
drawRect(doc, MARGIN, y, colA, headH);
|
||||
drawRect(doc, MARGIN + colA, y, colB, headH);
|
||||
drawRect(doc, MARGIN + colA + colB, y, colC, headH);
|
||||
|
||||
drawWrapped(doc, "Abteilung", MARGIN + 2, y + 4, colA - 4, 8, { bold: true });
|
||||
drawWrapped(doc, c.department, MARGIN + 2, y + 9, colA - 4, 11, { bold: true });
|
||||
|
||||
drawWrapped(
|
||||
doc,
|
||||
`Ausbildungsnachweis Nr. ${c.number}`,
|
||||
MARGIN + colA + 2,
|
||||
y + 4,
|
||||
colB - 4,
|
||||
9,
|
||||
{ bold: true },
|
||||
);
|
||||
drawWrapped(doc, c.periodLine, MARGIN + colA + 2, y + 9.5, colB - 4, 8);
|
||||
|
||||
drawWrapped(
|
||||
doc,
|
||||
c.yearLabel,
|
||||
MARGIN + colA + colB + 2,
|
||||
y + 6.5,
|
||||
colC - 4,
|
||||
9,
|
||||
{ bold: true },
|
||||
);
|
||||
y += headH;
|
||||
|
||||
// --- Name | Datum ---
|
||||
const nameH = 10;
|
||||
const nameW = INNER_W * 0.62;
|
||||
const dateW = INNER_W - nameW;
|
||||
drawRect(doc, MARGIN, y, nameW, nameH);
|
||||
drawRect(doc, MARGIN + nameW, y, dateW, nameH);
|
||||
drawWrapped(doc, `Name ${c.name}`, MARGIN + 2, y + 6, nameW - 4, 9);
|
||||
drawWrapped(doc, `Datum ${c.signDate}`, MARGIN + nameW + 2, y + 6, dateW - 4, 9);
|
||||
y += nameH;
|
||||
|
||||
// --- Hauptbereich: Tätigkeiten (links) | Betriebliche / Lfd. Nr. (rechts) ---
|
||||
const leftW = INNER_W * 0.58;
|
||||
const rightW = INNER_W - leftW;
|
||||
const pad = 2.5;
|
||||
const actTitle = "Tägliche Tätigkeiten";
|
||||
const rightTitle = "Betriebliche Tätigkeiten";
|
||||
const rightSub =
|
||||
"Zuordnung der Tätigkeit zu den Unterpunkten des Ausbildungsrahmenplanes";
|
||||
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(9);
|
||||
const actBodyH = (() => {
|
||||
let h = 6; // title
|
||||
for (const a of c.activities) {
|
||||
const lines = wrapLines(doc, a, leftW - pad * 2 - 3.5, 8.5);
|
||||
h += textBlockHeight(lines, 8.5, 1.2) + 1.2;
|
||||
}
|
||||
return h + 4;
|
||||
})();
|
||||
const fwBodyH = (() => {
|
||||
let h = 6 + 8; // titles
|
||||
for (const line of c.frameworkLines) {
|
||||
const lines = wrapLines(doc, line, rightW - pad * 2 - 3.5, 8.5);
|
||||
h += textBlockHeight(lines, 8.5, 1.2) + 1.2;
|
||||
}
|
||||
return h + 4;
|
||||
})();
|
||||
const mainH = Math.max(actBodyH, fwBodyH, 55);
|
||||
|
||||
drawRect(doc, MARGIN, y, leftW, mainH);
|
||||
drawRect(doc, MARGIN + leftW, y, rightW, mainH);
|
||||
|
||||
let ly = y + pad + 3;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(9);
|
||||
doc.text(actTitle, MARGIN + pad, ly);
|
||||
ly += 5;
|
||||
drawBulletList(doc, c.activities, MARGIN + pad, ly, leftW - pad * 2, 8.5);
|
||||
|
||||
let ry = y + pad + 3;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(9);
|
||||
doc.text(rightTitle, MARGIN + leftW + pad, ry);
|
||||
ry += 4.5;
|
||||
ry += drawWrapped(
|
||||
doc,
|
||||
rightSub,
|
||||
MARGIN + leftW + pad,
|
||||
ry,
|
||||
rightW - pad * 2,
|
||||
7.5,
|
||||
{ bold: true, lineGap: 1.15 },
|
||||
);
|
||||
ry += 2;
|
||||
drawBulletList(
|
||||
doc,
|
||||
c.frameworkLines,
|
||||
MARGIN + leftW + pad,
|
||||
ry,
|
||||
rightW - pad * 2,
|
||||
8.5,
|
||||
);
|
||||
y += mainH;
|
||||
|
||||
// --- Thema der Woche | Zuordnung zum Lernziel ---
|
||||
const themeLeft = INNER_W * 0.62;
|
||||
const themeRight = INNER_W - themeLeft;
|
||||
const themeLabel =
|
||||
"Thema der Woche (Unterweisung, Lehrgespräche, betrieblicher Unterricht)";
|
||||
const themeLines = wrapLines(doc, c.weekTheme, themeLeft - pad * 2, 9);
|
||||
const themeH = Math.max(
|
||||
8 + textBlockHeight(wrapLines(doc, themeLabel, themeLeft - pad * 2, 7.5), 7.5) +
|
||||
textBlockHeight(themeLines, 9) +
|
||||
6,
|
||||
22,
|
||||
);
|
||||
drawRect(doc, MARGIN, y, themeLeft, themeH);
|
||||
drawRect(doc, MARGIN + themeLeft, y, themeRight, themeH);
|
||||
|
||||
let ty = y + pad + 3;
|
||||
ty += drawWrapped(doc, themeLabel, MARGIN + pad, ty, themeLeft - pad * 2, 7.5, {
|
||||
bold: true,
|
||||
});
|
||||
ty += 2;
|
||||
drawWrapped(doc, c.weekTheme, MARGIN + pad, ty, themeLeft - pad * 2, 9);
|
||||
|
||||
let t2 = y + pad + 3;
|
||||
t2 += drawWrapped(
|
||||
doc,
|
||||
"Zuordnung zum Lernziel",
|
||||
MARGIN + themeLeft + pad,
|
||||
t2,
|
||||
themeRight - pad * 2,
|
||||
8,
|
||||
{ bold: true },
|
||||
);
|
||||
drawWrapped(doc, "–", MARGIN + themeLeft + pad, t2 + 2, themeRight - pad * 2, 9);
|
||||
y += themeH;
|
||||
|
||||
// --- Berufsschule ---
|
||||
const schoolTitle = "Berufsschule (Themen und Schwerpunkte des Unterrichts)";
|
||||
let schoolH = 8;
|
||||
for (const t of c.schoolTopics) {
|
||||
const lines = wrapLines(doc, t, INNER_W - pad * 2 - 3.5, 8.5);
|
||||
schoolH += textBlockHeight(lines, 8.5, 1.2) + 1.2;
|
||||
}
|
||||
schoolH = Math.max(schoolH + 4, 20);
|
||||
drawRect(doc, MARGIN, y, INNER_W, schoolH);
|
||||
let sy = y + pad + 3;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(8.5);
|
||||
doc.text(schoolTitle, MARGIN + pad, sy);
|
||||
sy += 5;
|
||||
drawBulletList(doc, c.schoolTopics, MARGIN + pad, sy, INNER_W - pad * 2, 8.5);
|
||||
y += schoolH;
|
||||
|
||||
// --- Unterschriften ---
|
||||
const sigH = 28;
|
||||
const sigW = INNER_W / 3;
|
||||
const labels = [
|
||||
"Datum und Unterschrift des/der Auszubildenden",
|
||||
"Datum und Unterschrift des gesetzlichen Vertreters",
|
||||
"Datum und Unterschrift des Ausbilders / der Ausbilderin",
|
||||
];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = MARGIN + sigW * i;
|
||||
drawRect(doc, x, y, sigW, sigH);
|
||||
// dotted signature line
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(9);
|
||||
doc.text("………………………………", x + 3, y + 10);
|
||||
if (i === 0) {
|
||||
doc.setFontSize(8);
|
||||
doc.text(c.signDate, x + 3, y + 15);
|
||||
}
|
||||
drawWrapped(doc, labels[i], x + 2, y + 20, sigW - 4, 6.5, { bold: false });
|
||||
}
|
||||
|
||||
// Footer hint (small, outside form)
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(100);
|
||||
doc.text(
|
||||
`${c.filenameBase}.pdf`,
|
||||
MARGIN,
|
||||
PAGE_H - 6,
|
||||
);
|
||||
doc.setTextColor(0);
|
||||
}
|
||||
|
||||
export function exportWeekPdf(week: WeekEntry): void {
|
||||
const content = buildWeekExportContent(week);
|
||||
const doc = new jsPDF({ unit: "mm", format: "a4" });
|
||||
renderWeekPdf(doc, content);
|
||||
doc.save(weekFilename(week, "pdf"));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
firstOpenDay,
|
||||
getFriday,
|
||||
getMonday,
|
||||
getWeekNumber,
|
||||
getYearLabel,
|
||||
weekNeedsSchool,
|
||||
} from "./calendar";
|
||||
import { START_WEEK_NUMBER, TRAINEE } from "../data/holidays-bw";
|
||||
import { WEEK_THEMES } from "../data/suggestions";
|
||||
|
||||
export type WeekEntry = {
|
||||
number: number;
|
||||
yearLabel: string;
|
||||
from: string;
|
||||
to: string;
|
||||
/** Flat weekly activity bullets (like original hefte) */
|
||||
activities: string[];
|
||||
frameworkRefs: string[];
|
||||
weekTheme: string;
|
||||
schoolTopics: string[];
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
/** Monday of the current open week */
|
||||
currentWeekMonday: string;
|
||||
weeks: Record<string, WeekEntry>;
|
||||
usedSuggestionIds: number[];
|
||||
};
|
||||
|
||||
/** Stable localStorage key – bump suffix only when AppState shape breaks compatibility. */
|
||||
export const STORAGE_KEY = "berichtsheft-generator-v2";
|
||||
/** Legacy key cleared on reset so old drafts do not linger. */
|
||||
export const STORAGE_KEY_LEGACY = "berichtsheft-generator-v1";
|
||||
|
||||
const DEFAULT_FRAMEWORK = ["1.1", "1.2", "1.4", "2.1", "2.2", "2.3", "2.5", "4.4"];
|
||||
|
||||
export function pickTheme(exclude?: string): string {
|
||||
const pool = WEEK_THEMES.filter((t) => t !== exclude);
|
||||
const src = pool.length ? pool : WEEK_THEMES;
|
||||
return src[Math.floor(Math.random() * src.length)] || "Büroabläufe";
|
||||
}
|
||||
|
||||
export function createEmptyWeek(monday: string): WeekEntry {
|
||||
return {
|
||||
number: getWeekNumber(monday) || START_WEEK_NUMBER,
|
||||
yearLabel: getYearLabel(monday),
|
||||
from: monday,
|
||||
to: getFriday(monday),
|
||||
activities: [],
|
||||
frameworkRefs: [...DEFAULT_FRAMEWORK],
|
||||
weekTheme: pickTheme(),
|
||||
schoolTopics: weekNeedsSchool(monday) ? [] : ["Ferien"],
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultState(): AppState {
|
||||
const monday = getMonday(firstOpenDay());
|
||||
return {
|
||||
currentWeekMonday: monday,
|
||||
weeks: { [monday]: createEmptyWeek(monday) },
|
||||
usedSuggestionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadState(): AppState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultState();
|
||||
const parsed = JSON.parse(raw) as AppState;
|
||||
if (!parsed.currentWeekMonday) return defaultState();
|
||||
return {
|
||||
...defaultState(),
|
||||
...parsed,
|
||||
weeks: parsed.weeks ?? {},
|
||||
usedSuggestionIds: parsed.usedSuggestionIds ?? [],
|
||||
};
|
||||
} catch {
|
||||
return defaultState();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: AppState): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function ensureWeek(state: AppState, monday: string): WeekEntry {
|
||||
if (!state.weeks[monday]) {
|
||||
state.weeks[monday] = createEmptyWeek(monday);
|
||||
}
|
||||
return state.weeks[monday];
|
||||
}
|
||||
|
||||
export function getTrainee() {
|
||||
return TRAINEE;
|
||||
}
|
||||
|
||||
export function nextWeekMonday(monday: string): string {
|
||||
const d = new Date(monday + "T12:00:00");
|
||||
d.setDate(d.getDate() + 7);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function prevWeekMonday(monday: string): string {
|
||||
const d = new Date(monday + "T12:00:00");
|
||||
d.setDate(d.getDate() - 7);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { SUGGESTIONS, type Suggestion } from "../data/suggestions";
|
||||
import { weekNeedsSchool } from "./calendar";
|
||||
|
||||
export { weekNeedsSchool };
|
||||
|
||||
function weightOf(s: Suggestion): number {
|
||||
// Häufige echte Heft-Phrasen stark bevorzugen
|
||||
const w = Math.max(1, s.weight ?? 1);
|
||||
return w * w;
|
||||
}
|
||||
|
||||
/** Weighted sample without replacement. */
|
||||
function weightedPick(pool: Suggestion[], count: number): Suggestion[] {
|
||||
const available = [...pool];
|
||||
const result: Suggestion[] = [];
|
||||
while (result.length < count && available.length > 0) {
|
||||
const total = available.reduce((sum, s) => sum + weightOf(s), 0);
|
||||
let r = Math.random() * total;
|
||||
let idx = available.length - 1;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
r -= weightOf(available[i]);
|
||||
if (r <= 0) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.push(available[idx]);
|
||||
available.splice(idx, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted pick with category diversity:
|
||||
* avoid stacking too many from the same category.
|
||||
*/
|
||||
function diverseWeightedPick(
|
||||
pool: Suggestion[],
|
||||
count: number,
|
||||
maxPerCategory = 3,
|
||||
): Suggestion[] {
|
||||
const available = [...pool];
|
||||
const result: Suggestion[] = [];
|
||||
const catCount = new Map<string, number>();
|
||||
|
||||
while (result.length < count && available.length > 0) {
|
||||
const eligible = available.filter((s) => (catCount.get(s.category) ?? 0) < maxPerCategory);
|
||||
const use = eligible.length ? eligible : available;
|
||||
const picked = weightedPick(use, 1)[0];
|
||||
if (!picked) break;
|
||||
result.push(picked);
|
||||
catCount.set(picked.category, (catCount.get(picked.category) ?? 0) + 1);
|
||||
const idx = available.findIndex((s) => s.id === picked.id);
|
||||
if (idx >= 0) available.splice(idx, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unusedPool(
|
||||
type: Suggestion["type"],
|
||||
usedIds: number[],
|
||||
alreadySelected: string[],
|
||||
): Suggestion[] {
|
||||
const selectedSet = new Set(alreadySelected.map((t) => t.toLowerCase()));
|
||||
const primary = SUGGESTIONS.filter(
|
||||
(s) =>
|
||||
s.type === type &&
|
||||
!usedIds.includes(s.id) &&
|
||||
!selectedSet.has(s.text.toLowerCase()),
|
||||
);
|
||||
if (primary.length >= 4) return primary;
|
||||
return SUGGESTIONS.filter(
|
||||
(s) => s.type === type && !selectedSet.has(s.text.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pick n random unused suggestions for the week (betrieb + optional school). */
|
||||
export function pickRandomSuggestions(
|
||||
monday: string,
|
||||
count: number,
|
||||
usedIds: number[],
|
||||
alreadySelected: string[],
|
||||
): Suggestion[] {
|
||||
const needSchool = weekNeedsSchool(monday);
|
||||
const betriebPool = unusedPool("betrieb", usedIds, alreadySelected);
|
||||
const schoolPool = unusedPool("schule", usedIds, alreadySelected);
|
||||
|
||||
if (!needSchool) {
|
||||
return diverseWeightedPick(betriebPool, count, 2);
|
||||
}
|
||||
|
||||
const schoolCount = Math.min(
|
||||
schoolPool.length,
|
||||
Math.max(1, Math.round(count * 0.25)),
|
||||
);
|
||||
const betriebCount = Math.max(0, count - schoolCount);
|
||||
return [
|
||||
...diverseWeightedPick(betriebPool, betriebCount, 2),
|
||||
...weightedPick(schoolPool, schoolCount),
|
||||
];
|
||||
}
|
||||
|
||||
/** Full week set: ~8–12 diverse betrieb (+ school topics if needed). */
|
||||
export function pickWeekSet(
|
||||
monday: string,
|
||||
usedIds: number[],
|
||||
alreadySelected: string[] = [],
|
||||
): Suggestion[] {
|
||||
const needSchool = weekNeedsSchool(monday);
|
||||
const betriebPool = unusedPool("betrieb", usedIds, alreadySelected);
|
||||
const schoolPool = unusedPool("schule", usedIds, alreadySelected);
|
||||
|
||||
const betrieb = diverseWeightedPick(betriebPool, 10, 3);
|
||||
if (!needSchool) return betrieb;
|
||||
|
||||
const school = weightedPick(schoolPool, 3);
|
||||
return [...betrieb, ...school];
|
||||
}
|
||||
|
||||
export function splitSelection(selected: Suggestion[]): {
|
||||
activities: string[];
|
||||
schoolTopics: string[];
|
||||
} {
|
||||
const activities = selected.filter((s) => s.type === "betrieb").map((s) => s.text);
|
||||
const schoolTopics = selected.filter((s) => s.type === "schule").map((s) => s.text);
|
||||
return { activities, schoolTopics };
|
||||
}
|
||||
|
||||
export function suggestionCount(): number {
|
||||
return SUGGESTIONS.length;
|
||||
}
|
||||
|
||||
export function findSuggestionsByText(texts: string[]): Suggestion[] {
|
||||
return texts
|
||||
.map((t) => SUGGESTIONS.find((s) => s.text === t))
|
||||
.filter((s): s is Suggestion => Boolean(s));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { feiertagActivityLine, formatDE, weekWorkdays } from "./calendar";
|
||||
import { getTrainee, type WeekEntry } from "./storage";
|
||||
|
||||
/** Canonical export payload – DOCX and PDF must use the same content. */
|
||||
export type WeekExportContent = {
|
||||
department: string;
|
||||
name: string;
|
||||
number: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
periodLine: string;
|
||||
yearLabel: string;
|
||||
signDate: string;
|
||||
/** Flat activity bullets (incl. „Freitag Feiertag“ like originals) */
|
||||
activities: string[];
|
||||
frameworkRefs: string[];
|
||||
frameworkLines: string[];
|
||||
weekTheme: string;
|
||||
/** School topics, or ["Ferien"] when no school week */
|
||||
schoolTopics: string[];
|
||||
filenameBase: string;
|
||||
};
|
||||
|
||||
export function weekFilename(week: WeekEntry, ext: "docx" | "pdf"): string {
|
||||
const from = formatDE(week.from);
|
||||
const to = formatDE(week.to);
|
||||
// Match existing: "Berichtsheft 23.03 - 27.03.2026 (77).docx"
|
||||
return `Berichtsheft ${from.slice(0, 5)} - ${to} (${week.number}).${ext}`;
|
||||
}
|
||||
|
||||
/** Append Feiertag markers like originals (e.g. „Freitag Feiertag“). */
|
||||
export function buildActivityLines(week: WeekEntry): string[] {
|
||||
const lines = [...week.activities];
|
||||
for (const d of weekWorkdays(week.from)) {
|
||||
const line = feiertagActivityLine(d);
|
||||
if (line && !lines.includes(line)) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function buildSchoolTopics(week: WeekEntry): string[] {
|
||||
if (week.schoolTopics.length > 0) return [...week.schoolTopics];
|
||||
return ["Ferien"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filled export content from a WeekEntry.
|
||||
* Guarantees non-empty Pflichtfelder for a finished week.
|
||||
*/
|
||||
export function buildWeekExportContent(week: WeekEntry): WeekExportContent {
|
||||
const trainee = getTrainee();
|
||||
const fromLabel = formatDE(week.from);
|
||||
const toLabel = formatDE(week.to);
|
||||
const signDate = toLabel;
|
||||
const activities = buildActivityLines(week);
|
||||
const schoolTopics = buildSchoolTopics(week);
|
||||
const frameworkRefs =
|
||||
week.frameworkRefs.length > 0 ? [...week.frameworkRefs] : ["–"];
|
||||
const weekTheme = week.weekTheme.trim() || "–";
|
||||
|
||||
return {
|
||||
department: trainee.department || "–",
|
||||
name: trainee.name || "–",
|
||||
number: week.number,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
periodLine: `Für die Zeit vom ${fromLabel} bis ${toLabel}`,
|
||||
yearLabel: week.yearLabel || "–",
|
||||
signDate,
|
||||
activities: activities.length > 0 ? activities : ["–"],
|
||||
frameworkRefs,
|
||||
frameworkLines: frameworkRefs.map((r) =>
|
||||
r === "–" ? "–" : `Lfd. Nr. ${r}`,
|
||||
),
|
||||
weekTheme,
|
||||
schoolTopics,
|
||||
filenameBase: weekFilename(week, "docx").replace(/\.docx$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** Required fields that must not be empty before export. */
|
||||
export function missingExportFields(week: WeekEntry): string[] {
|
||||
const c = buildWeekExportContent(week);
|
||||
const missing: string[] = [];
|
||||
if (!c.department || c.department === "–") missing.push("Abteilung");
|
||||
if (!c.name || c.name === "–") missing.push("Name");
|
||||
if (!c.number) missing.push("Ausbildungsnachweis-Nr.");
|
||||
if (!c.fromLabel || !c.toLabel) missing.push("Zeitraum");
|
||||
if (!c.yearLabel || c.yearLabel === "–") missing.push("Ausbildungsjahr");
|
||||
if (!c.signDate) missing.push("Datum");
|
||||
if (!week.activities.length && !c.activities.some((a) => a.endsWith("Feiertag"))) {
|
||||
missing.push("Tägliche Tätigkeiten");
|
||||
}
|
||||
if (!week.frameworkRefs.length) missing.push("Lfd. Nr. Ausbildungsrahmenplan");
|
||||
if (!week.weekTheme.trim()) missing.push("Thema der Woche");
|
||||
if (!c.schoolTopics.length) missing.push("Berufsschule");
|
||||
return missing;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user