Push-Zeitmail: Zeit nur in 15-Minuten-Schritten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 15:09:12 +00:00
1 parent 7da4452bbd
commit 88854aa2b4
4 files changed
+33 -14

No files matched your search

+15 -5
View File
@@ -3,7 +3,7 @@ import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { escapeHtml } from '../services/mailTemplates.js'
import { verifyPushTimeToken } from '../services/pushTimeToken.js'
import { applyPushTime, formatMinutes, needsTimeEntry, parseMinutes, MAX_MINUTES } from '../services/pushTimeEntry.js'
import { applyPushTime, formatMinutes, needsTimeEntry, parseMinutes, MAX_MINUTES, TIME_STEP } from '../services/pushTimeEntry.js'
/**
* Bestaetigungsseite der Push-Zeitmail: /zeit/<token>[?min=60].
@@ -84,13 +84,13 @@ dt{color:#6b6964;margin-top:8px}
dd{margin:0}
ul{margin:4px 0 0;padding-left:18px;font-family:Menlo,Consolas,monospace;font-size:13px}
label{display:block;font-weight:bold;margin:22px 0 6px}
input[type=number]{width:100%;font-size:20px;padding:12px;border:1px solid #d9d6ce;border-radius:10px}
select{width:100%;font-size:20px;padding:12px;border:1px solid #d9d6ce;border-radius:10px;background:#fff;color:inherit}
.err{background:#fdecec;color:#8a1c1c;border-radius:10px;padding:10px 12px;margin-top:16px;font-size:14px}
.ok{background:#e6f4ea;color:#1d5b2c;border-radius:10px;padding:10px 12px;margin-top:4px}
button,.btn{display:inline-block;margin-top:16px;padding:13px 22px;border-radius:10px;font-size:16px;font-weight:bold;text-decoration:none;cursor:pointer}
button{background:#111;color:#fff;border:0;width:100%}
.btn{background:#fff;color:#111;border:1px solid #d9d6ce}
@media (prefers-color-scheme:dark){body{background:#161615;color:#f2f1ee}.card{background:#1f1f1e}input[type=number]{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}button{background:#f2f1ee;color:#161615}.btn{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}.mut,dt{color:#a8a6a0}}
@media (prefers-color-scheme:dark){body{background:#161615;color:#f2f1ee}.card{background:#1f1f1e}select{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}button{background:#f2f1ee;color:#161615}.btn{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}.mut,dt{color:#a8a6a0}}
</style>
</head>
<body><main>
@@ -133,6 +133,16 @@ async function loadTicket(ws) {
}
}
// 15 Min bis 12 Std in 15er-Schritten; ohne gueltige Vorgabe ist 1 Std gewaehlt
function minuteOptions(selected) {
const chosen = parseMinutes(selected) ?? 60
const options = []
for (let m = TIME_STEP; m <= MAX_MINUTES; m += TIME_STEP) {
options.push(`<option value="${m}"${m === chosen ? ' selected' : ''}>${e(formatMinutes(m))}</option>`)
}
return options.join('')
}
function formPage(res, status, { ws, ticket, token, minutes, error }) {
const { repo, commits } = pushInfo(ws.details)
const woid = ticket?.woid || ws.woid
@@ -152,7 +162,7 @@ ${commits.length ? `<dt>Commits</dt><dd><ul>${commits.map((c) => `<li>${e(c)}</l
${error ? `<div class="err">${e(error)}</div>` : ''}
<form method="post" action="/zeit/${e(token)}">
<label for="minutes">Minuten</label>
<input id="minutes" type="number" name="minutes" min="1" max="${MAX_MINUTES}" step="1" inputmode="numeric" value="${e(minutes ?? '')}" required autofocus>
<select id="minutes" name="minutes" required autofocus>${minuteOptions(minutes)}</select>
<button type="submit">Speichern</button>
</form>
${openTicket(woid)}`
@@ -222,7 +232,7 @@ router.post('/:token', async (req, res) => {
...found,
token: req.params.token,
minutes: '',
error: `Bitte ganze Minuten zwischen 1 und ${MAX_MINUTES} eingeben.`,
error: 'Bitte eine Zeit in 15-Minuten-Schritten wählen.',
})
}
+5 -2
View File
@@ -37,13 +37,16 @@ export function needsTimeEntry(ws) {
return ws?.serviceType === 'GIT' && !(ws.startTime && Number(ws.totalTime) > 0)
}
/** Ganze Minuten 1-720 aus Formular/Query, sonst null */
/** Zeiten nur in 15-Minuten-Schritten (wie WOMS lib/timeSteps.js) */
export const TIME_STEP = 15
/** 15-720 in 15er-Schritten 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
return value >= TIME_STEP && value <= MAX_MINUTES && value % TIME_STEP === 0 ? value : null
}
/** 15 -> '15 Min', 60 -> '1 Std', 90 -> '1,5 Std', 130 -> '2 Std 10 Min' */
+3 -2
View File
@@ -44,10 +44,11 @@ test('needsTimeEntry', () => {
assert.equal(needsTimeEntry({ serviceType: 'Remote', startTime: '', totalTime: 0 }), false)
})
test('parseMinutes: ganze Minuten 1–720', () => {
test('parseMinutes: nur 15-Minuten-Schritte 15–720', () => {
assert.equal(parseMinutes('45'), 45)
assert.equal(parseMinutes('15'), 15)
assert.equal(parseMinutes(720), 720)
for (const bad of ['1.5', '0', '721', '', 'abc', undefined, null, '-5', '45abc']) {
for (const bad of ['1.5', '0', '721', '735', '70', '1', '', 'abc', undefined, null, '-5', '-15', '45abc']) {
assert.equal(parseMinutes(bad), null, String(bad))
}
})
+10 -5
View File
@@ -90,10 +90,15 @@ test('GET speichert nie', async () => {
assert.equal(ws.startTime, '')
})
test('GET mit min=60 füllt das Feld vor, Unsinn nicht', async () => {
test('GET: Auswahl in 15-Minuten-Schritten, min=90 vorausgewählt, sonst 1 Std', async () => {
const t = token('open1')
assert.match((await get(`/zeit/${t}?min=60`)).html, /name="minutes"[^>]*value="60"/)
assert.match((await get(`/zeit/${t}?min=abc`)).html, /name="minutes"[^>]*value=""/)
const html = (await get(`/zeit/${t}?min=90`)).html
assert.match(html, /<select id="minutes" name="minutes"/)
assert.equal((html.match(/<option /g) || []).length, 48)
assert.match(html, /<option value="90" selected>1,5 Std<\/option>/)
assert.doesNotMatch(html, /type="number"/)
assert.match((await get(`/zeit/${t}?min=abc`)).html, /<option value="60" selected>1 Std<\/option>/)
assert.match((await get(`/zeit/${t}?min=70`)).html, /<option value="60" selected>1 Std<\/option>/)
})
test('POST speichert', async () => {
@@ -107,10 +112,10 @@ test('POST speichert', async () => {
})
test('POST mit ungültigen Minuten', async () => {
for (const minutes of ['0', '721', '1.5', '']) {
for (const minutes of ['0', '721', '1.5', '', '70', '735']) {
const r = await post(`/zeit/${token('open3')}`, { minutes })
assert.equal(r.status, 400, minutes)
assert.match(r.html, /Bitte ganze Minuten zwischen 1 und 720 eingeben\./)
assert.match(r.html, /Bitte eine Zeit in 15-Minuten-Schritten wählen\./)
assert.match(r.html, /name="minutes"/)
}
assert.equal(fake.get('worksheets', 'open3').totalTime, 0)