Mobile-Update + Bericht
All checks were successful
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

View File

@@ -18,8 +18,10 @@ Die Anwendung besteht aus einem Go-Binary, eingebettetem HTML/CSS/Vanilla-JavaSc
- Rundung 160 Minuten, normal oder immer aufwärts
- CSV-Export (Semikolon + UTF-8 BOM für Excel)
- PDF-Export ohne PDF-Framework
- Service-Bericht als PDF pro Kunde mit Tätigkeitsübersicht, Auftrag/Ticket, Ansprechpartner, Einsatzort, Bemerkungen und Unterschriftsfeldern für Kunde/Techniker
- Export wahlweise für aktuelle Ansicht oder freien Datumsbereich, auf-/absteigend und kompakt
- Responsive Desktop-/Mobile-Oberfläche
- Mobile Kompaktansicht: startet mit dem Timer im Fokus; Verlauf/Übersicht und Einstellungen werden separat über die untere Navigation geöffnet
- Mehrbenutzerbetrieb mit strikt getrennten Daten
- Admin-/Benutzerrollen
- Benutzer anlegen, deaktivieren und Passwörter zurücksetzen
@@ -135,6 +137,35 @@ Schreibende API-Aufrufe benötigen zusätzlich ein zufälliges, sitzungsgebunden
Jede SQL-Operation auf Zeiten enthält die `user_id` aus der authentifizierten Session. IDs aus einem anderen Benutzerkonto reichen daher nicht aus, um fremde Einträge zu lesen oder zu verändern.
## Mobile Kompaktansicht
Auf Smartphones (bis 820 px Breite) ist standardmäßig die **Kompaktansicht** aktiv. Dabei verschwindet die Desktop-Kopfleiste und die Timer-Erfassung nutzt nahezu den gesamten verfügbaren Bildschirm. Kunde, Tätigkeit, Timer, Start/Stop und die Tages-/Wochensummen bleiben direkt sichtbar.
Die untere mobile Navigation trennt die Bereiche bewusst:
- **Timer** konzentrierte Zeiterfassung
- **Übersicht** Verlauf, Suche, Filter, Nachtragen und Export
- **Mehr** Einstellungen, Benutzerverwaltung (für Admins) und Abmelden
Die Einstellung **„Kompaktansicht auf Mobilgeräten“** kann pro Benutzer deaktiviert werden. Sie wird in SQLite in `user_settings.mobile_compact` gespeichert. Bestehende Datenbanken werden beim Start automatisch um die neue Spalte ergänzt; der Standardwert ist aktiviert.
## Service-Berichte
Im Exportdialog steht zusätzlich **„Service-Bericht für Unterschrift“** zur Verfügung. Der Bericht verwendet den gewählten Exportzeitraum und filtert anschließend exakt auf einen Kunden. Dadurch können keine Tätigkeiten anderer Kunden versehentlich in denselben unterschreibbaren Bericht geraten.
Der Service-Bericht enthält:
- Kunde und Ansprechpartner
- Einsatzort
- Auftrags-/Ticketnummer
- Betreff sowie optionale Zusammenfassung/Bemerkungen
- chronologische Tätigkeiten mit Datum, Uhrzeit und gerundeter Dauer
- Gesamtdauer
- Ort und Berichtsdatum
- getrennte Unterschriftsfelder für Kunde und Techniker
Die zusätzlichen Berichtsdaten werden per `POST /api/service-report.pdf` übertragen und nicht als Query-Parameter in der Download-URL abgelegt. Es werden keine Service-Berichte oder Unterschriften dauerhaft in SQLite gespeichert; der PDF-Export wird bei Bedarf erzeugt.
## Backup
Wegen WAL sollte die Datenbank nicht blind während Schreibzugriffen als einzelne Datei kopiert werden. Der einfachste konsistente Weg bei Docker Compose:

View File

@@ -29,6 +29,7 @@ type Settings struct {
ShowWeekTotal bool `json:"show_week_total"`
StickyDays bool `json:"sticky_days"`
LongRunReminder bool `json:"long_run_reminder"`
MobileCompact bool `json:"mobile_compact"`
ExportName string `json:"export_name"`
Timezone string `json:"timezone"`
ExportDate bool `json:"export_date"`
@@ -135,12 +136,49 @@ CREATE TABLE IF NOT EXISTS user_settings (
show_week_total INTEGER NOT NULL DEFAULT 1 CHECK(show_week_total IN (0,1)),
sticky_days INTEGER NOT NULL DEFAULT 1 CHECK(sticky_days IN (0,1)),
long_run_reminder INTEGER NOT NULL DEFAULT 1 CHECK(long_run_reminder IN (0,1)),
mobile_compact INTEGER NOT NULL DEFAULT 1 CHECK(mobile_compact IN (0,1)),
export_name TEXT NOT NULL DEFAULT '',
timezone TEXT NOT NULL DEFAULT 'UTC',
export_date INTEGER NOT NULL DEFAULT 1 CHECK(export_date IN (0,1))
);
`
_, err := s.db.ExecContext(ctx, schema)
if _, err := s.db.ExecContext(ctx, schema); err != nil {
return err
}
return s.ensureMobileCompactColumn(ctx)
}
// ensureMobileCompactColumn upgrades databases created before the mobile compact
// view existed. SQLite has no ADD COLUMN IF NOT EXISTS, so inspect the table first.
func (s *store) ensureMobileCompactColumn(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `PRAGMA table_info(user_settings)`)
if err != nil {
return err
}
found := false
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
rows.Close()
return err
}
if name == "mobile_compact" {
found = true
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
if found {
return nil
}
_, err = s.db.ExecContext(ctx, `ALTER TABLE user_settings ADD COLUMN mobile_compact INTEGER NOT NULL DEFAULT 1 CHECK(mobile_compact IN (0,1))`)
return err
}
@@ -286,14 +324,15 @@ func (s *store) resetPassword(ctx context.Context, id, hash string) error {
func (s *store) settings(ctx context.Context, userID string) (Settings, error) {
var x Settings
var up, week, sticky, reminder, exportDate int
err := s.db.QueryRowContext(ctx, `SELECT language,time_format,rounding_minutes,round_up,show_week_total,sticky_days,long_run_reminder,export_name,timezone,export_date FROM user_settings WHERE user_id=?`, userID).Scan(
&x.Language, &x.TimeFormat, &x.RoundingMinutes, &up, &week, &sticky, &reminder, &x.ExportName, &x.Timezone, &exportDate,
var up, week, sticky, reminder, mobileCompact, exportDate int
err := s.db.QueryRowContext(ctx, `SELECT language,time_format,rounding_minutes,round_up,show_week_total,sticky_days,long_run_reminder,mobile_compact,export_name,timezone,export_date FROM user_settings WHERE user_id=?`, userID).Scan(
&x.Language, &x.TimeFormat, &x.RoundingMinutes, &up, &week, &sticky, &reminder, &mobileCompact, &x.ExportName, &x.Timezone, &exportDate,
)
x.RoundUp = up == 1
x.ShowWeekTotal = week == 1
x.StickyDays = sticky == 1
x.LongRunReminder = reminder == 1
x.MobileCompact = mobileCompact == 1
x.ExportDate = exportDate == 1
return x, err
}
@@ -305,8 +344,8 @@ func (s *store) updateSettings(ctx context.Context, userID string, x Settings) e
}
return 0
}
_, err := s.db.ExecContext(ctx, `UPDATE user_settings SET language=?,time_format=?,rounding_minutes=?,round_up=?,show_week_total=?,sticky_days=?,long_run_reminder=?,export_name=?,timezone=?,export_date=? WHERE user_id=?`,
x.Language, x.TimeFormat, x.RoundingMinutes, boolInt(x.RoundUp), boolInt(x.ShowWeekTotal), boolInt(x.StickyDays), boolInt(x.LongRunReminder), strings.TrimSpace(x.ExportName), x.Timezone, boolInt(x.ExportDate), userID)
_, err := s.db.ExecContext(ctx, `UPDATE user_settings SET language=?,time_format=?,rounding_minutes=?,round_up=?,show_week_total=?,sticky_days=?,long_run_reminder=?,mobile_compact=?,export_name=?,timezone=?,export_date=? WHERE user_id=?`,
x.Language, x.TimeFormat, x.RoundingMinutes, boolInt(x.RoundUp), boolInt(x.ShowWeekTotal), boolInt(x.StickyDays), boolInt(x.LongRunReminder), boolInt(x.MobileCompact), strings.TrimSpace(x.ExportName), x.Timezone, boolInt(x.ExportDate), userID)
return err
}
@@ -381,6 +420,7 @@ func (s *store) deleteEntry(ctx context.Context, userID, id string) error {
type entryFilter struct {
Query string
Client string
FromMS int64
ToMS int64
Limit int
@@ -471,6 +511,10 @@ func buildEntryWhere(userID string, f entryFilter) (string, []any) {
where += ` AND start_ms<?`
args = append(args, f.ToMS)
}
if client := strings.TrimSpace(f.Client); client != "" {
where += ` AND client = ? COLLATE NOCASE`
args = append(args, client)
}
if q := strings.TrimSpace(f.Query); q != "" {
where += ` AND (client LIKE ? ESCAPE '\' COLLATE NOCASE OR activity LIKE ? ESCAPE '\' COLLATE NOCASE)`
q = "%" + escapeLike(q) + "%"

View File

@@ -124,6 +124,218 @@ func makePDF(entries []Entry, cfg Settings, owner User, compact bool) []byte {
return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc)
}
type serviceReportMeta struct {
Client string
Contact string
Location string
OrderNumber string
Subject string
Notes string
Place string
ReportDate string
}
func makeServiceReportPDF(entries []Entry, cfg Settings, owner User, meta serviceReportMeta) []byte {
loc := location(cfg.Timezone)
technician := strings.TrimSpace(cfg.ExportName)
if technician == "" {
technician = strings.TrimSpace(owner.DisplayName)
}
if technician == "" {
technician = owner.Username
}
if strings.TrimSpace(meta.Subject) == "" {
meta.Subject = "Service-Bericht"
}
if strings.TrimSpace(meta.ReportDate) == "" {
meta.ReportDate = time.Now().In(loc).Format("02.01.2006")
}
const pageW, pageH = 595.0, 842.0
var pages [][]pdfPageLine
var page []pdfPageLine
y := 795.0
add := func(x, yy, size float64, bold bool, text string) {
page = append(page, pdfPageLine{x, yy, size, bold, text})
}
addTableHeader := func() {
add(50, y, 8.5, true, "Datum")
add(108, y, 8.5, true, "Zeit")
add(184, y, 8.5, true, "Dauer")
add(244, y, 8.5, true, "Tätigkeit / Leistung")
y -= 10
add(50, y, 6.5, false, strings.Repeat("-", 104))
y -= 14
}
newPage := func(continuation, includeTable bool) {
if len(page) > 0 {
pages = append(pages, page)
}
page = []pdfPageLine{}
y = 795
title := "SERVICE-BERICHT"
if continuation {
title += " - FORTSETZUNG"
}
add(50, y, 18, true, title)
if strings.TrimSpace(meta.OrderNumber) != "" {
add(405, y+2, 8.5, true, "Auftrag / Ticket")
add(405, y-11, 9.5, false, clipRunes(meta.OrderNumber, 25))
}
y -= 30
if !continuation {
add(50, y, 8, true, "Kunde")
add(50, y-14, 10, false, clipRunes(meta.Client, 48))
add(310, y, 8, true, "Ansprechpartner")
add(310, y-14, 10, false, clipRunes(meta.Contact, 39))
y -= 42
add(50, y, 8, true, "Einsatzort")
add(50, y-14, 9.5, false, clipRunes(meta.Location, 48))
add(310, y, 8, true, "Berichtsdatum")
add(310, y-14, 9.5, false, meta.ReportDate)
y -= 42
add(50, y, 8, true, "Techniker")
add(50, y-14, 9.5, false, clipRunes(technician, 48))
add(310, y, 8, true, "Betreff")
add(310, y-14, 9.5, false, clipRunes(meta.Subject, 39))
y -= 38
}
if includeTable {
addTableHeader()
}
}
newPage(false, true)
var total int64
for _, e := range entries {
if e.EndMS == nil {
continue
}
start := time.UnixMilli(e.StartMS).In(loc)
end := time.UnixMilli(*e.EndMS).In(loc)
d := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
total += d
activity := strings.TrimSpace(e.Activity)
if activity == "" {
activity = "-"
}
activityLines := wrapPDFText(activity, 50)
rowLines := len(activityLines)
if rowLines < 1 {
rowLines = 1
}
rowHeight := float64(rowLines*12 + 10)
if y-rowHeight < 105 {
newPage(true, true)
}
add(50, y, 8.5, false, start.Format("02.01.06"))
add(108, y, 8.5, false, formatClock(start, cfg.TimeFormat)+"-"+formatClock(end, cfg.TimeFormat))
add(184, y, 8.5, false, formatDuration(d))
for i, line := range activityLines {
add(244, y-float64(i*12), 8.5, false, line)
}
y -= rowHeight - 4
add(50, y, 6, false, strings.Repeat("-", 104))
y -= 10
}
if y < 155 {
newPage(true, false)
}
add(392, y, 10, true, "Gesamtdauer")
add(493, y, 10, true, formatDuration(total))
y -= 30
if notes := strings.TrimSpace(meta.Notes); notes != "" {
if y < 145 {
newPage(true, false)
}
add(50, y, 9, true, "Zusammenfassung / Bemerkungen")
y -= 16
for _, line := range wrapPDFText(notes, 86) {
if y < 130 {
newPage(true, false)
}
add(50, y, 9, false, line)
y -= 13
}
y -= 12
}
// Reserve enough room so both signatures always stay together.
if y < 175 {
newPage(true, false)
}
add(50, y, 8.5, false, "Die aufgeführten Leistungen und Zeiten wurden erbracht und zur Kenntnis genommen.")
y -= 25
placeDate := strings.TrimSpace(meta.Place)
if placeDate != "" {
placeDate += ", "
}
placeDate += meta.ReportDate
add(50, y, 8, true, "Ort / Datum")
add(117, y, 8.5, false, clipRunes(placeDate, 65))
y -= 47
add(50, y, 9, false, "____________________________________")
add(330, y, 9, false, "____________________________________")
y -= 14
add(50, y, 8, true, "Unterschrift Kunde")
add(330, y, 8, true, "Unterschrift Techniker")
y -= 13
add(50, y, 7.5, false, clipRunes(meta.Contact, 40))
add(330, y, 7.5, false, clipRunes(technician, 40))
pages = append(pages, page)
return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc)
}
func wrapPDFText(s string, max int) []string {
if max < 1 {
max = 1
}
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
var out []string
for _, paragraph := range strings.Split(s, "\n") {
paragraph = strings.TrimSpace(paragraph)
if paragraph == "" {
out = append(out, "")
continue
}
words := strings.Fields(paragraph)
line := ""
for _, word := range words {
for len([]rune(word)) > max {
if line != "" {
out = append(out, line)
line = ""
}
r := []rune(word)
out = append(out, string(r[:max]))
word = string(r[max:])
}
candidate := word
if line != "" {
candidate = line + " " + word
}
if len([]rune(candidate)) > max && line != "" {
out = append(out, line)
line = word
} else {
line = candidate
}
}
if line != "" {
out = append(out, line)
}
}
if len(out) == 0 {
return []string{""}
}
return out
}
// buildSimplePDF writes a small, standards-compliant PDF using only built-in Type 1
// fonts. This avoids pulling a PDF framework into the server binary.
func buildSimplePDF(pageW, pageH float64, pages [][]pdfPageLine, exportDate bool, loc *time.Location) []byte {

View File

@@ -26,3 +26,30 @@ func TestCSVCompactOmitsActivity(t *testing.T) {
t.Fatal("compact CSV contains activity")
}
}
func TestServiceReportPDFContainsSignatureFields(t *testing.T) {
end := int64(3_600_000)
pdf := makeServiceReportPDF(
[]Entry{{ID: "x", Client: "ACME", Activity: "Router geprüft und Firmware aktualisiert", StartMS: 0, EndMS: &end}},
Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"},
User{DisplayName: "Max Techniker", Username: "max"},
serviceReportMeta{Client: "ACME", Contact: "Erika Kunde", OrderNumber: "SR-42", ReportDate: "14.08.2026"},
)
for _, want := range [][]byte{[]byte("%PDF-1.4"), []byte("SERVICE-BERICHT"), []byte("ACME"), []byte("Gesamtdauer"), []byte("Unterschrift Kunde"), []byte("Unterschrift Techniker")} {
if !bytes.Contains(pdf, want) {
t.Fatalf("service report missing %q", want)
}
}
}
func TestWrapPDFText(t *testing.T) {
lines := wrapPDFText("eins zwei drei vier", 9)
if len(lines) < 2 {
t.Fatalf("expected wrapped lines, got %#v", lines)
}
for _, line := range lines {
if len([]rune(line)) > 9 {
t.Fatalf("line too long: %q", line)
}
}
}

View File

@@ -0,0 +1,16 @@
package app
import (
"strings"
"testing"
)
func TestBuildEntryWhereExactClient(t *testing.T) {
where, args := buildEntryWhere("user-1", entryFilter{Client: " ACME "})
if !strings.Contains(where, "client = ? COLLATE NOCASE") {
t.Fatalf("exact client condition missing: %s", where)
}
if len(args) != 2 || args[0] != "user-1" || args[1] != "ACME" {
t.Fatalf("unexpected args: %#v", args)
}
}

View File

@@ -92,6 +92,7 @@ func (a *App) routes() {
api.HandleFunc("DELETE /api/entries/{id}", a.deleteEntry)
api.HandleFunc("GET /api/export.csv", a.exportCSV)
api.HandleFunc("GET /api/export.pdf", a.exportPDF)
api.HandleFunc("POST /api/service-report.pdf", a.serviceReportPDF)
api.HandleFunc("GET /api/admin/users", a.adminUsers)
api.HandleFunc("POST /api/admin/users", a.adminCreateUser)
api.HandleFunc("PATCH /api/admin/users/{id}", a.adminPatchUser)
@@ -505,6 +506,76 @@ func (a *App) exportPDF(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(b)
}
func (a *App) serviceReportPDF(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
var in struct {
Client string `json:"client"`
Contact string `json:"contact"`
Location string `json:"location"`
OrderNumber string `json:"order_number"`
Subject string `json:"subject"`
Notes string `json:"notes"`
Place string `json:"place"`
ReportDate string `json:"report_date"`
Query string `json:"q"`
FromMS int64 `json:"from_ms"`
ToMS int64 `json:"to_ms"`
}
if !decodeJSON(w, r, &in) {
return
}
in.Client = strings.TrimSpace(in.Client)
if in.Client == "" {
jsonError(w, 400, "client", "Bitte einen Kunden für den Service-Bericht auswählen.")
return
}
if len(in.Client) > 200 || len(in.Contact) > 200 || len(in.Location) > 400 || len(in.OrderNumber) > 120 || len(in.Subject) > 200 || len(in.Notes) > 6000 || len(in.Place) > 120 || len(in.Query) > 200 {
jsonError(w, 400, "too_long", "Ein Feld im Service-Bericht ist zu lang.")
return
}
if in.FromMS < 0 || in.ToMS < 0 || (in.FromMS > 0 && in.ToMS > 0 && in.ToMS <= in.FromMS) {
jsonError(w, 400, "period", "Ungültiger Berichtszeitraum.")
return
}
reportDate := ""
if strings.TrimSpace(in.ReportDate) != "" {
d, err := time.Parse("2006-01-02", in.ReportDate)
if err != nil {
jsonError(w, 400, "report_date", "Ungültiges Berichtsdatum.")
return
}
reportDate = d.Format("02.01.2006")
}
entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, entryFilter{
Query: strings.TrimSpace(in.Query), Client: in.Client, FromMS: in.FromMS, ToMS: in.ToMS, SortAsc: true,
})
if err != nil {
jsonError(w, 500, "db", "Service-Bericht konnte nicht erstellt werden.")
return
}
if len(entries) == 0 {
jsonError(w, 400, "empty", "Für diesen Kunden und Zeitraum wurden keine abgeschlossenen Tätigkeiten gefunden.")
return
}
cfg, err := a.store.settings(r.Context(), sessionOf(r).User.ID)
if err != nil {
jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.")
return
}
pdf := makeServiceReportPDF(entries, cfg, sessionOf(r).User, serviceReportMeta{
Client: in.Client, Contact: strings.TrimSpace(in.Contact), Location: strings.TrimSpace(in.Location),
OrderNumber: strings.TrimSpace(in.OrderNumber), Subject: strings.TrimSpace(in.Subject), Notes: strings.TrimSpace(in.Notes),
Place: strings.TrimSpace(in.Place), ReportDate: reportDate,
})
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", `attachment; filename="service-bericht.pdf"`)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(pdf)
}
func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
@@ -623,7 +694,12 @@ func parseFilter(w http.ResponseWriter, r *http.Request) (entryFilter, bool) {
}
offset = n
}
return entryFilter{Query: q.Get("q"), FromMS: from, ToMS: to, Limit: limit, Offset: offset, SortAsc: q.Get("sort") == "asc", Compact: q.Get("compact") == "1"}, true
client := strings.TrimSpace(q.Get("client"))
if len(client) > 200 {
jsonError(w, 400, "client", "Kunde ist zu lang.")
return entryFilter{}, false
}
return entryFilter{Query: q.Get("q"), Client: client, FromMS: from, ToMS: to, Limit: limit, Offset: offset, SortAsc: q.Get("sort") == "asc", Compact: q.Get("compact") == "1"}, true
}
func parseInt64Param(w http.ResponseWriter, q url.Values, key string) (int64, bool) {
x := q.Get(key)

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; }
}

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();

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>