diff --git a/server/services/pushTimeEntry.js b/server/services/pushTimeEntry.js new file mode 100644 index 0000000..cf40bae --- /dev/null +++ b/server/services/pushTimeEntry.js @@ -0,0 +1,75 @@ +import { config } from '../config.js' +import { withLocks } from './keyedLock.js' +import { getDocument, updateDocument } from './appwriteAdmin.js' + +/** + * Zeit fuer ein Git-Push-Arbeitsblatt eintragen (Push-Zeitmail). Ergebnis wie "Zeit nachtragen" in + * WOMS: Startzeit = Push-Zeit (Endzeit) minus Dauer, totalTime = Minuten, isComment = false. + * Eine schon eingetragene Zeit wird nie ueberschrieben - Aendern geht nur in WOMS. + */ + +export const MAX_MINUTES = 720 + +const pad = (n) => String(n).padStart(2, '0') + +/** Wand-Uhrzeit rueckwaerts rechnen ('tt.mm.jjjj', 'hhmm'); reine Kalenderrechnung, ohne Zeitzone. */ +export function startFromEnd(endDate, endTime, minutes) { + const [day, month, year] = String(endDate).split('.').map(Number) + const hours = Number(String(endTime).slice(0, 2)) + const mins = Number(String(endTime).slice(2, 4)) + const start = new Date(Date.UTC(year, month - 1, day, hours, mins) - minutes * 60 * 1000) + return { + startDate: `${pad(start.getUTCDate())}.${pad(start.getUTCMonth() + 1)}.${start.getUTCFullYear()}`, + startTime: `${pad(start.getUTCHours())}${pad(start.getUTCMinutes())}`, + } +} + +/** Git-Push ohne eingetragene Zeit (wie needsTimeEntry in WOMS) */ +export function needsTimeEntry(ws) { + return ws?.serviceType === 'GIT' && !(ws.startTime && Number(ws.totalTime) > 0) +} + +/** Ganze Minuten 1-720 aus Formular/Query, sonst null */ +export function parseMinutes(raw) { + if (raw === null || raw === undefined) return null + const text = String(raw).trim() + if (!/^\d+$/.test(text)) return null + const value = Number(text) + return value >= 1 && value <= MAX_MINUTES ? value : null +} + +/** 15 -> '15 Min', 60 -> '1 Std', 90 -> '1,5 Std', 130 -> '2 Std 10 Min' */ +export function formatMinutes(min) { + const total = Number(min) || 0 + if (total < 60) return `${total} Min` + const hours = Math.floor(total / 60) + const rest = total % 60 + if (rest === 0) return `${hours} Std` + if (rest === 30) return `${hours},5 Std` + return `${hours} Std ${rest} Min` +} + +/** + * @returns {Promise<{ status: 'saved' | 'already' | 'missing' | 'not_git', worksheet?: object }>} + */ +export async function applyPushTime(worksheetId, minutes) { + return withLocks([`ws-time:${worksheetId}`], async () => { + let ws + try { + ws = await getDocument(config.collections.worksheets, worksheetId) + } catch { + return { status: 'missing' } + } + if (ws.serviceType !== 'GIT') return { status: 'not_git', worksheet: ws } + if (!needsTimeEntry(ws)) return { status: 'already', worksheet: ws } + + const { startDate, startTime } = startFromEnd(ws.endDate, ws.endTime, minutes) + const worksheet = await updateDocument(config.collections.worksheets, worksheetId, { + startDate, + startTime, + totalTime: minutes, + isComment: false, + }) + return { status: 'saved', worksheet } + }) +} diff --git a/test/pushTimeEntry.test.js b/test/pushTimeEntry.test.js new file mode 100644 index 0000000..a3324ba --- /dev/null +++ b/test/pushTimeEntry.test.js @@ -0,0 +1,74 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let fake +let startFromEnd, needsTimeEntry, parseMinutes, formatMinutes, applyPushTime + +const openGit = (id) => ({ + $id: id, wsid: '100200', woid: '100118', serviceType: 'GIT', + endDate: '30.09.2026', endTime: '1646', startDate: '30.09.2026', startTime: '', totalTime: 0, isComment: true, +}) + +before(async () => { + fake = await startFakeAppwrite({ + seed: { + worksheets: [ + openGit('ws1'), + openGit('ws-race'), + { $id: 'remote1', serviceType: 'Remote', startTime: '', totalTime: 0 }, + ], + }, + }) + Object.assign(process.env, { APPWRITE_ENDPOINT: fake.endpoint, APPWRITE_API_KEY: 'test-key' }) + ;({ startFromEnd, needsTimeEntry, parseMinutes, formatMinutes, applyPushTime } = await import( + '../server/services/pushTimeEntry.js' + )) +}) + +after(async () => { + await fake.close() +}) + +test('startFromEnd: normal, über Mitternacht, Jahres- und Schalttag-Grenze', () => { + assert.deepEqual(startFromEnd('30.09.2026', '1646', 60), { startDate: '30.09.2026', startTime: '1546' }) + assert.deepEqual(startFromEnd('01.10.2026', '0020', 45), { startDate: '30.09.2026', startTime: '2335' }) + assert.deepEqual(startFromEnd('01.01.2027', '0010', 30), { startDate: '31.12.2026', startTime: '2340' }) + assert.deepEqual(startFromEnd('01.03.2028', '0000', 1), { startDate: '29.02.2028', startTime: '2359' }) +}) + +test('needsTimeEntry', () => { + assert.equal(needsTimeEntry({ serviceType: 'GIT', startTime: '', totalTime: 0 }), true) + assert.equal(needsTimeEntry({ serviceType: 'GIT', startTime: '1500', totalTime: 45 }), false) + assert.equal(needsTimeEntry({ serviceType: 'Remote', startTime: '', totalTime: 0 }), false) +}) + +test('parseMinutes: ganze Minuten 1–720', () => { + assert.equal(parseMinutes('45'), 45) + assert.equal(parseMinutes(720), 720) + for (const bad of ['1.5', '0', '721', '', 'abc', undefined, null, '-5', '45abc']) { + assert.equal(parseMinutes(bad), null, String(bad)) + } +}) + +test('formatMinutes', () => { + assert.equal(formatMinutes(15), '15 Min') + assert.equal(formatMinutes(60), '1 Std') + assert.equal(formatMinutes(90), '1,5 Std') + assert.equal(formatMinutes(130), '2 Std 10 Min') +}) + +test('applyPushTime: speichert einmal, überschreibt nie', async () => { + assert.equal((await applyPushTime('ws1', 60)).status, 'saved') + const ws = fake.get('worksheets', 'ws1') + assert.deepEqual([ws.startDate, ws.startTime, ws.totalTime, ws.isComment], ['30.09.2026', '1546', 60, false]) + assert.equal((await applyPushTime('ws1', 30)).status, 'already') + assert.equal(fake.get('worksheets', 'ws1').totalTime, 60) + assert.equal((await applyPushTime('gibtsnicht', 30)).status, 'missing') + assert.equal((await applyPushTime('remote1', 30)).status, 'not_git') +}) + +test('gleichzeitig: nur einer speichert', async () => { + const results = await Promise.all([applyPushTime('ws-race', 30), applyPushTime('ws-race', 90)]) + assert.deepEqual(results.map((r) => r.status).sort(), ['already', 'saved']) +})