/** * 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. */ 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 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 ? { kind, label, date, minutes, changes } : null } return { 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))) continue } entries.push(entry) } return entries }