Push-Zeitmail: Zeit nur in 15-Minuten-Schritten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7da4452bbd
commit
88854aa2b4
4 files changed
+33
-14
No files matched your search
@@ -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.',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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' */
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user