From d2d4ad9cfb5c0aaa74648d66509bf258210b1db2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:15:19 +0000 Subject: [PATCH] Projekte: Typ-, Hosting- und Aktionslogik als getestete Lib-Funktionen Co-Authored-By: Claude Sonnet 5.5 --- src/lib/projectRow.js | 90 ++++++++++++++++++++++++++++++++++ tests/unit/projectRow.test.mjs | 56 +++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/lib/projectRow.js create mode 100644 tests/unit/projectRow.test.mjs diff --git a/src/lib/projectRow.js b/src/lib/projectRow.js new file mode 100644 index 0000000..cb8e4c4 --- /dev/null +++ b/src/lib/projectRow.js @@ -0,0 +1,90 @@ +// Logik der Projektliste (Typ, Hosting-Zustand, Aktionen je Zeile) ohne Markup. +// Aus ProjectsPage herausgezogen; die Töne (info/ok/warn/muted) lösen die Badge-Komponenten auf. +import { formatDistance } from 'date-fns' +import { de } from 'date-fns/locale' +import { fmtDateTime } from './format' + +// Projekt-Typen (= "Template"/Hosting-Modus). Die Reihenfolge der Schlüssel bestimmt die Optionen im Dialog. +export const PROJECT_TYPES = { + preview: { + label: 'Preview (mit Login)', + short: 'Preview', + tone: 'info', + hint: 'Login-geschuetzte Vorschau unter .project.webklar.com', + }, + website: { + label: 'Oeffentliche Website', + short: 'Website', + tone: 'ok', + hint: 'Oeffentlich erreichbar ohne Login unter .project.webklar.com', + }, + template: { + label: 'Vorlage', + short: 'Vorlage', + tone: 'warn', + hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht', + adminOnly: true, + }, + project: { + label: 'Internes Projekt (kein Hosting)', + short: 'Projekt', + tone: 'muted', + hint: 'Nur Gitea-Repository, keine Website/Subdomain', + }, +} + +// Typen mit Hosting (Subdomain + Deploy) +export const HOSTED_TYPES = new Set(['preview', 'website', 'template']) + +export function typeOf(project) { + if (project?.isTemplate) return 'template' + if (project?.isPublic) return 'website' + if (project?.subdomain) return 'preview' + return 'project' +} + +// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js): +// nur 'ready'/'deployed' gelten als tatsaechlich gehostet. +export function isProjectReady(project) { + const st = String(project?.provisioningStatus || project?.status || '').toLowerCase() + return ['ready', 'deployed'].includes(st) +} + +/** + * Welche Aktionen und welches Hosting-Kennzeichen eine Zeile zeigt. + * host = "Hosten" (noch nicht deployt), preview = Vorschau-Link, refresh = "Aktualisieren" (erneut deployen). + */ +export function projectActions(project) { + const type = typeOf(project) + const hosted = HOSTED_TYPES.has(type) + const ready = isProjectReady(project) + const archived = Boolean(project?.archived) + const host = hosted && !ready && !archived + const preview = !host && Boolean(project?.previewUrl) + const refresh = hosted && ready && !archived + const gitea = Boolean(project?.giteaRepoUrl) + const hostedBadge = !hosted || archived + ? null + : ready + ? { text: 'gehostet', tone: 'ok' } + : { text: 'nicht deployt', tone: 'warn' } + return { type, hosted, ready, host, preview, refresh, gitea, hostedBadge } +} + +/** Ticketnummer (woid) der Zeile: aus der Ticketliste, sonst die $id, ohne Ticket leer. */ +export function projectTicketRef(project, ticketMap) { + const id = project?.ticketId + if (!id) return '' + return String(ticketMap?.[id] ?? id) +} + +/** "vor etwa 3 Stunden" mit Titel in Berliner Zeit; fehlend oder unlesbar -> "-". */ +export function updatedLabel(iso, now = new Date()) { + if (!iso) return { text: '-', title: '' } + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return { text: '-', title: '' } + return { + text: formatDistance(date, now, { addSuffix: true, locale: de }), + title: fmtDateTime(iso), + } +} diff --git a/tests/unit/projectRow.test.mjs b/tests/unit/projectRow.test.mjs new file mode 100644 index 0000000..af71740 --- /dev/null +++ b/tests/unit/projectRow.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { PROJECT_TYPES, typeOf, isProjectReady, projectActions, projectTicketRef, updatedLabel } = await loadModule('src/lib/projectRow.js') + +test('typeOf: Vorlage vor Website vor Preview vor Projekt', () => { + assert.equal(typeOf({ isTemplate: true, isPublic: true, subdomain: 'x' }), 'template') + assert.equal(typeOf({ isPublic: true, subdomain: 'x' }), 'website') + assert.equal(typeOf({ subdomain: 'x' }), 'preview') + assert.equal(typeOf({}), 'project') +}) +test('isProjectReady: nur ready/deployed, provisioningStatus hat Vorrang', () => { + assert.equal(isProjectReady({ status: 'ready' }), true) + assert.equal(isProjectReady({ status: 'DEPLOYED' }), true) + assert.equal(isProjectReady({ provisioningStatus: 'pending', status: 'ready' }), false) + assert.equal(isProjectReady({ status: 'building' }), false) + assert.equal(isProjectReady({}), false) +}) +test('PROJECT_TYPES: Reihenfolge, Texte und Töne unverändert', () => { + assert.deepEqual(Object.keys(PROJECT_TYPES), ['preview', 'website', 'template', 'project']) + assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.label), ['Preview (mit Login)', 'Oeffentliche Website', 'Vorlage', 'Internes Projekt (kein Hosting)']) + assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.short), ['Preview', 'Website', 'Vorlage', 'Projekt']) + assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.tone), ['info', 'ok', 'warn', 'muted']) + assert.equal(PROJECT_TYPES.template.adminOnly, true) + assert.equal(PROJECT_TYPES.project.hint, 'Nur Gitea-Repository, keine Website/Subdomain') +}) +test('projectActions: gehostet und nicht deployt', () => { + const ready = projectActions({ subdomain: 'a', status: 'ready', previewUrl: 'https://a.project.webklar.com', giteaRepoUrl: 'https://git/x' }) + assert.deepEqual(ready, { type: 'preview', hosted: true, ready: true, host: false, preview: true, refresh: true, gitea: true, hostedBadge: { text: 'gehostet', tone: 'ok' } }) + const open = projectActions({ subdomain: 'a', status: 'provisioning', previewUrl: 'https://a.project.webklar.com' }) + assert.deepEqual(open, { type: 'preview', hosted: true, ready: false, host: true, preview: false, refresh: false, gitea: false, hostedBadge: { text: 'nicht deployt', tone: 'warn' } }) +}) +test('projectActions: archiviert, ohne Website, Vorlage', () => { + const arch = projectActions({ subdomain: 'a', status: 'ready', archived: true, previewUrl: 'u' }) + assert.deepEqual([arch.host, arch.preview, arch.refresh, arch.hostedBadge], [false, true, false, null]) + const archOpen = projectActions({ subdomain: 'a', status: 'error', archived: true }) + assert.deepEqual([archOpen.host, archOpen.refresh, archOpen.hostedBadge], [false, false, null]) + const intern = projectActions({ giteaRepoUrl: 'https://git/x' }) + assert.deepEqual([intern.type, intern.host, intern.preview, intern.refresh, intern.hostedBadge, intern.gitea], ['project', false, false, false, null, true]) + const tpl = projectActions({ isTemplate: true, subdomain: 't', status: 'deployed' }) + assert.deepEqual([tpl.type, tpl.refresh, tpl.host], ['template', true, false]) +}) +test('projectTicketRef: woid aus der Liste, sonst die $id, ohne Ticket leer', () => { + assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: '100131' }), '100131') + assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: 100131 }), '100131') + assert.equal(projectTicketRef({ ticketId: 'zzz' }, {}), 'zzz') + assert.equal(projectTicketRef({ ticketId: '' }, { abc: '1' }), '') + assert.equal(projectTicketRef({}, {}), '') +}) +test('updatedLabel: deutsch relativ, Titel in Berliner Zeit, ungültig = "-"', () => { + const now = new Date('2026-10-05T10:00:00.000Z') + assert.deepEqual(updatedLabel('2026-10-05T07:00:00.000Z', now), { text: 'vor etwa 3 Stunden', title: '05.10.26, 09:00' }) + assert.equal(updatedLabel('2026-10-04T10:00:00.000Z', now).text, 'vor 1 Tag') + for (const bad of ['', 'kaputt', undefined]) assert.deepEqual(updatedLabel(bad, now), { text: '-', title: '' }) +})