250 lines
9.9 KiB
JavaScript
250 lines
9.9 KiB
JavaScript
/**
|
||
* Kundenansicht der Projektdaten: Commits, Ticketstatus und Arbeitseintraege so aufbereiten,
|
||
* dass Endkunden sie verstehen. Alles Technische (Commit-Kuerzel, Kontonamen, Git-Links,
|
||
* KI-Hinweise wie "Co-Authored-By: Claude ...") bleibt auf dem Server.
|
||
* Admin-Ansichten nutzen weiter die Rohdaten.
|
||
*/
|
||
|
||
import { parseSupporters, supportMinutes, firstName } from './worksheetMinutes.js'
|
||
|
||
const GENERIC_UPDATE = 'Website aktualisiert'
|
||
|
||
/* --------------------------------------------------------------- Commits --- */
|
||
|
||
// Zeilen, die nie beim Kunden landen: Git-Trailer (Co-Authored-By, Signed-off-by, ...),
|
||
// Hinweise von KI-Werkzeugen und die Standardzeile von "git revert"
|
||
const HIDDEN_LINES = [
|
||
/^[\w-]+-by\s*:/i,
|
||
/^(change-id|cc|made-with)\s*:/i,
|
||
/^\W*generated (with|by)\b/i,
|
||
/noreply@anthropic\.com/i,
|
||
/claude\.(ai|com)\/(code|claude-code)\b/i,
|
||
/^this reverts commit [0-9a-f]{7,40}/i,
|
||
]
|
||
|
||
// Rein technische Commits (Vorschau-Konfiguration, Merges) - fuer den Kunden ohne Inhalt
|
||
const HIDDEN_TITLES = [
|
||
/^update preview config\b/i,
|
||
/^preview-config\b/i,
|
||
/^hosting: preview aktivieren\b/i,
|
||
/^migration: preview\b/i,
|
||
/^merge (branch|pull request|remote-tracking branch|tag)\b/i,
|
||
]
|
||
|
||
// Feste Texte unserer Automatisierung -> verstaendliche Fassung
|
||
const TRANSLATED_TITLES = [
|
||
[/^initial commit$/i, () => 'Projekt angelegt'],
|
||
[/^automatisch erstellte preview-seite$/i, () => 'Erste Vorschau der Website erstellt'],
|
||
[/^anreicherung:\s*(.+?)(\s*\(cloud-routine\))?$/i, (m) => `Inhalte ergänzt: ${m[1]}`],
|
||
[/^google fonts self-hosten\b/i, () => 'Schriftarten datenschutzkonform eingebunden (DSGVO)'],
|
||
[/^website \S+ – aus \S+, auf .+ angepasst$/i, () => 'Website erstellt und an den Betrieb angepasst'],
|
||
]
|
||
|
||
// "feat(roadmaps): ...", "fix: ..." (Conventional Commits)
|
||
const TYPE_PREFIX = /^(feat|fix|hotfix|chore|docs|style|refactor|perf|tests?|build|ci|wip)(\([^)]*\))?!?:\s*/i
|
||
|
||
const MAX_DETAILS = 6
|
||
const MAX_DETAIL_LENGTH = 300
|
||
|
||
function isHiddenLine(line) {
|
||
return HIDDEN_LINES.some((re) => re.test(line.trim()))
|
||
}
|
||
|
||
function stripMarkdown(text) {
|
||
return text.replace(/`/g, '')
|
||
}
|
||
|
||
function capitalize(text) {
|
||
return text.charAt(0).toUpperCase() + text.slice(1)
|
||
}
|
||
|
||
/** Commit-Titel fuer den Kunden, oder null wenn der Commit nichts Sichtbares aendert. */
|
||
function customerCommitTitle(rawTitle) {
|
||
let title = stripMarkdown(String(rawTitle || '').trim())
|
||
|
||
const revert = /^revert "(.+)"$/i.exec(title)
|
||
if (revert) {
|
||
const inner = customerCommitTitle(revert[1])
|
||
return inner ? `„${inner}“ zurückgenommen` : null
|
||
}
|
||
|
||
title = title.replace(TYPE_PREFIX, '').trim()
|
||
if (HIDDEN_TITLES.some((re) => re.test(title))) return null
|
||
for (const [re, translate] of TRANSLATED_TITLES) {
|
||
const match = re.exec(title)
|
||
if (match) return translate(match)
|
||
}
|
||
// "fix", "upload", "pagev1", "webseite-version-01": ein kleingeschriebenes Wort sagt dem Kunden nichts
|
||
if (!title || /^[a-z0-9._-]+$/.test(title)) return GENERIC_UPDATE
|
||
return capitalize(title)
|
||
}
|
||
|
||
function clip(text) {
|
||
return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH - 1).trimEnd()}…` : text
|
||
}
|
||
|
||
/**
|
||
* Commit-Beschreibung als Liste kurzer Punkte. Git bricht Zeilen hart um (72 Zeichen);
|
||
* Folgezeilen werden wieder an den Punkt bzw. Absatz davor gehaengt.
|
||
*/
|
||
function customerCommitDetails(bodyLines, title) {
|
||
const items = []
|
||
let open = false
|
||
for (const raw of bodyLines) {
|
||
const line = stripMarkdown(raw.trim())
|
||
if (!line || isHiddenLine(line)) {
|
||
open = false
|
||
continue
|
||
}
|
||
const bullet = /^[-*•]\s+(.*)$/.exec(line)
|
||
if (bullet) {
|
||
items.push(bullet[1])
|
||
open = true
|
||
} else if (open) {
|
||
const prev = items[items.length - 1]
|
||
items[items.length - 1] = prev.endsWith('-') ? prev + line : `${prev} ${line}`
|
||
} else {
|
||
items.push(line)
|
||
open = true
|
||
}
|
||
}
|
||
return items.filter((item) => item && item !== title).slice(0, MAX_DETAILS).map(clip)
|
||
}
|
||
|
||
/** Commits aus listRepoCommits() -> [{ date, title, details }] ohne Kuerzel, Autor und Link. */
|
||
export function customerChanges(commits = []) {
|
||
const changes = []
|
||
for (const commit of commits) {
|
||
const [firstLine = '', ...bodyLines] = String(commit?.message || '').trim().split('\n')
|
||
const title = customerCommitTitle(firstLine)
|
||
if (!title) continue
|
||
const date = commit.date || ''
|
||
// Mehrere "Website aktualisiert" am selben Tag hintereinander sind fuer den Kunden eine Aenderung
|
||
const prev = changes[changes.length - 1]
|
||
if (prev && prev.title === title && prev.date.slice(0, 10) === date.slice(0, 10)) continue
|
||
changes.push({ date, title, details: customerCommitDetails(bodyLines, title) })
|
||
}
|
||
return changes
|
||
}
|
||
|
||
/* ---------------------------------------------------- Tickets / Auftrag --- */
|
||
|
||
// step: Position in der Fortschrittsleiste Eingegangen (1) -> In Arbeit (2) -> Prüfung (3) -> Erledigt (4);
|
||
// 0 = ausserhalb (pausiert, storniert, abgebrochen)
|
||
const TICKET_STATUS = {
|
||
open: { label: 'Eingegangen', tone: 'new', step: 1 },
|
||
assigned: { label: 'Eingeplant', tone: 'active', step: 2 },
|
||
occupied: { label: 'In Arbeit', tone: 'active', step: 2 },
|
||
'added info': { label: 'In Arbeit', tone: 'active', step: 2 },
|
||
awaiting: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 },
|
||
'in test': { label: 'Wird geprüft', tone: 'review', step: 3 },
|
||
closed: { label: 'Erledigt', tone: 'done', step: 4 },
|
||
halted: { label: 'Pausiert', tone: 'paused', step: 0 },
|
||
cancelled: { label: 'Storniert', tone: 'stopped', step: 0 },
|
||
aborted: { label: 'Abgebrochen', tone: 'stopped', step: 0 },
|
||
}
|
||
const UNKNOWN_STATUS = { label: 'In Bearbeitung', tone: 'active', step: 2 }
|
||
|
||
/** Ticketstatus aus dem Ticketsystem -> { label, tone, step } auf Deutsch. */
|
||
export function customerTicketStatus(status) {
|
||
const key = String(status || 'open').trim().toLowerCase()
|
||
return { ...(TICKET_STATUS[key] || UNKNOWN_STATUS) }
|
||
}
|
||
|
||
/** Interne Tickets (Akquise) gehoeren nicht in die Kundenansicht. */
|
||
export function isInternalTicket(ticket) {
|
||
return String(ticket?.type || '').trim().toLowerCase() === 'akquise' || /^akquise\s*:/i.test(String(ticket?.topic || '').trim())
|
||
}
|
||
|
||
/** Ticket -> Auftrag fuer die Kundenansicht (null bei fehlendem oder internem Ticket). */
|
||
export function customerOrder(ticket) {
|
||
if (!ticket || isInternalTicket(ticket)) return null
|
||
return {
|
||
number: String(ticket.woid || ''),
|
||
topic: String(ticket.topic || ticket.title || ''),
|
||
status: customerTicketStatus(ticket.status),
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------ Arbeitseintraege --- */
|
||
|
||
const WORK_KINDS = {
|
||
git: ['update', GENERIC_UPDATE],
|
||
remote: ['online', 'Online erledigt'],
|
||
'on site': ['onsite', 'Vor Ort bei dir'],
|
||
'off site': ['office', 'In unserem Büro'],
|
||
}
|
||
const OTHER_WORK = ['work', 'Arbeit am Projekt']
|
||
|
||
// "24.09.2026" (Ticketsystem) -> "2026-09-24"
|
||
function isoDay(value) {
|
||
const text = String(value || '').trim()
|
||
const de = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text)
|
||
if (de) return `${de[3]}-${de[2]}-${de[1]}`
|
||
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : ''
|
||
}
|
||
|
||
// Git-Push-Worksheet (gitWorksheet.js): "- [sha](url): Titel (Autor)" je Commit
|
||
function changesFromPushDetails(details) {
|
||
const titles = []
|
||
for (const line of String(details || '').split('\n')) {
|
||
const match = /^- \[[0-9a-f]{7,40}\]\([^)]*\):\s*(.+)$/i.exec(line.trim())
|
||
if (!match) continue
|
||
const title = customerCommitTitle(match[1].replace(/\s*\([^()]*\)\s*$/, ''))
|
||
if (title && !titles.includes(title)) titles.push(title)
|
||
}
|
||
return titles
|
||
}
|
||
|
||
function customerWorkEntry(sheet, { includeUpdates }) {
|
||
const type = String(sheet?.serviceType || '').trim().toLowerCase()
|
||
if (type === 'comment') return null
|
||
const isGit = type === 'git'
|
||
// Reine Kommentare sind intern; Git-Pushes legt das Ticketsystem ebenfalls als Kommentar an
|
||
if (sheet.isComment && !isGit) return null
|
||
|
||
const [kind, label] = WORK_KINDS[type] || OTHER_WORK
|
||
const date = isoDay(sheet.date || sheet.endDate || sheet.startDate)
|
||
const minutes = Number(sheet.totalTime) || 0
|
||
// Unterstuetzung: eigene Zeilen, je Person die Haelfte der Arbeitszeit (aufgerundet auf 15 min)
|
||
const supports = parseSupporters(sheet.supporters, sheet.$id)
|
||
.map((s) => ({ name: firstName(s.name), task: s.task, minutes: supportMinutes(minutes) }))
|
||
.filter((s) => s.minutes > 0)
|
||
const withSupports = (entry) => (supports.length ? { ...entry, supports } : entry)
|
||
|
||
if (isGit) {
|
||
// In den Projektdetails stehen die Aenderungen schon unter "Letzte Änderungen";
|
||
// dort zaehlt ein Push nur, wenn Arbeitszeit nachgetragen wurde
|
||
if (!includeUpdates && !minutes) return null
|
||
const changes = changesFromPushDetails(sheet.details)
|
||
return changes.length ? withSupports({ kind, label, date, minutes, changes }) : null
|
||
}
|
||
return withSupports({ kind, label, date, minutes, person: String(sheet.employeeName || '').trim(), text: String(sheet.details || '').trim() })
|
||
}
|
||
|
||
/** Worksheets (roh aus Appwrite oder aus getCustomerActivity) -> Eintraege fuer den Kunden. */
|
||
export function customerWorkEntries(sheets = [], { includeUpdates = true } = {}) {
|
||
const entries = []
|
||
for (const entry of sheets.map((sheet) => customerWorkEntry(sheet, { includeUpdates }))) {
|
||
if (!entry) continue
|
||
// Mehrere Pushes an einem Tag: ein Eintrag "Website aktualisiert" mit allen Aenderungen
|
||
const prev = entries[entries.length - 1]
|
||
if (entry.kind === 'update' && prev?.kind === 'update' && prev.date === entry.date) {
|
||
prev.minutes += entry.minutes
|
||
prev.changes.push(...entry.changes.filter((change) => !prev.changes.includes(change)))
|
||
if (entry.supports) prev.supports = [...(prev.supports || []), ...entry.supports]
|
||
continue
|
||
}
|
||
entries.push(entry)
|
||
}
|
||
return entries
|
||
}
|
||
|
||
/** Arbeitszeit gesamt fuer den Projektkopf: alle Schritte inkl. Unterstuetzung */
|
||
export function workTotalMinutes(entries = []) {
|
||
return entries.reduce(
|
||
(sum, e) => sum + (Number(e.minutes) || 0) + (e.supports || []).reduce((s, x) => s + (Number(x.minutes) || 0), 0),
|
||
0
|
||
)
|
||
}
|