Files
betreuungs-cockpit/scripts/bauprotokoll.mjs
T
Florian Wilde c5bd8b53d6 docs: Bauprotokoll mit Zeiten, Aufwand und gefundenen Fehlern
Aufstellung, wann was entstanden ist und was es gekostet hat - zur
Nachvollziehbarkeit und zur Kostenrechnung.

Die reine Commit-Rechnung taugt hier nur bedingt: Die Etappen 2 bis 7
sind parallel entstanden, erst drei, dann fuenf Agenten gleichzeitig.
Aus der Historie sieht es aus, als haette Etappe 3 dreissig Minuten
gedauert - tatsaechlich lief sie knapp eine Stunde und gleichzeitig mit
vier anderen. Deshalb steht neben der automatischen Auswertung eine
Tabelle mit den tatsaechlichen Laufzeiten und den Ausgabe-Token je
Agent; daran bemessen sich die Kosten.

Ebenfalls festgehalten: die gut drei Stunden erzwungener Pause am
Sitzungslimit, die in der kalendarischen Spanne stecken, aber keine
Bauzeit sind. Reine Bauzeit rund zwei Stunden 45 Minuten.

Und die sechs Fehler, die waehrend des Baus aufgefallen sind - von der
Server/Client-Grenze bis zur Doppelzaehlung bei den Heimkosten. Die
stehen dort, weil sie zeigen, wo bei dieser Art Anwendung die
Fallstricke liegen.

Etappe: 8
Phase: Haertung
2026-08-22 07:11:47 +02:00

171 lines
5.6 KiB
JavaScript

#!/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('')
}