From 1497f7a4dc1977ddd4f003df09e7ebcdf2073059 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:47:36 +0000 Subject: [PATCH] =?UTF-8?q?Unterst=C3=BCtzung:=20Rechenregeln=20f=C3=BCr?= =?UTF-8?q?=2015-Minuten-Raster=20und=20Unterst=C3=BCtzungszeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-30-unterstuetzung-design.md | 2 +- src/lib/support.js | 85 +++++++++++++ src/lib/timeSteps.js | 39 ++++++ tests/unit/support.test.mjs | 114 ++++++++++++++++++ 4 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/lib/support.js create mode 100644 src/lib/timeSteps.js create mode 100644 tests/unit/support.test.mjs diff --git a/docs/superpowers/specs/2026-09-30-unterstuetzung-design.md b/docs/superpowers/specs/2026-09-30-unterstuetzung-design.md index 859c61d..54eeaf2 100644 --- a/docs/superpowers/specs/2026-09-30-unterstuetzung-design.md +++ b/docs/superpowers/specs/2026-09-30-unterstuetzung-design.md @@ -53,7 +53,7 @@ weil Claude den Appwrite-Schlüssel nicht verwenden darf. Betroffen: `CreateWorksheetModal` (neues Arbeitsblatt), `WorksheetTimesModal` („Zeiten bearbeiten“ und „Zeit nachtragen“ bei Git). -- Das Feld „Arbeitszeit“ wird eine Auswahl: 15 min, 30 min, 45 min, 1 h, 1 h 15 … bis 10 h (40 Werte). +- Das Feld „Arbeitszeit“ wird eine Auswahl: 15 min, 30 min, 45 min, 1 h, 1 h 15 … bis 12 h (48 Werte). Kein freies Eintippen mehr. - Beginn und Ende bleiben freie Uhrzeiten (bei Git ist das Ende die Uhrzeit des Pushs). Die automatisch berechnete Zeit (`minutesBetween`) wird auf den nächsten 15-Minuten-Schritt diff --git a/src/lib/support.js b/src/lib/support.js new file mode 100644 index 0000000..3eec0b8 --- /dev/null +++ b/src/lib/support.js @@ -0,0 +1,85 @@ +/* Unterstuetzung bei Arbeitsblaettern. + Im Arbeitsblatt steht im Feld "supporters" eine JSON-Liste + [{ employeeId, name, task }]. Die Minuten werden nie gespeichert: jede + unterstuetzende Person bekommt die Haelfte der Arbeitszeit, aufgerundet auf + 15 Minuten. Das Kundenportal rechnet mit derselben Regel + (server/services/worksheetMinutes.js). */ + +import { TIME_STEP } from './timeSteps' +import { isWorksheetCreator } from './ticketForm' + +const MAX_TASK = 200 + +export function supportMinutes(totalTime) { + const total = Number(totalTime) || 0 + if (total <= 0) return 0 + return Math.ceil(total / 2 / TIME_STEP) * TIME_STEP +} + +// Nie eine Exception: kaputte Werte ergeben eine leere Liste, kaputte Eintraege fallen weg +export function parseSupporters(raw) { + let list = raw + if (typeof raw === 'string') { + if (!raw.trim()) return [] + try { + list = JSON.parse(raw) + } catch { + return [] + } + } + if (!Array.isArray(list)) return [] + const seen = new Set() + const result = [] + for (const entry of list) { + if (!entry || typeof entry !== 'object') continue + const employeeId = String(entry.employeeId || '').trim() + const task = String(entry.task || '').trim().slice(0, MAX_TASK) + if (!employeeId || !task || seen.has(employeeId)) continue + seen.add(employeeId) + result.push({ employeeId, name: String(entry.name || '').trim(), task }) + } + return result +} + +export function serializeSupporters(list) { + return JSON.stringify((list || []).map(({ employeeId, name, task }) => ({ employeeId, name, task }))) +} + +// Arbeitszeit des Blatts plus die Unterstuetzung aller Personen +export function worksheetMinutes(worksheet) { + const base = Number(worksheet?.totalTime) || 0 + return base + parseSupporters(worksheet?.supporters).length * supportMinutes(base) +} + +export function firstName(name) { + return String(name || '').trim().split(/\s+/)[0] || '' +} + +// "Ich habe unterstuetzt": fremde Blaetter mit Arbeitszeit (Git-Pushes erst nach "Zeit nachtragen") +export function canAddOwnSupport(worksheet, user, employee) { + if (!user?.$id || !worksheet) return false + if (worksheet.isComment || !(Number(worksheet.totalTime) > 0)) return false + return !isWorksheetCreator(worksheet, user, employee) +} + +export function upsertSupporter(list, entry) { + const current = list || [] + return current.some((s) => s.employeeId === entry.employeeId) + ? current.map((s) => (s.employeeId === entry.employeeId ? { ...s, ...entry } : s)) + : [...current, entry] +} + +export function removeSupporter(list, employeeId) { + return (list || []).filter((s) => s.employeeId !== employeeId) +} + +// Pruefung im Formular des Erstellers; null = in Ordnung +export function validateSupporters(list, creatorUserId) { + const rows = list || [] + if (rows.some((s) => !String(s.employeeId || '').trim())) return 'Bitte in jeder Zeile eine Person auswählen.' + if (rows.some((s) => !String(s.task || '').trim())) return 'Bitte bei jeder Person angeben, wobei sie unterstützt hat.' + const ids = rows.map((s) => s.employeeId) + if (new Set(ids).size !== ids.length) return 'Jede Person nur einmal eintragen.' + if (creatorUserId && ids.includes(creatorUserId)) return 'Du kannst dich nicht selbst als Unterstützung eintragen.' + return null +} diff --git a/src/lib/timeSteps.js b/src/lib/timeSteps.js new file mode 100644 index 0000000..b9a4673 --- /dev/null +++ b/src/lib/timeSteps.js @@ -0,0 +1,39 @@ +/* Arbeitszeit wird nur in 15-Minuten-Schritten erfasst (15 min bis 12 h). + Aus Beginn und Ende berechnete Zeiten werden auf den naechsten Schritt aufgerundet. + 12 h ist auch das Maximum der Push-Zeitmail im Kundenportal. */ + +export const TIME_STEP = 15 +export const MAX_STEP_MINUTES = 720 + +// 50 -> 60, 61 -> 75; keine oder negative Dauer -> 15 (kleinster Schritt) +export function roundUpToStep(minutes) { + const value = Number(minutes) + if (!Number.isFinite(value) || value <= 0) return TIME_STEP + return Math.ceil(value / TIME_STEP) * TIME_STEP +} + +export function isValidStep(minutes) { + if (typeof minutes !== 'number' && typeof minutes !== 'string') return false + const value = Number(minutes) + return Number.isInteger(value) && value > 0 && value % TIME_STEP === 0 && value <= MAX_STEP_MINUTES +} + +// 15 -> "15 min", 60 -> "1 h", 75 -> "1 h 15 min" +export function stepLabel(minutes) { + const value = Math.max(0, Number(minutes) || 0) + const hours = Math.floor(value / 60) + const mins = value % 60 + if (!hours) return `${mins} min` + return mins ? `${hours} h ${mins} min` : `${hours} h` +} + +// Auswahl 15 min … 12 h. Ein alter, laengerer Wert im Raster bleibt waehlbar. +export function stepOptions(current) { + const options = [] + for (let m = TIME_STEP; m <= MAX_STEP_MINUTES; m += TIME_STEP) options.push({ value: m, label: stepLabel(m) }) + const value = Number(current) + if (Number.isInteger(value) && value > MAX_STEP_MINUTES && value % TIME_STEP === 0) { + options.push({ value, label: stepLabel(value) }) + } + return options +} diff --git a/tests/unit/support.test.mjs b/tests/unit/support.test.mjs new file mode 100644 index 0000000..9563fc3 --- /dev/null +++ b/tests/unit/support.test.mjs @@ -0,0 +1,114 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { TIME_STEP, MAX_STEP_MINUTES, roundUpToStep, isValidStep, stepOptions, stepLabel } = await loadModule('src/lib/timeSteps.js') +const { + supportMinutes, parseSupporters, serializeSupporters, worksheetMinutes, firstName, + canAddOwnSupport, upsertSupporter, removeSupporter, validateSupporters, +} = await loadModule('src/lib/support.js') + +const andrej = { employeeId: 'a', name: 'Andrej Stevanovski', task: 'Farbkonzept' } +const nico = { employeeId: 'n', name: 'Nico Rech', task: 'Texte' } + +test('Raster: 15 Minuten bis 12 Stunden', () => { + assert.equal(TIME_STEP, 15) + assert.equal(MAX_STEP_MINUTES, 720) +}) + +test('roundUpToStep rundet auf, 0 und leer werden 15', () => { + assert.equal(roundUpToStep(0), 15) + assert.equal(roundUpToStep(null), 15) + assert.equal(roundUpToStep(-10), 15) + assert.equal(roundUpToStep(50), 60) + assert.equal(roundUpToStep(60), 60) + assert.equal(roundUpToStep(61), 75) + assert.equal(roundUpToStep(70), 75) +}) + +test('isValidStep: nur ganze Vielfache von 15 bis 720', () => { + for (const ok of [15, 45, 720]) assert.equal(isValidStep(ok), true, String(ok)) + for (const bad of [70, 0, 735, 'x', 7.5, -15, null]) assert.equal(isValidStep(bad), false, String(bad)) +}) + +test('stepOptions: 48 Werte mit deutschen Beschriftungen', () => { + const opts = stepOptions() + assert.equal(opts.length, 48) + assert.deepEqual(opts[0], { value: 15, label: '15 min' }) + assert.deepEqual(opts[3], { value: 60, label: '1 h' }) + assert.deepEqual(opts[4], { value: 75, label: '1 h 15 min' }) + assert.deepEqual(opts[47], { value: 720, label: '12 h' }) + // Ein gueltiger Wert ueber 12 h (altes Blatt) bleibt waehlbar + assert.equal(stepOptions(900).length, 49) + assert.equal(stepOptions(60).length, 48) + assert.equal(stepLabel(135), '2 h 15 min') +}) + +test('supportMinutes: Hälfte, aufgerundet auf 15', () => { + const cases = [[15, 15], [30, 15], [45, 30], [60, 30], [75, 45], [70, 45], [0, 0], [-5, 0], ['60', 30], [null, 0]] + for (const [total, expected] of cases) assert.equal(supportMinutes(total), expected, `${total}`) +}) + +test('parseSupporters ist fehlertolerant', () => { + assert.deepEqual(parseSupporters('kaputt'), []) + assert.deepEqual(parseSupporters('{}'), []) + assert.deepEqual(parseSupporters(null), []) + assert.deepEqual(parseSupporters(''), []) + const raw = '[{"employeeId":"a","name":"Andrej S","task":" Farbe "},{"employeeId":"a","name":"x","task":"y"},{"employeeId":"b","name":"Nico"},null,"x"]' + assert.deepEqual(parseSupporters(raw), [{ employeeId: 'a', name: 'Andrej S', task: 'Farbe' }]) + assert.equal(parseSupporters([{ employeeId: 'a', name: 'A', task: 'x'.repeat(250) }])[0].task.length, 200) +}) + +test('serializeSupporters und parseSupporters passen zusammen', () => { + assert.deepEqual(parseSupporters(serializeSupporters([andrej, nico])), [andrej, nico]) + assert.equal(serializeSupporters([]), '[]') +}) + +test('worksheetMinutes: Arbeitszeit plus je Person die Unterstützung', () => { + assert.equal(worksheetMinutes({ totalTime: 60, supporters: serializeSupporters([andrej, nico]) }), 120) + assert.equal(worksheetMinutes({ totalTime: 70, supporters: serializeSupporters([andrej]) }), 115) + assert.equal(worksheetMinutes({ totalTime: 45 }), 45) + assert.equal(worksheetMinutes({ totalTime: 0, supporters: serializeSupporters([andrej]) }), 0) + assert.equal(worksheetMinutes({ totalTime: 60, supporters: 'kaputt' }), 60) +}) + +test('firstName', () => { + assert.equal(firstName('Andrej Stevanovski'), 'Andrej') + assert.equal(firstName(' Nico '), 'Nico') + assert.equal(firstName(''), '') +}) + +test('canAddOwnSupport: nur fremde Blätter mit Arbeitszeit', () => { + const kenso = { $id: 'kenso-admin', name: 'Kenso Grimm' } + const kensoEmp = { shortcode: 'KNSO', displayName: 'Kenso Grimm' } + const andrejUser = { $id: 'andrej', name: 'Andrej Stevanovski' } + const andrejEmp = { shortcode: 'ANDJ', displayName: 'Andrej Stevanovski' } + const fremd = { employeeId: 'justin', totalTime: 60, isComment: false, serviceType: 'Remote' } + assert.equal(canAddOwnSupport(fremd, kenso, kensoEmp), true) + assert.equal(canAddOwnSupport({ ...fremd, employeeId: 'kenso-admin' }, kenso, kensoEmp), false) + assert.equal(canAddOwnSupport({ ...fremd, isComment: true }, kenso, kensoEmp), false) + assert.equal(canAddOwnSupport({ ...fremd, totalTime: 0 }, kenso, kensoEmp), false) + assert.equal(canAddOwnSupport(fremd, null, null), false) + const gitOffen = { employeeId: 'gitea', employeeName: 'knso', serviceType: 'GIT', isComment: true, totalTime: 0 } + assert.equal(canAddOwnSupport(gitOffen, andrejUser, andrejEmp), false) + const gitFertig = { ...gitOffen, isComment: false, totalTime: 45, startTime: '1400' } + assert.equal(canAddOwnSupport(gitFertig, kenso, kensoEmp), false) + assert.equal(canAddOwnSupport(gitFertig, andrejUser, andrejEmp), true) +}) + +test('upsertSupporter und removeSupporter', () => { + const list = upsertSupporter([andrej], nico) + assert.deepEqual(list, [andrej, nico]) + assert.deepEqual(upsertSupporter(list, { ...andrej, task: 'Logo' }), [{ ...andrej, task: 'Logo' }, nico]) + assert.deepEqual(removeSupporter(list, 'a'), [nico]) + assert.deepEqual(removeSupporter(list, 'x'), list) +}) + +test('validateSupporters', () => { + assert.equal(validateSupporters([andrej, nico], 'justin'), null) + assert.equal(validateSupporters([], 'justin'), null) + assert.equal(validateSupporters([{ ...andrej, task: ' ' }], 'justin'), 'Bitte bei jeder Person angeben, wobei sie unterstützt hat.') + assert.equal(validateSupporters([{ ...andrej, employeeId: '' }], 'justin'), 'Bitte in jeder Zeile eine Person auswählen.') + assert.equal(validateSupporters([andrej, { ...andrej, task: 'x' }], 'justin'), 'Jede Person nur einmal eintragen.') + assert.equal(validateSupporters([andrej], 'a'), 'Du kannst dich nicht selbst als Unterstützung eintragen.') +})