This commit is contained in:
2026-07-26 18:11:57 +02:00
parent d408e01ab5
commit 21a3b34471
139 changed files with 32872 additions and 0 deletions
+25
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+58
View File
@@ -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.
+19
View File
@@ -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>
+2308
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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

+24
View File
@@ -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.
+180
View File
@@ -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
};
+291
View File
@@ -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(/&gt;/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
+213
View File
@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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();
+336
View File
@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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
+15
View File
@@ -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
+433
View File
@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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);
});
+285
View File
@@ -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
+137
View File
@@ -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.");
+153
View File
@@ -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(/&gt;/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
View File
@@ -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
View File
@@ -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

+63
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
}
button,
input,
select {
font-family: inherit;
}
+158
View File
@@ -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 (MoFr), 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";
}
+191
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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;
}
+36
View File
@@ -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"));
}
+304
View File
@@ -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"));
}
+116
View File
@@ -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}`;
}
+137
View File
@@ -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: ~812 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));
}
+98
View File
@@ -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;
}
+10
View File
@@ -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>,
);
+27
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})