Unterstützung: Rechenregeln für 15-Minuten-Raster und Unterstützungszeit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 14:47:36 +00:00
1 parent 564fecdde4
commit 1497f7a4dc
4 files changed
+239 -1

No files matched your search

@@ -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
+85
View File
@@ -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
}
+39
View File
@@ -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
}
+114
View File
@@ -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.')
})