Mobile-Update + Bericht
release-tag / release-image (push) Successful in 2m6s

This commit is contained in:
2026-08-14 18:17:38 +02:00
parent 2d384046dd
commit 57f7470310
9 changed files with 608 additions and 16 deletions
+88
View File
@@ -138,6 +138,7 @@ button { border: 0; cursor: pointer; }
.export-compact { align-self: end; min-height: 42px; padding-bottom: 9px; }
.export-preview { margin: -2px 0 0; padding: 10px 12px; border-radius: 11px; background: var(--bg); border: 1px solid var(--border); color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.export-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.service-report-button { grid-column: 1 / -1; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.toast { position: fixed; z-index: 30; bottom: 24px; left: 50%; transform: translateX(-50%); background: #25292e; border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; box-shadow: var(--shadow); font-size: 13px; }
@@ -198,3 +199,90 @@ button { border: 0; cursor: pointer; }
.history-actions .btn { padding-inline: 10px; }
.auth-card { padding: 23px; }
}
/* Mobile compact view: timer first, overview as a separate screen. */
.mobile-view-nav, .mobile-compact-only { display: none; }
@media (max-width: 820px) {
body.mobile-compact .topbar { display: none; }
body.mobile-compact .app-shell { height: 100dvh; }
body.mobile-compact .main-grid { min-height: 0; flex: 1 1 auto; }
body.mobile-compact .tracker-pane,
body.mobile-compact .history-pane { height: 100%; min-height: 0; }
body.mobile-compact .tracker-pane { padding: 15px; gap: 14px; }
body.mobile-compact .mobile-pane-head,
body.mobile-compact .sidebar-footer { display: none; }
body.mobile-compact .tracker-card {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 18px;
padding: 24px 26px;
border-radius: 28px;
}
body.mobile-compact .tracker-date { font-size: 15px; }
body.mobile-compact .field-label { gap: 10px; font-size: 15px; }
body.mobile-compact .tracker-card input,
body.mobile-compact .tracker-card textarea { border-radius: 18px; padding: 15px 16px; font-size: 16px; }
body.mobile-compact .tracker-card input { min-height: 58px; }
body.mobile-compact .activity-field { display: flex; flex: 1 1 180px; min-height: 0; flex-direction: column; }
body.mobile-compact .activity-field textarea { flex: 1 1 auto; min-height: 120px; resize: none; }
body.mobile-compact .timer-display { padding-top: 4px; font-size: clamp(40px, 11vw, 58px); }
body.mobile-compact .timer-btn { min-height: 64px; border-radius: 18px; font-size: 18px; }
body.mobile-compact .metric-grid { gap: 12px; }
body.mobile-compact .metric-card { min-height: 92px; justify-content: center; border-radius: 22px; padding: 18px 20px; }
body.mobile-compact .metric-card span { font-size: 13px; }
body.mobile-compact .metric-card strong { font-size: 24px; }
body.mobile-compact .history-title .mobile-only { display: none; }
body.mobile-compact .history-actions #history-export { display: inline-block; }
body.mobile-compact .mobile-view-nav {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 6px 10px calc(6px + env(safe-area-inset-bottom));
background: rgba(17, 19, 22, .98);
border-top: 1px solid var(--divider);
}
body.mobile-compact .mobile-nav-button {
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 13px;
background: transparent;
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
body.mobile-compact .mobile-nav-button:hover { background: var(--raised); color: var(--text); }
body.mobile-compact .mobile-nav-button.active { background: var(--raised); color: var(--amber); }
body.mobile-compact .mobile-nav-icon { font-size: 18px; line-height: 1; }
body.mobile-compact .mobile-compact-only { display: inline-flex; }
}
@media (max-width: 430px) {
body.mobile-compact .tracker-card { padding: 20px; gap: 15px; border-radius: 24px; }
body.mobile-compact .tracker-card input,
body.mobile-compact .tracker-card textarea { padding: 13px 14px; }
body.mobile-compact .tracker-card input { min-height: 52px; }
body.mobile-compact .timer-btn { min-height: 58px; }
body.mobile-compact .metric-card { min-height: 82px; padding: 14px 16px; }
body.mobile-compact .mobile-nav-button { gap: 5px; }
}
@media (max-height: 720px) and (max-width: 820px) {
body.mobile-compact .tracker-pane { gap: 9px; padding: 9px; }
body.mobile-compact .tracker-card { gap: 10px; padding: 15px; border-radius: 20px; }
body.mobile-compact .tracker-card input { min-height: 44px; padding-block: 10px; }
body.mobile-compact .activity-field textarea { min-height: 80px; padding-block: 10px; }
body.mobile-compact .timer-display { font-size: 36px; }
body.mobile-compact .timer-btn { min-height: 48px; }
body.mobile-compact .metric-card { min-height: 66px; padding: 10px 13px; border-radius: 16px; }
body.mobile-compact .mobile-nav-button { min-height: 42px; }
}
+72 -5
View File
@@ -14,6 +14,23 @@ const state = {
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
function applyMobileCompactPreference(enabled = true) {
document.body.classList.toggle('mobile-compact', !!enabled);
const checkbox = $('#setting-mobile-compact');
if (checkbox) checkbox.checked = !!enabled;
syncMobileNav();
}
function switchMobileView(view) {
document.body.classList.toggle('mobile-history', view === 'history');
syncMobileNav();
}
function syncMobileNav() {
const history = document.body.classList.contains('mobile-history');
const track = $('#mobile-nav-track'), overview = $('#mobile-nav-history');
if (track) { track.classList.toggle('active', !history); if (!history) track.setAttribute('aria-current','page'); else track.removeAttribute('aria-current'); }
if (overview) { overview.classList.toggle('active', history); if (history) overview.setAttribute('aria-current','page'); else overview.removeAttribute('aria-current'); }
}
async function api(url, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body !== undefined && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json';
@@ -103,6 +120,7 @@ async function init() {
const me = await api('/api/me'); state.me = me.user; state.csrf = me.csrf_token;
const [settings, running, clients] = await Promise.all([api('/api/settings'), api('/api/running'), api('/api/clients')]);
state.settings = settings; state.running = running.entry; state.clients = clients.clients || [];
applyMobileCompactPreference(state.settings.mobile_compact !== false);
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if ((!state.settings.timezone || state.settings.timezone === 'UTC') && tz && tz !== 'UTC') {
state.settings.timezone = tz;
@@ -210,9 +228,9 @@ function updateEditDuration(){const s=msFromLocalValue($('#edit-start').value),e
async function saveEntry(ev){ev.preventDefault();hideError('#entry-error');const id=$('#entry-id').value;const start=msFromLocalValue($('#edit-start').value),end=msFromLocalValue($('#edit-end').value);if(!start||!end||end<start){showError('#entry-error',new Error('Bitte einen gültigen Start- und Endzeitpunkt wählen.'));return}const body={client:$('#edit-client').value,activity:$('#edit-activity').value,start_ms:start,end_ms:end};try{if(id)await api(`/api/entries/${id}`,{method:'PUT',body:JSON.stringify(body)});else await api('/api/entries',{method:'POST',body:JSON.stringify(body)});$('#entry-dialog').close();if(state.running?.id===id){state.running=null;renderRunning()}await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);toast('Eintrag gespeichert.')}catch(e){showError('#entry-error',e)}}
async function deleteEntry(){const id=$('#entry-id').value;if(!id||!confirm('Diesen Eintrag wirklich löschen?'))return;try{await api(`/api/entries/${id}`,{method:'DELETE'});if(state.running?.id===id){state.running=null;renderRunning()}$('#entry-dialog').close();await Promise.all([refreshEntries(true),refreshTotals()]);toast('Eintrag gelöscht.')}catch(e){showError('#entry-error',e)}}
function fillSettings(){const s=state.settings;$('#setting-time-format').value=s.time_format;$('#setting-rounding').value=s.rounding_minutes;$('#setting-round-up').checked=s.round_up;$('#setting-week').checked=s.show_week_total;$('#setting-sticky').checked=s.sticky_days;$('#setting-reminder').checked=s.long_run_reminder;$('#setting-export-name').value=s.export_name||'';$('#setting-timezone').value=s.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||'UTC';$('#setting-export-date').checked=s.export_date;$('#admin-section').hidden=state.me.role!=='admin'}
function fillSettings(){const s=state.settings;$('#setting-time-format').value=s.time_format;$('#setting-rounding').value=s.rounding_minutes;$('#setting-round-up').checked=s.round_up;$('#setting-week').checked=s.show_week_total;$('#setting-sticky').checked=s.sticky_days;$('#setting-reminder').checked=s.long_run_reminder;$('#setting-mobile-compact').checked=s.mobile_compact!==false;$('#setting-export-name').value=s.export_name||'';$('#setting-timezone').value=s.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||'UTC';$('#setting-export-date').checked=s.export_date;$('#admin-section').hidden=state.me.role!=='admin'}
async function openSettings(){fillSettings();hideError('#settings-error');$('#settings-dialog').showModal();if(state.me.role==='admin')await loadUsers()}
async function saveSettings(ev){ev.preventDefault();hideError('#settings-error');const s={language:state.settings.language||'de',time_format:$('#setting-time-format').value,rounding_minutes:Number($('#setting-rounding').value),round_up:$('#setting-round-up').checked,show_week_total:$('#setting-week').checked,sticky_days:$('#setting-sticky').checked,long_run_reminder:$('#setting-reminder').checked,export_name:$('#setting-export-name').value,timezone:$('#setting-timezone').value,export_date:$('#setting-export-date').checked};try{state.settings=await api('/api/settings',{method:'PUT',body:JSON.stringify(s)});$('#settings-dialog').close();renderEntries();await refreshTotals();toast('Einstellungen gespeichert.')}catch(e){showError('#settings-error',e)}}
async function saveSettings(ev){ev.preventDefault();hideError('#settings-error');const s={language:state.settings.language||'de',time_format:$('#setting-time-format').value,rounding_minutes:Number($('#setting-rounding').value),round_up:$('#setting-round-up').checked,show_week_total:$('#setting-week').checked,sticky_days:$('#setting-sticky').checked,long_run_reminder:$('#setting-reminder').checked,mobile_compact:$('#setting-mobile-compact').checked,export_name:$('#setting-export-name').value,timezone:$('#setting-timezone').value,export_date:$('#setting-export-date').checked};try{state.settings=await api('/api/settings',{method:'PUT',body:JSON.stringify(s)});applyMobileCompactPreference(state.settings.mobile_compact!==false);$('#settings-dialog').close();renderEntries();await refreshTotals();toast('Einstellungen gespeichert.')}catch(e){showError('#settings-error',e)}}
async function changeOwnPassword(ev){
@@ -280,20 +298,69 @@ function downloadExport(type){
a.href=`/api/export.${type}${p.toString()?'?'+p.toString():''}`; a.click();
}
function serviceFilterPayload(){
const p=exportParams(false);
return {q:p.get('q')||'',from_ms:Number(p.get('from')||0),to_ms:Number(p.get('to')||0)};
}
let servicePreviewSeq=0;
async function updateServicePreview(){
const seq=++servicePreviewSeq, client=$('#service-client').value.trim();
if(!client){$('#service-preview').textContent='Bitte einen Kunden auswählen.';return;}
try{
const p=exportParams(false);p.set('client',client);p.set('limit','1');p.set('offset','0');
const result=await api('/api/entries?'+p.toString());
if(seq===servicePreviewSeq) $('#service-preview').textContent=result.total_count?`${result.total_count} Tätigkeiten · ${duration(result.total_duration_ms)}`:'Keine abgeschlossenen Tätigkeiten für Kunde/Zeitraum.';
}catch(e){if(seq===servicePreviewSeq) $('#service-preview').textContent=e.message;}
}
function openServiceReport(){
hideError('#service-error');
if(($('input[name="export-scope"]:checked')?.value||'view')==='custom'){
const from=localDayStartMS($('#export-from').value),to=localDayStartMS($('#export-to').value);
if(!from||!to||to<from){$('#export-preview').textContent='Bitte einen gültigen Zeitraum wählen.';return;}
}
let client=$('#client-input').value.trim();
const visible=[...new Set(state.entries.map(e=>(e.client||'').trim()).filter(Boolean))];
if(!client&&visible.length===1) client=visible[0];
if(!client&&state.filter.q){const exact=state.clients.find(x=>x.toLowerCase()===state.filter.q.toLowerCase());if(exact)client=exact;}
$('#service-client').value=client;
if(!$('#service-date').value) $('#service-date').value=dateInputValue(new Date());
$('#export-dialog').close();
$('#service-report-dialog').showModal();
updateServicePreview();
}
async function downloadServiceReport(ev){
ev.preventDefault();hideError('#service-error');
const submit=$('#service-report-form button[type="submit"]');submit.disabled=true;
const body={...serviceFilterPayload(),client:$('#service-client').value.trim(),contact:$('#service-contact').value.trim(),location:$('#service-location').value.trim(),order_number:$('#service-order').value.trim(),subject:$('#service-subject').value.trim(),notes:$('#service-notes').value.trim(),place:$('#service-place').value.trim(),report_date:$('#service-date').value};
try{
const res=await fetch('/api/service-report.pdf',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':state.csrf},body:JSON.stringify(body)});
if(res.status===401){location.assign('/login');return;}
if(!res.ok){const ct=res.headers.get('content-type')||'';const x=ct.includes('application/json')?await res.json().catch(()=>({})):await res.text();throw new Error(x?.error?.message||x||`HTTP ${res.status}`);}
const blob=await res.blob(),url=URL.createObjectURL(blob),a=document.createElement('a');
a.href=url;a.download='service-bericht.pdf';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);
$('#service-report-dialog').close();toast('Service-Bericht erstellt.');
}catch(e){showError('#service-error',e);}finally{submit.disabled=false;}
}
async function logout() {
if(!confirm('Abmelden?')) return;
try { await api('/api/logout',{method:'POST',body:'{}'}); } finally { location.assign('/login'); }
}
function wireEvents(){
$('#timer-button').addEventListener('click',timerToggle); $('#activity-input').addEventListener('blur',()=>saveRunningActivity().catch(e=>toast(e.message)));
$('#edit-running').addEventListener('click',()=>openEntry(null,true)); $('#add-entry').addEventListener('click',()=>openEntry());
$('#entry-form').addEventListener('submit',saveEntry); $('#delete-entry').addEventListener('click',deleteEntry); $('#edit-start').addEventListener('input',updateEditDuration); $('#edit-end').addEventListener('input',updateEditDuration);
$('#settings-open').addEventListener('click',openSettings); $('#settings-form').addEventListener('submit',saveSettings); $('#change-own-password').addEventListener('click',()=>{hideError('#account-password-error');$('#account-password-form').reset();$('#account-password-dialog').showModal()}); $('#account-password-form').addEventListener('submit',changeOwnPassword); $('#add-user').addEventListener('click',()=>{hideError('#user-error');$('#user-dialog').showModal()}); $('#user-form').addEventListener('submit',createUser); $('#password-form').addEventListener('submit',resetPassword);
$('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview);
$('#account-menu').addEventListener('click',async()=>{if(!confirm('Abmelden?'))return;try{await api('/api/logout',{method:'POST',body:'{}'});}finally{location.assign('/login')}});
$('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $('#export-service').addEventListener('click',openServiceReport); $('#service-report-form').addEventListener('submit',downloadServiceReport); let serviceClientTimer; $('#service-client').addEventListener('input',()=>{clearTimeout(serviceClientTimer);serviceClientTimer=setTimeout(updateServicePreview,220)}); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview);
$('#account-menu').addEventListener('click',logout); $('#mobile-logout').addEventListener('click',logout);
let searchTimer; $('#search-input').addEventListener('input',e=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{state.filter.q=e.target.value.trim();state.filter.offset=0;refreshEntries(true)},220)});
$$('.period-tabs button').forEach(b=>b.addEventListener('click',()=>{$$('.period-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.filter.period=b.dataset.period;state.filter.anchor=new Date();state.filter.offset=0;refreshEntries(true)}));
$('#period-prev').addEventListener('click',()=>stepPeriod(-1)); $('#period-next').addEventListener('click',()=>stepPeriod(1));
$('#load-more').addEventListener('click',()=>{state.filter.offset=state.entries.length;refreshEntries(false)});
$$('[data-close]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.close).close()));
$$('dialog').forEach(d=>d.addEventListener('click',e=>{const r=d.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)d.close()}));
$('#mobile-history').addEventListener('click',()=>document.body.classList.add('mobile-history')); $('#mobile-track').addEventListener('click',()=>document.body.classList.remove('mobile-history'));
$('#mobile-history').addEventListener('click',()=>switchMobileView('history')); $('#mobile-track').addEventListener('click',()=>switchMobileView('track')); $('#mobile-nav-track').addEventListener('click',()=>switchMobileView('track')); $('#mobile-nav-history').addEventListener('click',()=>switchMobileView('history')); $('#mobile-nav-settings').addEventListener('click',openSettings);
}
wireEvents(); init();
+35 -4
View File
@@ -8,7 +8,7 @@
<title>Pocketwatch</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<body class="mobile-compact">
<div class="app-shell">
<header class="topbar">
<div class="brand"><span class="brand-dot"></span><span>pocketwatch</span></div>
@@ -29,7 +29,7 @@
<input id="client-input" list="client-list" maxlength="200" placeholder="Kunde / Projekt">
<datalist id="client-list"></datalist>
</label>
<label class="field-label">Tätigkeit
<label class="field-label activity-field">Tätigkeit
<textarea id="activity-input" rows="4" maxlength="4000" placeholder="Woran arbeitest du?"></textarea>
</label>
<div class="timer-display" id="timer-display">00:00:00</div>
@@ -69,6 +69,12 @@
<footer class="history-footer"><span><strong id="result-count">0</strong> Einträge</span><span>Gesamt <strong id="result-total">0:00</strong></span></footer>
</section>
</main>
<nav class="mobile-view-nav" aria-label="Mobile Ansicht">
<button type="button" id="mobile-nav-track" class="mobile-nav-button active" aria-current="page"><span class="mobile-nav-icon">◷</span><span>Timer</span></button>
<button type="button" id="mobile-nav-history" class="mobile-nav-button"><span class="mobile-nav-icon">☷</span><span>Übersicht</span></button>
<button type="button" id="mobile-nav-settings" class="mobile-nav-button"><span class="mobile-nav-icon">⚙</span><span>Mehr</span></button>
</nav>
</div>
<dialog id="entry-dialog" class="modal">
@@ -96,6 +102,7 @@
<label class="check-row"><input id="setting-week" type="checkbox"><span>Wochensumme anzeigen</span></label>
<label class="check-row"><input id="setting-sticky" type="checkbox"><span>Tagesüberschriften fixieren</span></label>
<label class="check-row"><input id="setting-reminder" type="checkbox"><span>Warnung bei langem Timer</span></label>
<label class="check-row"><input id="setting-mobile-compact" type="checkbox"><span>Kompaktansicht auf Mobilgeräten</span></label>
</section>
<section>
<h3>Export</h3>
@@ -111,7 +118,7 @@
<div id="user-list" class="user-list"></div>
</section>
<p id="settings-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="settings-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
<div class="modal-actions"><button type="button" id="mobile-logout" class="btn danger mobile-compact-only">Abmelden</button><div class="spacer"></div><button type="button" class="btn subtle" data-close="settings-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
</form>
</dialog>
@@ -164,10 +171,34 @@
<label class="check-row export-compact"><input id="export-compact" type="checkbox"><span>Nur Zeiten (ohne Tätigkeit)</span></label>
</div>
<p id="export-preview" class="export-preview">0 Einträge · 0:00</p>
<div class="export-buttons"><button id="export-pdf" class="btn primary">PDF herunterladen</button><button id="export-csv" class="btn subtle">CSV herunterladen</button></div>
<div class="export-buttons"><button id="export-service" class="btn subtle service-report-button">Service-Bericht für Unterschrift</button><button id="export-pdf" class="btn primary">PDF herunterladen</button><button id="export-csv" class="btn subtle">CSV herunterladen</button></div>
</div>
</dialog>
<dialog id="service-report-dialog" class="modal modal-wide">
<form id="service-report-form" class="modal-card">
<div class="modal-head"><div><p class="eyebrow">SERVICE</p><h2>Service-Bericht erstellen</h2></div><button type="button" class="icon-btn" data-close="service-report-dialog">×</button></div>
<p class="muted">Der Bericht übernimmt den Zeitraum aus dem Export und enthält nur abgeschlossene Tätigkeiten des ausgewählten Kunden.</p>
<div class="two-col">
<label>Kunde *<input id="service-client" list="client-list" maxlength="200" required placeholder="Kunde / Projekt"></label>
<label>Ansprechpartner<input id="service-contact" maxlength="200" placeholder="Name beim Kunden"></label>
</div>
<div class="two-col">
<label>Einsatzort<input id="service-location" maxlength="400" placeholder="Standort / Adresse"></label>
<label>Auftrag / Ticket<input id="service-order" maxlength="120" placeholder="z. B. SR-2026-0042"></label>
</div>
<label>Betreff<input id="service-subject" maxlength="200" value="Service-Bericht"></label>
<label>Zusammenfassung / Bemerkungen<textarea id="service-notes" rows="4" maxlength="6000" placeholder="Optional: Ergebnis, Hinweise, offene Punkte …"></textarea></label>
<div class="two-col">
<label>Ort<input id="service-place" maxlength="120" placeholder="Ort der Unterschrift"></label>
<label>Berichtsdatum<input id="service-date" type="date" required></label>
</div>
<p id="service-preview" class="export-preview">Kunde und Zeitraum wählen.</p>
<p id="service-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="service-report-dialog">Abbrechen</button><button type="submit" class="btn primary">Service-Bericht PDF</button></div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/static/app.js" defer></script>
</body>