#!/usr/bin/env node /** * Bauprotokoll aus der Git-Historie erzeugen. * * Aufruf: npm run protokoll -> Tabelle auf der Konsole * npm run protokoll -- --md -> Markdown fuer docs/BAUPROTOKOLL.md * * Grundlage sind die Commit-Trailer "Etappe:" und "Phase:". Jeder Commit * traegt sie, damit sich im Nachhinein nachvollziehen laesst, wie lange * welche Etappe gedauert hat - fuer die Kostenrechnung und als Nachweis, * was wann entstanden ist. * * Die Dauer einer Etappe wird gemessen vom Ende der vorherigen Etappe * (bzw. vom Baubeginn) bis zum letzten Commit der Etappe. Pausen zwischen * zwei Commits, die laenger als PAUSE_SCHWELLE_MIN dauern, werden als * Pause gewertet und nicht als Arbeitszeit gezaehlt. */ import { execSync } from 'node:child_process' /** Laengere Luecken gelten als Pause und zaehlen nicht als Arbeitszeit. */ const PAUSE_SCHWELLE_MIN = 30 const TRENNER = '' // ASCII Unit Separator - kommt in Commit-Texten nicht vor const SATZ_TRENNER = '' // Record Separator function commitsLesen() { const roh = execSync( `git log --reverse --format="%H${TRENNER}%at${TRENNER}%s${TRENNER}%b${SATZ_TRENNER}"`, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }, ) return roh .split(SATZ_TRENNER) .map((eintrag) => eintrag.trim()) .filter(Boolean) .map((eintrag) => { const [hash, zeitstempel, betreff, koerper = ''] = eintrag.split(TRENNER) const etappe = koerper.match(/^Etappe:\s*(.+)$/m)?.[1]?.trim() ?? 'ohne' const phase = koerper.match(/^Phase:\s*(.+)$/m)?.[1]?.trim() ?? '' return { hash: hash.slice(0, 8), zeit: new Date(Number(zeitstempel) * 1000), betreff, etappe, phase, } }) } /** * Arbeitszeit als Summe der Abstaende zwischen aufeinanderfolgenden * Commits, wobei zu grosse Abstaende auf die Pausenschwelle gedeckelt * werden. Der allererste Commit bekommt die Schwelle als Anlaufzeit * gutgeschrieben - vorher wurde ja auch schon gearbeitet. */ function arbeitsminuten(commits) { if (commits.length === 0) return 0 let summe = PAUSE_SCHWELLE_MIN for (let i = 1; i < commits.length; i++) { const diff = (commits[i].zeit - commits[i - 1].zeit) / 60000 summe += Math.min(diff, PAUSE_SCHWELLE_MIN) } return summe } function dauerText(minuten) { const m = Math.round(minuten) const std = Math.floor(m / 60) const rest = m % 60 if (std === 0) return `${rest} min` return `${std} h ${String(rest).padStart(2, '0')} min` } function formatZeit(datum) { return datum.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }) } const commits = commitsLesen() if (commits.length === 0) { console.error('Keine Commits gefunden.') process.exit(1) } // Nach Etappe gruppieren, Reihenfolge des ersten Auftretens beibehalten. const gruppen = new Map() for (const commit of commits) { if (!gruppen.has(commit.etappe)) gruppen.set(commit.etappe, []) gruppen.get(commit.etappe).push(commit) } const zeilen = [...gruppen.entries()].map(([etappe, eintraege]) => ({ etappe, phase: eintraege.find((e) => e.phase)?.phase ?? '', commits: eintraege.length, von: eintraege[0].zeit, bis: eintraege[eintraege.length - 1].zeit, minuten: arbeitsminuten(eintraege), })) const gesamtMinuten = zeilen.reduce((s, z) => s + z.minuten, 0) const gesamtCommits = commits.length const spanneMinuten = (commits[commits.length - 1].zeit - commits[0].zeit) / 60000 const alsMarkdown = process.argv.includes('--md') if (alsMarkdown) { const out = [] out.push('# Bauprotokoll') out.push('') out.push('Automatisch aus der Git-Historie erzeugt: `npm run protokoll -- --md`.') out.push('Grundlage sind die Commit-Trailer `Etappe:` und `Phase:`.') out.push('') out.push( `Reine Arbeitszeit wird als Summe der Commit-Abstaende gemessen, gedeckelt auf ` + `${PAUSE_SCHWELLE_MIN} Minuten je Abstand. Laengere Luecken gelten als Pause.`, ) out.push('') out.push('| Etappe | Phase | Commits | Beginn | Ende | Arbeitszeit |') out.push('|---|---|---:|---|---|---:|') for (const z of zeilen) { out.push( `| ${z.etappe} | ${z.phase} | ${z.commits} | ${formatZeit(z.von)} | ` + `${formatZeit(z.bis)} | ${dauerText(z.minuten)} |`, ) } out.push( `| **Gesamt** | | **${gesamtCommits}** | ${formatZeit(commits[0].zeit)} | ` + `${formatZeit(commits[commits.length - 1].zeit)} | **${dauerText(gesamtMinuten)}** |`, ) out.push('') out.push(`Kalendarische Spanne vom ersten bis zum letzten Commit: ${dauerText(spanneMinuten)}.`) out.push('') out.push('## Commits im Einzelnen') out.push('') out.push('| Zeitpunkt | Etappe | Commit | Betreff |') out.push('|---|---|---|---|') for (const c of commits) { out.push(`| ${formatZeit(c.zeit)} | ${c.etappe} | \`${c.hash}\` | ${c.betreff} |`) } console.log(out.join('\n')) } else { console.log('') console.log('Bauprotokoll') console.log('='.repeat(74)) console.log( 'Hinweis: gemessen werden Commit-Abstaende. Fuer parallel gebaute ' + 'Etappen ist das zu wenig - siehe docs/BAUPROTOKOLL.md.', ) console.log('-'.repeat(74)) for (const z of zeilen) { console.log( `Etappe ${z.etappe.padEnd(4)} ${z.phase.padEnd(16)} ` + `${String(z.commits).padStart(3)} Commits ${dauerText(z.minuten).padStart(10)}`, ) } console.log('-'.repeat(74)) console.log( `Gesamt${' '.repeat(17)}${String(gesamtCommits).padStart(3)} Commits ` + `${dauerText(gesamtMinuten).padStart(10)}`, ) console.log(`Kalendarische Spanne: ${dauerText(spanneMinuten)}`) console.log('') }