init
release-tag / release-image (push) Has been cancelled

This commit is contained in:
2026-08-14 17:42:54 +02:00
parent 18a48d2285
commit 222b5d2413
22 changed files with 3013 additions and 1 deletions
+204
View File
@@ -0,0 +1,204 @@
package app
import (
"context"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const (
passwordIterations = 310_000
passwordSaltBytes = 16
passwordKeyBytes = 32
sessionCookie = "pocketwatch_session"
)
// This is deliberately public and never authenticates an account. It only keeps
// failed-login password work roughly constant when a username does not exist.
const dummyPasswordHash = "pbkdf2-sha256$310000$cG9ja2V0d2F0Y2gtZHVtbXk$y2kfBbJVOW/xpojEt9AAGQfhF+Ul6iGKDKH09PSY7IY"
type session struct {
User User
CSRF string
}
type contextKey int
const sessionKey contextKey = 1
func hashPassword(password string) (string, error) {
if len(password) < 10 {
return "", fmt.Errorf("password must have at least 10 characters")
}
salt := make([]byte, passwordSaltBytes)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := pbkdf2SHA256([]byte(password), salt, passwordIterations, passwordKeyBytes)
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
func verifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter < 100_000 || iter > 2_000_000 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(want) == 0 {
return false
}
got := pbkdf2SHA256([]byte(password), salt, iter, len(want))
return subtle.ConstantTimeCompare(got, want) == 1
}
// pbkdf2SHA256 wraps Go 1.26's standard-library PBKDF2 implementation.
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
key, err := pbkdf2.Key(sha256.New, string(password), salt, iterations, keyLen)
if err != nil {
panic(err) // callers use fixed, validated parameters
}
return key
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func tokenDigest(token string) []byte {
s := sha256.Sum256([]byte(token))
return s[:]
}
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
h := hex.EncodeToString(b)
return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
func (a *App) createSession(ctx context.Context, userID string) (token, csrf string, expires time.Time, err error) {
token, err = randomToken(32)
if err != nil {
return
}
csrf, err = randomToken(24)
if err != nil {
return
}
now := time.Now()
expires = now.Add(a.cfg.SessionTTL)
_, err = a.store.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,created_at_ms,expires_at_ms) VALUES(?,?,?,?,?)`, tokenDigest(token), userID, csrf, now.UnixMilli(), expires.UnixMilli())
return
}
func (a *App) sessionFromRequest(r *http.Request) (*session, error) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return nil, sql.ErrNoRows
}
var userID, csrf string
var expires int64
err = a.store.db.QueryRowContext(r.Context(), `SELECT user_id,csrf_token,expires_at_ms FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)).Scan(&userID, &csrf, &expires)
if err != nil {
return nil, err
}
if expires <= time.Now().UnixMilli() {
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
return nil, sql.ErrNoRows
}
u, err := a.store.userByID(r.Context(), userID)
if err != nil || !u.Active {
return nil, sql.ErrNoRows
}
return &session{User: u, CSRF: csrf}, nil
}
func (a *App) withSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, err := a.sessionFromRequest(r)
if err != nil {
if strings.HasPrefix(r.URL.Path, "/api/") {
jsonError(w, http.StatusUnauthorized, "not_authenticated", "Bitte anmelden.")
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
})
}
func sessionOf(r *http.Request) *session {
x, _ := r.Context().Value(sessionKey).(*session)
return x
}
func requireCSRF(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
return true
}
s := sessionOf(r)
if s == nil || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.CSRF)) != 1 {
jsonError(w, http.StatusForbidden, "csrf", "Ungültiges CSRF-Token.")
return false
}
return true
}
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
s := sessionOf(r)
if s == nil || s.User.Role != "admin" {
jsonError(w, http.StatusForbidden, "forbidden", "Admin-Rechte erforderlich.")
return false
}
return true
}
func (a *App) deleteCurrentSession(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
}
a.clearSessionCookie(w)
}
func (a *App) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
}
func (a *App) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
}
func isUniqueConstraint(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "unique constraint") || strings.Contains(s, "constraint failed")
}
+16
View File
@@ -0,0 +1,16 @@
package app
import "testing"
func TestPBKDF2Deterministic(t *testing.T) {
got := pbkdf2SHA256([]byte("password"), []byte("salt"), 2, 32)
want := []byte{0xae, 0x4d, 0x0c, 0x95, 0xaf, 0x6b, 0x46, 0xd3, 0x2d, 0x0a, 0xdf, 0xf9, 0x28, 0xf0, 0x6d, 0xd0, 0x2a, 0x30, 0x3f, 0x8e, 0xf3, 0xc2, 0x51, 0xdf, 0xd6, 0xe2, 0xd8, 0x5a, 0x95, 0x47, 0x4c, 0x43}
if len(got) != len(want) {
t.Fatalf("length %d", len(got))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("byte %d: got %x want %x", i, got[i], want[i])
}
}
}
+531
View File
@@ -0,0 +1,531 @@
package app
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strings"
"time"
_ "modernc.org/sqlite"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Active bool `json:"active"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type Settings struct {
Language string `json:"language"`
TimeFormat string `json:"time_format"`
RoundingMinutes int `json:"rounding_minutes"`
RoundUp bool `json:"round_up"`
ShowWeekTotal bool `json:"show_week_total"`
StickyDays bool `json:"sticky_days"`
LongRunReminder bool `json:"long_run_reminder"`
ExportName string `json:"export_name"`
Timezone string `json:"timezone"`
ExportDate bool `json:"export_date"`
}
type Entry struct {
ID string `json:"id"`
Client string `json:"client"`
Activity string `json:"activity"`
StartMS int64 `json:"start_ms"`
EndMS *int64 `json:"end_ms"`
Created int64 `json:"created_at_ms"`
Updated int64 `json:"updated_at_ms"`
}
type store struct{ db *sql.DB }
func openStore(path string) (*store, error) {
// modernc.org/sqlite supports validated DSN shorthands for common PRAGMAs.
u := &url.URL{Scheme: "file", Path: path}
q := u.Query()
q.Set("_fk", "1")
q.Set("_journal", "WAL")
q.Set("_timeout", "5000")
q.Set("_sync", "NORMAL")
q.Set("_dqs", "false")
u.RawQuery = q.Encode()
dsn := u.String()
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// SQLite has a single writer. A small pool avoids accidental writer stampedes while
// still allowing concurrent reads in WAL mode.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(0)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, err
}
s := &store{db: db}
if err := s.migrate(ctx); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *store) migrate(ctx context.Context) error {
const schema = `
CREATE TABLE IF NOT EXISTS app_state (
id INTEGER PRIMARY KEY CHECK(id = 1),
setup_complete INTEGER NOT NULL DEFAULT 0 CHECK(setup_complete IN (0,1))
);
INSERT OR IGNORE INTO app_state(id, setup_complete) VALUES(1, 0);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash BLOB PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
expires_at_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at_ms);
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client TEXT NOT NULL DEFAULT '',
activity TEXT NOT NULL DEFAULT '',
start_ms INTEGER NOT NULL,
end_ms INTEGER,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
CHECK(end_ms IS NULL OR end_ms >= start_ms)
);
CREATE INDEX IF NOT EXISTS idx_entries_user_start ON entries(user_id, start_ms DESC);
CREATE INDEX IF NOT EXISTS idx_entries_user_client ON entries(user_id, client COLLATE NOCASE);
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running_entry_per_user ON entries(user_id) WHERE end_ms IS NULL;
UPDATE app_state SET setup_complete=1 WHERE id=1 AND EXISTS(SELECT 1 FROM users);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
language TEXT NOT NULL DEFAULT 'de' CHECK(language IN ('de','en')),
time_format TEXT NOT NULL DEFAULT '24' CHECK(time_format IN ('24','12')),
rounding_minutes INTEGER NOT NULL DEFAULT 1 CHECK(rounding_minutes BETWEEN 1 AND 60),
round_up INTEGER NOT NULL DEFAULT 0 CHECK(round_up IN (0,1)),
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)),
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)
return err
}
var errAlreadySetup = errors.New("instance already set up")
func (s *store) needsSetup(ctx context.Context) (bool, error) {
var complete int
err := s.db.QueryRowContext(ctx, `SELECT setup_complete FROM app_state WHERE id=1`).Scan(&complete)
return complete == 0, err
}
func (s *store) bootstrapAdmin(ctx context.Context, username, display, passwordHash string) (User, error) {
username = strings.TrimSpace(username)
display = strings.TrimSpace(display)
if display == "" {
display = username
}
now := time.Now().UnixMilli()
u := User{ID: newID(), Username: username, DisplayName: display, Role: "admin", Active: true, CreatedAtMS: now}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return User{}, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `UPDATE app_state SET setup_complete=1 WHERE id=1 AND setup_complete=0`)
if err != nil {
return User{}, err
}
n, err := res.RowsAffected()
if err != nil {
return User{}, err
}
if n != 1 {
return User{}, errAlreadySetup
}
if _, err := tx.ExecContext(ctx, `INSERT INTO users(id,username,display_name,password_hash,role,active,created_at_ms) VALUES(?,?,?,?,'admin',1,?)`, u.ID, u.Username, u.DisplayName, passwordHash, now); err != nil {
return User{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO user_settings(user_id,export_name) VALUES(?,?)`, u.ID, u.DisplayName); err != nil {
return User{}, err
}
if err := tx.Commit(); err != nil {
return User{}, err
}
return u, nil
}
func (s *store) createUser(ctx context.Context, username, display, passwordHash, role string) (User, error) {
username = strings.TrimSpace(username)
display = strings.TrimSpace(display)
if display == "" {
display = username
}
if role != "admin" {
role = "user"
}
now := time.Now().UnixMilli()
u := User{ID: newID(), Username: username, DisplayName: display, Role: role, Active: true, CreatedAtMS: now}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return User{}, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `INSERT INTO users(id,username,display_name,password_hash,role,active,created_at_ms) VALUES(?,?,?,?,?,1,?)`, u.ID, u.Username, u.DisplayName, passwordHash, u.Role, now); err != nil {
return User{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO user_settings(user_id,export_name) VALUES(?,?)`, u.ID, u.DisplayName); err != nil {
return User{}, err
}
if err := tx.Commit(); err != nil {
return User{}, err
}
return u, nil
}
func (s *store) userForLogin(ctx context.Context, username string) (User, string, error) {
var u User
var hash string
var active int
err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,role,active,created_at_ms FROM users WHERE username=?`, strings.TrimSpace(username)).Scan(&u.ID, &u.Username, &u.DisplayName, &hash, &u.Role, &active, &u.CreatedAtMS)
u.Active = active == 1
return u, hash, err
}
func (s *store) userByID(ctx context.Context, id string) (User, error) {
var u User
var active int
err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,role,active,created_at_ms FROM users WHERE id=?`, id).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &active, &u.CreatedAtMS)
u.Active = active == 1
return u, err
}
func (s *store) listUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,username,display_name,role,active,created_at_ms FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var active int
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &active, &u.CreatedAtMS); err != nil {
return nil, err
}
u.Active = active == 1
out = append(out, u)
}
return out, rows.Err()
}
func (s *store) setUserActive(ctx context.Context, id string, active bool) error {
v := 0
if active {
v = 1
}
res, err := s.db.ExecContext(ctx, `UPDATE users SET active=? WHERE id=?`, v, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
if !active {
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id=?`, id)
}
return nil
}
func (s *store) resetPassword(ctx context.Context, id, hash string) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash=? WHERE id=?`, hash, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id=?`, id)
return nil
}
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,
)
x.RoundUp = up == 1
x.ShowWeekTotal = week == 1
x.StickyDays = sticky == 1
x.LongRunReminder = reminder == 1
x.ExportDate = exportDate == 1
return x, err
}
func (s *store) updateSettings(ctx context.Context, userID string, x Settings) error {
boolInt := func(v bool) int {
if v {
return 1
}
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)
return err
}
func (s *store) runningEntry(ctx context.Context, userID string) (*Entry, error) {
var e Entry
err := s.db.QueryRowContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries WHERE user_id=? AND end_ms IS NULL LIMIT 1`, userID).Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &e, err
}
func (s *store) startEntry(ctx context.Context, userID, client, activity string, startMS int64) (Entry, error) {
now := time.Now().UnixMilli()
if startMS == 0 {
startMS = now
}
e := Entry{ID: newID(), Client: strings.TrimSpace(client), Activity: strings.TrimSpace(activity), StartMS: startMS, Created: now, Updated: now}
_, err := s.db.ExecContext(ctx, `INSERT INTO entries(id,user_id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms) VALUES(?,?,?,?,?,NULL,?,?)`, e.ID, userID, e.Client, e.Activity, e.StartMS, now, now)
return e, err
}
func (s *store) stopEntry(ctx context.Context, userID, id string, endMS int64) (Entry, error) {
if endMS == 0 {
endMS = time.Now().UnixMilli()
}
now := time.Now().UnixMilli()
res, err := s.db.ExecContext(ctx, `UPDATE entries SET end_ms=?,updated_at_ms=? WHERE id=? AND user_id=? AND end_ms IS NULL AND start_ms<=?`, endMS, now, id, userID, endMS)
if err != nil {
return Entry{}, err
}
n, _ := res.RowsAffected()
if n == 0 {
return Entry{}, sql.ErrNoRows
}
return s.entryByID(ctx, userID, id)
}
func (s *store) entryByID(ctx context.Context, userID, id string) (Entry, error) {
var e Entry
err := s.db.QueryRowContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries WHERE id=? AND user_id=?`, id, userID).Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated)
return e, err
}
func (s *store) updateEntry(ctx context.Context, userID, id, client, activity string, startMS int64, endMS *int64) (Entry, error) {
if startMS <= 0 || (endMS != nil && *endMS < startMS) {
return Entry{}, fmt.Errorf("invalid time range")
}
now := time.Now().UnixMilli()
res, err := s.db.ExecContext(ctx, `UPDATE entries SET client=?,activity=?,start_ms=?,end_ms=?,updated_at_ms=? WHERE id=? AND user_id=?`, strings.TrimSpace(client), strings.TrimSpace(activity), startMS, endMS, now, id, userID)
if err != nil {
return Entry{}, err
}
n, _ := res.RowsAffected()
if n == 0 {
return Entry{}, sql.ErrNoRows
}
return s.entryByID(ctx, userID, id)
}
func (s *store) deleteEntry(ctx context.Context, userID, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM entries WHERE id=? AND user_id=?`, id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
type entryFilter struct {
Query string
FromMS int64
ToMS int64
Limit int
Offset int
SortAsc bool
Compact bool
}
type entryPage struct {
Entries []Entry `json:"entries"`
TotalCount int `json:"total_count"`
TotalDurationMS int64 `json:"total_duration_ms"`
}
func (s *store) listEntries(ctx context.Context, userID string, f entryFilter, cfg Settings) (entryPage, error) {
where, args := buildEntryWhere(userID, f)
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM entries `+where+` AND end_ms IS NOT NULL`, args...).Scan(&total); err != nil {
return entryPage{}, err
}
// Totals are calculated row-wise to keep the rounding semantics identical to exports.
rowsDur, err := s.db.QueryContext(ctx, `SELECT start_ms,end_ms FROM entries `+where+` AND end_ms IS NOT NULL`, args...)
if err != nil {
return entryPage{}, err
}
var totalDur int64
for rowsDur.Next() {
var start, end int64
if err := rowsDur.Scan(&start, &end); err != nil {
rowsDur.Close()
return entryPage{}, err
}
totalDur += roundedDuration(end-start, cfg.RoundingMinutes, cfg.RoundUp)
}
rowsDur.Close()
if err := rowsDur.Err(); err != nil {
return entryPage{}, err
}
qargs := append(append([]any{}, args...), f.Limit, f.Offset)
rows, err := s.db.QueryContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries `+where+` AND end_ms IS NOT NULL ORDER BY start_ms DESC,id DESC LIMIT ? OFFSET ?`, qargs...)
if err != nil {
return entryPage{}, err
}
defer rows.Close()
out := entryPage{TotalCount: total, TotalDurationMS: totalDur, Entries: []Entry{}}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated); err != nil {
return entryPage{}, err
}
out.Entries = append(out.Entries, e)
}
return out, rows.Err()
}
func (s *store) allEntries(ctx context.Context, userID string, f entryFilter) ([]Entry, error) {
where, args := buildEntryWhere(userID, f)
order := "DESC"
if f.SortAsc {
order = "ASC"
}
rows, err := s.db.QueryContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries `+where+` AND end_ms IS NOT NULL ORDER BY start_ms `+order+`,id `+order, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func buildEntryWhere(userID string, f entryFilter) (string, []any) {
where := `WHERE user_id=?`
args := []any{userID}
if f.FromMS > 0 {
where += ` AND start_ms>=?`
args = append(args, f.FromMS)
}
if f.ToMS > 0 {
where += ` AND start_ms<?`
args = append(args, f.ToMS)
}
if q := strings.TrimSpace(f.Query); q != "" {
where += ` AND (client LIKE ? ESCAPE '\' COLLATE NOCASE OR activity LIKE ? ESCAPE '\' COLLATE NOCASE)`
q = "%" + escapeLike(q) + "%"
args = append(args, q, q)
}
return where, args
}
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (s *store) recentClients(ctx context.Context, userID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT client FROM entries WHERE user_id=? AND TRIM(client)<>'' GROUP BY client COLLATE NOCASE ORDER BY MAX(start_ms) DESC LIMIT 50`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var x string
if err := rows.Scan(&x); err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
func roundedDuration(ms int64, minutes int, up bool) int64 {
if ms <= 0 {
return 0
}
if minutes < 1 {
minutes = 1
}
interval := int64(minutes) * 60_000
if interval <= 60_000 {
return ms
}
if up {
return ((ms + interval - 1) / interval) * interval
}
return ((ms + interval/2) / interval) * interval
}
func (s *store) createFinishedEntry(ctx context.Context, userID, client, activity string, startMS, endMS int64) (Entry, error) {
if startMS <= 0 || endMS < startMS {
return Entry{}, fmt.Errorf("invalid time range")
}
now := time.Now().UnixMilli()
e := Entry{ID: newID(), Client: strings.TrimSpace(client), Activity: strings.TrimSpace(activity), StartMS: startMS, EndMS: &endMS, Created: now, Updated: now}
_, err := s.db.ExecContext(ctx, `INSERT INTO entries(id,user_id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms) VALUES(?,?,?,?,?,?,?,?)`, e.ID, userID, e.Client, e.Activity, startMS, endMS, now, now)
return e, err
}
+253
View File
@@ -0,0 +1,253 @@
package app
import (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
)
func makeCSV(entries []Entry, cfg Settings, compact bool) ([]byte, error) {
loc := location(cfg.Timezone)
var b bytes.Buffer
b.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM.
w := csv.NewWriter(&b)
w.Comma = ';'
if compact {
_ = w.Write([]string{"Datum", "Kunde", "Start", "Ende", "Dauer"})
} else {
_ = w.Write([]string{"Datum", "Kunde", "Tätigkeit", "Start", "Ende", "Dauer"})
}
for _, e := range entries {
if e.EndMS == nil {
continue
}
start := time.UnixMilli(e.StartMS).In(loc)
end := time.UnixMilli(*e.EndMS).In(loc)
dur := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
if compact {
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
} else {
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, e.Activity, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
}
}
w.Flush()
return b.Bytes(), w.Error()
}
type pdfPageLine struct {
x, y, size float64
bold bool
text string
}
func makePDF(entries []Entry, cfg Settings, owner User, compact bool) []byte {
loc := location(cfg.Timezone)
name := strings.TrimSpace(cfg.ExportName)
if name == "" {
name = owner.DisplayName
}
const pageW, pageH = 595.0, 842.0 // A4 points.
var pages [][]pdfPageLine
var page []pdfPageLine
y := 795.0
newPage := func() {
if len(page) > 0 {
pages = append(pages, page)
}
page = []pdfPageLine{}
y = 795
page = append(page, pdfPageLine{50, y, 19, true, "Zeiterfassung"})
y -= 24
page = append(page, pdfPageLine{50, y, 10, false, name})
y -= 22
if compact {
page = append(page,
pdfPageLine{50, y, 9, true, "Datum"},
pdfPageLine{112, y, 9, true, "Kunde"},
pdfPageLine{430, y, 9, true, "Zeit"},
pdfPageLine{515, y, 9, true, "Dauer"},
)
} else {
page = append(page,
pdfPageLine{50, y, 9, true, "Datum"},
pdfPageLine{112, y, 9, true, "Kunde"},
pdfPageLine{255, y, 9, true, "Tätigkeit"},
pdfPageLine{430, y, 9, true, "Zeit"},
pdfPageLine{515, y, 9, true, "Dauer"},
)
}
y -= 16
}
newPage()
var total int64
for _, e := range entries {
if e.EndMS == nil {
continue
}
if y < 65 {
newPage()
}
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
page = append(page,
pdfPageLine{50, y, 8.5, false, start.Format("02.01.06")},
pdfPageLine{112, y, 8.5, false, clipRunes(e.Client, func() int {
if compact {
return 50
}
return 25
}())},
)
if !compact {
page = append(page, pdfPageLine{255, y, 8.5, false, clipRunes(e.Activity, 29)})
}
page = append(page,
pdfPageLine{430, y, 8.5, false, formatClock(start, cfg.TimeFormat) + "-" + formatClock(end, cfg.TimeFormat)},
pdfPageLine{515, y, 8.5, false, formatDuration(d)},
)
y -= 15
}
if y < 60 {
newPage()
}
page = append(page, pdfPageLine{430, y - 4, 10, true, "Gesamt"}, pdfPageLine{515, y - 4, 10, true, formatDuration(total)})
pages = append(pages, page)
return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc)
}
// 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 {
objs := make([][]byte, 0)
add := func(s string) int {
objs = append(objs, []byte(s))
return len(objs)
}
catalogID := add("")
pagesID := add("")
fontID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`)
boldID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>`)
pageIDs := make([]int, 0, len(pages))
for i, lines := range pages {
var c strings.Builder
for _, ln := range lines {
font := "F1"
if ln.bold {
font = "F2"
}
fmt.Fprintf(&c, "BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", font, ln.size, ln.x, ln.y, pdfEscape(ln.text))
}
footer := "Seite " + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(pages))
if exportDate {
footer = "Export: " + time.Now().In(loc).Format("02.01.2006") + " - " + footer
}
fmt.Fprintf(&c, "BT /F1 7.5 Tf 50 28 Td (%s) Tj ET\n", pdfEscape(footer))
content := c.String()
contentID := add(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content))
pageID := add(fmt.Sprintf(
"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %.0f %.0f] /Resources << /Font << /F1 %d 0 R /F2 %d 0 R >> >> /Contents %d 0 R >>",
pagesID, pageW, pageH, fontID, boldID, contentID,
))
pageIDs = append(pageIDs, pageID)
}
kids := make([]string, len(pageIDs))
for i, id := range pageIDs {
kids[i] = fmt.Sprintf("%d 0 R", id)
}
objs[catalogID-1] = []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pagesID))
objs[pagesID-1] = []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", len(pageIDs), strings.Join(kids, " ")))
var out bytes.Buffer
out.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")
offsets := make([]int, len(objs)+1)
for i, obj := range objs {
offsets[i+1] = out.Len()
fmt.Fprintf(&out, "%d 0 obj\n", i+1)
out.Write(obj)
out.WriteString("\nendobj\n")
}
xref := out.Len()
fmt.Fprintf(&out, "xref\n0 %d\n", len(objs)+1)
out.WriteString("0000000000 65535 f \n")
for i := 1; i <= len(objs); i++ {
fmt.Fprintf(&out, "%010d 00000 n \n", offsets[i])
}
fmt.Fprintf(&out, "trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, catalogID, xref)
return out.Bytes()
}
func pdfEscape(s string) string {
var b strings.Builder
for _, r := range s {
var c byte
switch r {
case '', '—':
c = '-'
case '€':
c = 0x80
case '“', '”':
c = '"'
case '':
c = '\''
default:
if r >= 32 && r <= 255 {
c = byte(r)
} else if r == '\n' || r == '\r' {
c = ' '
} else {
c = '?'
}
}
if c == '(' || c == ')' || c == '\\' {
b.WriteByte('\\')
}
b.WriteByte(c)
}
return b.String()
}
func formatClock(t time.Time, f string) string {
if f == "12" {
return t.Format("03:04 PM")
}
return t.Format("15:04")
}
func formatDuration(ms int64) string {
if ms < 0 {
ms = 0
}
mins := (ms + 30_000) / 60_000
return fmt.Sprintf("%d:%02d", mins/60, mins%60)
}
func location(name string) *time.Location {
if name != "" {
if x, err := time.LoadLocation(name); err == nil {
return x
}
}
return time.UTC
}
func clipRunes(s string, max int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= max {
return string(r)
}
if max < 2 {
return string(r[:max])
}
return string(r[:max-1]) + "…"
}
+28
View File
@@ -0,0 +1,28 @@
package app
import (
"bytes"
"testing"
)
func TestPDFHeader(t *testing.T) {
end := int64(3_600_000)
pdf := makePDF([]Entry{{ID: "x", Client: "ACME", Activity: "Arbeit", StartMS: 0, EndMS: &end}}, Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"}, User{DisplayName: "Test"}, false)
if !bytes.HasPrefix(pdf, []byte("%PDF-1.4")) {
t.Fatal("missing PDF header")
}
if !bytes.Contains(pdf, []byte("xref")) {
t.Fatal("missing xref")
}
}
func TestCSVCompactOmitsActivity(t *testing.T) {
end := int64(3_600_000)
b, err := makeCSV([]Entry{{ID: "x", Client: "ACME", Activity: "Geheim", StartMS: 0, EndMS: &end}}, Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"}, true)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(b, []byte("Geheim")) {
t.Fatal("compact CSV contains activity")
}
}
+22
View File
@@ -0,0 +1,22 @@
package app
import "testing"
func TestRoundedDuration(t *testing.T) {
tests := []struct {
ms int64
min int
up bool
want int64
}{
{37 * 60_000, 15, false, 30 * 60_000},
{38 * 60_000, 15, false, 45 * 60_000},
{37 * 60_000, 15, true, 45 * 60_000},
{37 * 60_000, 1, false, 37 * 60_000},
}
for _, tt := range tests {
if got := roundedDuration(tt.ms, tt.min, tt.up); got != tt.want {
t.Errorf("roundedDuration(%d,%d,%v)=%d want %d", tt.ms, tt.min, tt.up, got, tt.want)
}
}
}
+757
View File
@@ -0,0 +1,757 @@
package app
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"mime"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
)
//go:embed web/*
var webFS embed.FS
type Config struct {
DBPath string
CookieSecure bool
SessionTTL time.Duration
}
type App struct {
cfg Config
store *store
log *slog.Logger
mux *http.ServeMux
limiter *loginLimiter
static fs.FS
}
func New(cfg Config, logger *slog.Logger) (*App, error) {
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 30 * 24 * time.Hour
}
st, err := openStore(cfg.DBPath)
if err != nil {
return nil, err
}
static, err := fs.Sub(webFS, "web")
if err != nil {
st.db.Close()
return nil, err
}
a := &App{cfg: cfg, store: st, log: logger, mux: http.NewServeMux(), limiter: newLoginLimiter(), static: static}
a.routes()
return a, nil
}
func (a *App) Close() error { return a.store.db.Close() }
func (a *App) Handler() http.Handler { return a.securityHeaders(a.recoverer(a.accessLog(a.mux))) }
func (a *App) routes() {
// Public.
a.mux.HandleFunc("GET /healthz", a.health)
a.mux.HandleFunc("GET /login", a.loginPage)
a.mux.HandleFunc("GET /api/bootstrap", a.bootstrap)
a.mux.HandleFunc("POST /api/setup", a.setup)
a.mux.HandleFunc("POST /api/login", a.login)
fileServer := http.FileServer(http.FS(a.static))
a.mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
// Authenticated HTML.
a.mux.Handle("GET /{$}", a.withSession(http.HandlerFunc(a.indexPage)))
// Authenticated API.
api := http.NewServeMux()
api.HandleFunc("GET /api/me", a.me)
api.HandleFunc("POST /api/logout", a.logout)
api.HandleFunc("POST /api/account/password", a.changeOwnPassword)
api.HandleFunc("GET /api/settings", a.getSettings)
api.HandleFunc("PUT /api/settings", a.putSettings)
api.HandleFunc("GET /api/running", a.getRunning)
api.HandleFunc("GET /api/clients", a.getClients)
api.HandleFunc("GET /api/entries", a.getEntries)
api.HandleFunc("POST /api/entries", a.createEntry)
api.HandleFunc("POST /api/entries/start", a.startEntry)
api.HandleFunc("POST /api/entries/{id}/stop", a.stopEntry)
api.HandleFunc("PUT /api/entries/{id}", a.updateEntry)
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("GET /api/admin/users", a.adminUsers)
api.HandleFunc("POST /api/admin/users", a.adminCreateUser)
api.HandleFunc("PATCH /api/admin/users/{id}", a.adminPatchUser)
api.HandleFunc("POST /api/admin/users/{id}/password", a.adminResetPassword)
a.mux.Handle("/api/", a.withSession(api))
}
func (a *App) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := a.store.db.PingContext(ctx); err != nil {
jsonError(w, 503, "db_unavailable", "database unavailable")
return
}
writeJSON(w, 200, map[string]string{"status": "ok"})
}
func (a *App) loginPage(w http.ResponseWriter, r *http.Request) {
if _, err := a.sessionFromRequest(r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
a.serveAsset(w, r, "login.html")
}
func (a *App) indexPage(w http.ResponseWriter, r *http.Request) { a.serveAsset(w, r, "index.html") }
func (a *App) serveAsset(w http.ResponseWriter, r *http.Request, name string) {
b, err := fs.ReadFile(a.static, name)
if err != nil {
http.NotFound(w, r)
return
}
if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
w.Header().Set("Content-Type", ct)
}
if strings.HasSuffix(name, ".html") {
w.Header().Set("Cache-Control", "no-store")
}
_, _ = w.Write(b)
}
func (a *App) bootstrap(w http.ResponseWriter, r *http.Request) {
needs, err := a.store.needsSetup(r.Context())
if err != nil {
jsonError(w, 500, "db", "Datenbankfehler.")
return
}
writeJSON(w, 200, map[string]bool{"needs_setup": needs})
}
func (a *App) setup(w http.ResponseWriter, r *http.Request) {
needs, err := a.store.needsSetup(r.Context())
if err != nil {
jsonError(w, 500, "db", "Datenbankfehler.")
return
}
if !needs {
jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.")
return
}
var in struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &in) {
return
}
if !validUsername(in.Username) {
jsonError(w, 400, "username", "Benutzername: 364 Zeichen, Buchstaben/Zahlen/._-.")
return
}
hash, err := hashPassword(in.Password)
if err != nil {
jsonError(w, 400, "password", err.Error())
return
}
u, err := a.store.bootstrapAdmin(r.Context(), in.Username, in.DisplayName, hash)
if errors.Is(err, errAlreadySetup) {
jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.")
return
}
if err != nil {
jsonError(w, 409, "create_user", "Benutzer konnte nicht angelegt werden.")
return
}
token, _, exp, err := a.createSession(r.Context(), u.ID)
if err != nil {
jsonError(w, 500, "session", "Session konnte nicht erstellt werden.")
return
}
a.setSessionCookie(w, token, exp)
writeJSON(w, 201, map[string]any{"user": u})
}
func (a *App) login(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if !a.limiter.allow(ip) {
jsonError(w, 429, "rate_limited", "Zu viele Anmeldeversuche. Bitte später erneut versuchen.")
return
}
var in struct {
Username string `json:"username"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &in) {
return
}
u, hash, err := a.store.userForLogin(r.Context(), in.Username)
hashToCheck := hash
if err != nil {
hashToCheck = dummyPasswordHash
}
passwordOK := verifyPassword(hashToCheck, in.Password)
if err != nil || !u.Active || !passwordOK {
a.limiter.fail(ip)
time.Sleep(150 * time.Millisecond)
jsonError(w, 401, "bad_credentials", "Benutzername oder Passwort ist falsch.")
return
}
a.limiter.success(ip)
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE expires_at_ms<=?`, time.Now().UnixMilli())
token, _, exp, err := a.createSession(r.Context(), u.ID)
if err != nil {
jsonError(w, 500, "session", "Session konnte nicht erstellt werden.")
return
}
a.setSessionCookie(w, token, exp)
writeJSON(w, 200, map[string]any{"user": u})
}
func (a *App) me(w http.ResponseWriter, r *http.Request) {
s := sessionOf(r)
writeJSON(w, 200, map[string]any{"user": s.User, "csrf_token": s.CSRF})
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
a.deleteCurrentSession(w, r)
w.WriteHeader(204)
}
func (a *App) changeOwnPassword(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
var in struct {
Current string `json:"current_password"`
New string `json:"new_password"`
}
if !decodeJSON(w, r, &in) {
return
}
s := sessionOf(r)
_, currentHash, err := a.store.userForLogin(r.Context(), s.User.Username)
if err != nil || !verifyPassword(currentHash, in.Current) {
jsonError(w, http.StatusUnauthorized, "bad_password", "Das aktuelle Passwort ist falsch.")
return
}
hash, err := hashPassword(in.New)
if err != nil {
jsonError(w, 400, "password", err.Error())
return
}
if err := a.store.resetPassword(r.Context(), s.User.ID, hash); err != nil {
jsonError(w, 500, "db", "Passwort konnte nicht geändert werden.")
return
}
a.clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
x, err := a.store.settings(r.Context(), sessionOf(r).User.ID)
if err != nil {
jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.")
return
}
writeJSON(w, 200, x)
}
func (a *App) putSettings(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
var x Settings
if !decodeJSON(w, r, &x) {
return
}
if x.Language != "de" && x.Language != "en" {
x.Language = "de"
}
if x.TimeFormat != "12" {
x.TimeFormat = "24"
}
if x.RoundingMinutes < 1 || x.RoundingMinutes > 60 {
jsonError(w, 400, "rounding", "Rundung muss zwischen 1 und 60 Minuten liegen.")
return
}
if len(x.ExportName) > 120 {
jsonError(w, 400, "export_name", "Name ist zu lang.")
return
}
if len(x.Timezone) > 80 {
jsonError(w, 400, "timezone", "Zeitzone ist ungültig.")
return
}
if _, err := time.LoadLocation(x.Timezone); err != nil {
x.Timezone = "UTC"
}
if err := a.store.updateSettings(r.Context(), sessionOf(r).User.ID, x); err != nil {
jsonError(w, 500, "db", "Einstellungen konnten nicht gespeichert werden.")
return
}
writeJSON(w, 200, x)
}
func (a *App) getRunning(w http.ResponseWriter, r *http.Request) {
e, err := a.store.runningEntry(r.Context(), sessionOf(r).User.ID)
if err != nil {
jsonError(w, 500, "db", "Timer konnte nicht geladen werden.")
return
}
writeJSON(w, 200, map[string]any{"entry": e})
}
func (a *App) getClients(w http.ResponseWriter, r *http.Request) {
x, err := a.store.recentClients(r.Context(), sessionOf(r).User.ID)
if err != nil {
jsonError(w, 500, "db", "Kunden konnten nicht geladen werden.")
return
}
writeJSON(w, 200, map[string]any{"clients": x})
}
func (a *App) getEntries(w http.ResponseWriter, r *http.Request) {
f, ok := parseFilter(w, r)
if !ok {
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
}
p, err := a.store.listEntries(r.Context(), sessionOf(r).User.ID, f, cfg)
if err != nil {
jsonError(w, 500, "db", "Einträge konnten nicht geladen werden.")
return
}
writeJSON(w, 200, p)
}
func (a *App) startEntry(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
var in struct {
Client string `json:"client"`
Activity string `json:"activity"`
StartMS int64 `json:"start_ms"`
}
if !decodeJSON(w, r, &in) {
return
}
if len(in.Client) > 200 || len(in.Activity) > 4000 {
jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.")
return
}
if in.StartMS < 0 {
jsonError(w, 400, "start_ms", "Ungültiger Startzeitpunkt.")
return
}
e, err := a.store.startEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS)
if err != nil {
if isUniqueConstraint(err) {
jsonError(w, 409, "timer_running", "Es läuft bereits ein Timer.")
return
}
jsonError(w, 500, "db", "Timer konnte nicht gestartet werden.")
return
}
writeJSON(w, 201, e)
}
func (a *App) createEntry(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
var in struct {
Client string `json:"client"`
Activity string `json:"activity"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
}
if !decodeJSON(w, r, &in) {
return
}
if len(in.Client) > 200 || len(in.Activity) > 4000 {
jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.")
return
}
e, err := a.store.createFinishedEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS, in.EndMS)
if err != nil {
jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.")
return
}
writeJSON(w, 201, e)
}
func (a *App) stopEntry(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
var in struct {
EndMS int64 `json:"end_ms"`
}
if !decodeJSONAllowEmpty(w, r, &in) {
return
}
e, err := a.store.stopEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.EndMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
jsonError(w, 404, "not_found", "Laufender Eintrag nicht gefunden.")
return
}
jsonError(w, 500, "db", "Timer konnte nicht gestoppt werden.")
return
}
writeJSON(w, 200, e)
}
func (a *App) updateEntry(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
var in struct {
Client string `json:"client"`
Activity string `json:"activity"`
StartMS int64 `json:"start_ms"`
EndMS *int64 `json:"end_ms"`
}
if !decodeJSON(w, r, &in) {
return
}
if len(in.Client) > 200 || len(in.Activity) > 4000 {
jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.")
return
}
e, err := a.store.updateEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.Client, in.Activity, in.StartMS, in.EndMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
jsonError(w, 404, "not_found", "Eintrag nicht gefunden.")
return
}
if isUniqueConstraint(err) {
jsonError(w, 409, "timer_running", "Es kann nur einen laufenden Timer geben.")
return
}
jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.")
return
}
writeJSON(w, 200, e)
}
func (a *App) deleteEntry(w http.ResponseWriter, r *http.Request) {
if !requireCSRF(w, r) {
return
}
if err := a.store.deleteEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id")); err != nil {
if errors.Is(err, sql.ErrNoRows) {
jsonError(w, 404, "not_found", "Eintrag nicht gefunden.")
return
}
jsonError(w, 500, "db", "Eintrag konnte nicht gelöscht werden.")
return
}
w.WriteHeader(204)
}
func (a *App) exportCSV(w http.ResponseWriter, r *http.Request) {
f, ok := parseFilter(w, r)
if !ok {
return
}
entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f)
if err != nil {
jsonError(w, 500, "db", "Export konnte nicht erstellt werden.")
return
}
cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID)
b, err := makeCSV(entries, cfg, f.Compact)
if err != nil {
jsonError(w, 500, "export", "CSV konnte nicht erstellt werden.")
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.csv"`)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b)
}
func (a *App) exportPDF(w http.ResponseWriter, r *http.Request) {
f, ok := parseFilter(w, r)
if !ok {
return
}
entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f)
if err != nil {
jsonError(w, 500, "db", "Export konnte nicht erstellt werden.")
return
}
cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID)
b := makePDF(entries, cfg, sessionOf(r).User, f.Compact)
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.pdf"`)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b)
}
func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
users, err := a.store.listUsers(r.Context())
if err != nil {
jsonError(w, 500, "db", "Benutzer konnten nicht geladen werden.")
return
}
writeJSON(w, 200, map[string]any{"users": users})
}
func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) || !requireCSRF(w, r) {
return
}
var in struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
Role string `json:"role"`
}
if !decodeJSON(w, r, &in) {
return
}
if !validUsername(in.Username) {
jsonError(w, 400, "username", "Ungültiger Benutzername.")
return
}
hash, err := hashPassword(in.Password)
if err != nil {
jsonError(w, 400, "password", err.Error())
return
}
u, err := a.store.createUser(r.Context(), in.Username, in.DisplayName, hash, in.Role)
if err != nil {
jsonError(w, 409, "username_exists", "Benutzername ist bereits vergeben.")
return
}
writeJSON(w, 201, u)
}
func (a *App) adminPatchUser(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) || !requireCSRF(w, r) {
return
}
id := r.PathValue("id")
if id == sessionOf(r).User.ID {
jsonError(w, 400, "self", "Den eigenen Account hier nicht deaktivieren.")
return
}
var in struct {
Active *bool `json:"active"`
}
if !decodeJSON(w, r, &in) {
return
}
if in.Active == nil {
jsonError(w, 400, "active", "active fehlt.")
return
}
if err := a.store.setUserActive(r.Context(), id, *in.Active); err != nil {
jsonError(w, 404, "not_found", "Benutzer nicht gefunden.")
return
}
w.WriteHeader(204)
}
func (a *App) adminResetPassword(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) || !requireCSRF(w, r) {
return
}
var in struct {
Password string `json:"password"`
}
if !decodeJSON(w, r, &in) {
return
}
hash, err := hashPassword(in.Password)
if err != nil {
jsonError(w, 400, "password", err.Error())
return
}
if err := a.store.resetPassword(r.Context(), r.PathValue("id"), hash); err != nil {
jsonError(w, 404, "not_found", "Benutzer nicht gefunden.")
return
}
w.WriteHeader(204)
}
func parseFilter(w http.ResponseWriter, r *http.Request) (entryFilter, bool) {
q := r.URL.Query()
from, ok := parseInt64Param(w, q, "from")
if !ok {
return entryFilter{}, false
}
to, ok := parseInt64Param(w, q, "to")
if !ok {
return entryFilter{}, false
}
limit := 200
if x := q.Get("limit"); x != "" {
n, err := strconv.Atoi(x)
if err != nil || n < 1 {
jsonError(w, 400, "limit", "Ungültiges Limit.")
return entryFilter{}, false
}
if n > 500 {
n = 500
}
limit = n
}
offset := 0
if x := q.Get("offset"); x != "" {
n, err := strconv.Atoi(x)
if err != nil || n < 0 {
jsonError(w, 400, "offset", "Ungültiger Offset.")
return entryFilter{}, false
}
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
}
func parseInt64Param(w http.ResponseWriter, q url.Values, key string) (int64, bool) {
x := q.Get(key)
if x == "" {
return 0, true
}
n, err := strconv.ParseInt(x, 10, 64)
if err != nil || n < 0 {
jsonError(w, 400, key, "Ungültiger Zeitraum.")
return 0, false
}
return n, true
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
jsonError(w, 415, "content_type", "Content-Type application/json erforderlich.")
return false
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
jsonError(w, 400, "json", "Ungültige JSON-Daten.")
return false
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
jsonError(w, 400, "json", "Nach dem JSON-Objekt sind weitere Daten enthalten.")
return false
}
return true
}
func decodeJSONAllowEmpty(w http.ResponseWriter, r *http.Request, dst any) bool {
if r.ContentLength == 0 {
return true
}
return decodeJSON(w, r, dst)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func jsonError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}})
}
func validUsername(s string) bool {
s = strings.TrimSpace(s)
if len(s) < 3 || len(s) > 64 {
return false
}
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func (a *App) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
next.ServeHTTP(w, r)
})
}
func (a *App) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
a.log.Error("panic", "value", fmt.Sprint(v), "path", r.URL.Path)
jsonError(w, 500, "internal", "Interner Serverfehler.")
}
}()
next.ServeHTTP(w, r)
})
}
func (a *App) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if r.URL.Path != "/healthz" {
a.log.Info("http", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(start).Milliseconds())
}
})
}
type loginAttempt struct {
fails int
first, blockedUntil time.Time
}
type loginLimiter struct {
mu sync.Mutex
m map[string]loginAttempt
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{m: map[string]loginAttempt{}} }
func (l *loginLimiter) allow(k string) bool {
l.mu.Lock()
defer l.mu.Unlock()
x := l.m[k]
return x.blockedUntil.IsZero() || time.Now().After(x.blockedUntil)
}
func (l *loginLimiter) fail(k string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
x := l.m[k]
if x.first.IsZero() || now.Sub(x.first) > 10*time.Minute {
x = loginAttempt{first: now}
}
x.fails++
if x.fails >= 5 {
x.blockedUntil = now.Add(10 * time.Minute)
}
l.m[k] = x
}
func (l *loginLimiter) success(k string) { l.mu.Lock(); defer l.mu.Unlock(); delete(l.m, k) }
+200
View File
@@ -0,0 +1,200 @@
:root {
color-scheme: dark;
--bg: #0e0f11;
--panel: #111316;
--card: #16181b;
--raised: #1c1f23;
--active: #2b3037;
--border: #282c31;
--divider: #202328;
--text: #eceef1;
--muted: #969ca4;
--dim: #6c727a;
--amber: #f5c065;
--amber-hover: #ffd98a;
--on-amber: #16191c;
--danger: #e85b61;
--shadow: 0 24px 70px rgba(0,0,0,.42);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
body { min-height: 100dvh; }
button, input, textarea, select { font: inherit; }
button { color: inherit; }
input, textarea, select {
width: 100%; border: 1px solid var(--border); background: var(--card); color: var(--text);
border-radius: 12px; padding: 11px 12px; outline: none; transition: border-color .15s, background .15s;
}
input:focus, textarea:focus, select:focus { border-color: var(--amber); background: #191c20; }
textarea { resize: vertical; min-height: 90px; }
label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; font-weight: 600; }
button { border: 0; cursor: pointer; }
[hidden] { display: none !important; }
.app-shell { height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.topbar { height: 68px; flex: 0 0 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--divider); }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 720; letter-spacing: -.025em; font-size: 18px; }
.brand-large { font-size: 22px; margin-bottom: 32px; }
.brand-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(245,192,101,.08); }
.topbar-actions { display: flex; align-items: center; gap: 9px; }
.metric-inline { display: grid; text-align: right; margin-right: 12px; }
.metric-inline strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px; }
.metric-inline span { font-size: 10px; color: var(--muted); }
.icon-btn, .avatar-btn { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: transparent; border: 1px solid transparent; }
.icon-btn:hover, .avatar-btn:hover { background: var(--raised); border-color: var(--border); }
.avatar-btn { background: var(--amber); color: var(--on-amber); font-weight: 800; border-radius: 50%; width: 34px; height: 34px; margin-left: 4px; }
.main-grid { min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(320px, 400px) 1fr; }
.tracker-pane { min-height: 0; overflow-y: auto; background: var(--panel); padding: 26px; display: flex; flex-direction: column; gap: 18px; }
.tracker-card { padding: 22px; border: 1px solid var(--border); border-radius: 22px; background: var(--card); display: grid; gap: 16px; box-shadow: inset 0 1px rgba(255,255,255,.02); }
.tracker-date { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--muted); }
.status-pill { color: var(--amber); background: rgba(245,192,101,.08); border: 1px solid rgba(245,192,101,.25); border-radius: 999px; padding: 4px 8px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.timer-display { padding: 7px 0 0; text-align: center; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(34px, 5vw, 48px); font-variant-numeric: tabular-nums; font-weight: 700; letter-spacing: -.05em; }
.timer-display.running { color: var(--amber); }
.btn { min-height: 40px; padding: 9px 14px; border-radius: 12px; font-weight: 700; border: 1px solid transparent; }
.btn.primary { background: var(--amber); color: var(--on-amber); }
.btn.primary:hover { background: var(--amber-hover); }
.btn.subtle { background: var(--raised); border-color: var(--border); color: var(--text); }
.btn.subtle:hover { background: var(--active); }
.btn.danger { background: rgba(232,91,97,.11); color: #ff9296; border-color: rgba(232,91,97,.28); }
.btn.wide { width: 100%; }
.timer-btn { min-height: 52px; font-size: 16px; }
.link-btn { background: transparent; color: var(--muted); padding: 3px; font-size: 13px; }
.link-btn:hover { color: var(--text); }
.centered { justify-self: center; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { background: var(--card); border: 1px solid var(--border); border-radius: 17px; padding: 15px 16px; display: grid; gap: 3px; }
.metric-card span { font-size: 11px; color: var(--muted); }
.metric-card strong { color: var(--amber); font: 700 21px ui-monospace, SFMono-Regular, Menlo, monospace; }
.warning-box, .error-box { border-radius: 12px; padding: 11px 12px; font-size: 13px; }
.warning-box { background: rgba(245,192,101,.08); color: #f6cf8c; border: 1px solid rgba(245,192,101,.22); }
.error-box { background: rgba(232,91,97,.09); color: #ff9aa0; border: 1px solid rgba(232,91,97,.22); margin: 0; }
.sidebar-footer { margin-top: auto; padding-top: 10px; display: flex; justify-content: space-between; color: var(--dim); font-size: 11px; }
.mobile-pane-head { display: none; }
.history-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-left: 1px solid var(--divider); }
.history-head { padding: 25px 28px 15px; display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; }
.history-head h1, .mobile-pane-head h1 { margin: 0; font-size: 31px; line-height: 1; letter-spacing: -.035em; }
.eyebrow { margin: 0 0 7px; color: var(--dim); letter-spacing: .13em; font-size: 10px; font-weight: 800; }
.history-actions { display: flex; gap: 8px; }
.filter-bar { padding: 0 28px 15px; display: grid; grid-template-columns: minmax(180px, 1fr) auto auto; gap: 10px; align-items: center; border-bottom: 1px solid var(--divider); }
.filter-bar input { min-width: 0; }
.period-tabs { display: flex; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 3px; }
.period-tabs button, .period-step button { background: transparent; color: var(--muted); padding: 7px 9px; border-radius: 8px; font-size: 12px; font-weight: 700; }
.period-tabs button.active { background: var(--active); color: var(--text); }
.period-step { display: flex; align-items: center; justify-content: center; gap: 2px; color: var(--muted); white-space: nowrap; }
.period-step span { min-width: 112px; text-align: center; font-size: 12px; }
.period-step button:hover { color: var(--text); background: var(--raised); }
.history-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 28px 20px; }
.entries { display: grid; }
.day-group { min-width: 0; }
.day-heading { position: sticky; top: 0; z-index: 2; padding: 15px 0 8px; display: flex; justify-content: space-between; align-items: baseline; background: linear-gradient(var(--bg) 80%, transparent); }
.day-heading strong { font-size: 13px; }
.day-heading span { color: var(--dim); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.entry-row { width: 100%; display: grid; grid-template-columns: minmax(120px, .8fr) minmax(180px, 1.3fr) 120px 72px 28px; gap: 14px; align-items: center; padding: 12px 14px; margin-bottom: 6px; background: var(--card); border: 1px solid transparent; border-radius: 13px; text-align: left; }
.entry-row:hover { border-color: var(--border); background: var(--raised); }
.entry-client { min-width: 0; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-activity { min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-time { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.entry-duration { text-align: right; color: var(--amber); font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
.entry-chevron { color: var(--dim); font-size: 19px; }
.history-footer { flex: 0 0 50px; border-top: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; padding: 0 28px; color: var(--muted); font-size: 12px; }
.history-footer strong { color: var(--text); }
.history-footer span:last-child strong { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; margin-left: 8px; }
.empty-state { min-height: 55vh; display: grid; place-items: center; align-content: center; text-align: center; color: var(--muted); }
.empty-state h2 { color: var(--text); margin: 10px 0 4px; font-size: 18px; }
.empty-state p { margin: 0; font-size: 13px; }
.empty-icon { font-size: 40px; color: var(--dim); }
.load-more { display: block; margin: 18px auto 0; }
.modal { width: min(620px, calc(100vw - 28px)); max-height: calc(100dvh - 28px); padding: 0; border: 1px solid var(--border); border-radius: 20px; background: var(--card); color: var(--text); box-shadow: var(--shadow); }
.modal::backdrop { background: rgba(0,0,0,.68); backdrop-filter: blur(5px); }
.modal-wide { width: min(860px, calc(100vw - 28px)); }
.modal-card { padding: 22px; display: grid; gap: 16px; max-height: calc(100dvh - 30px); overflow-y: auto; }
.modal-card.compact { max-width: 520px; }
.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; }
.modal h2, .modal h3 { margin: 0; letter-spacing: -.025em; }
.modal h2 { font-size: 24px; }
.modal h3 { font-size: 15px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 5px; }
.modal-actions.split { justify-content: initial; }
.spacer { flex: 1; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.settings-grid section { display: grid; gap: 13px; align-content: start; }
.settings-grid h3 { padding-bottom: 4px; }
.check-row { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 500; }
.check-row input { width: 17px; height: 17px; accent-color: var(--amber); }
.admin-section { border-top: 1px solid var(--divider); padding-top: 18px; display: grid; gap: 10px; }
.section-title { display: flex; justify-content: space-between; align-items: center; }
.user-list { display: grid; gap: 7px; }
.user-row { display: grid; grid-template-columns: minmax(140px,1fr) auto auto auto; gap: 9px; align-items: center; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.user-row .user-meta { min-width: 0; display: grid; }
.user-row .user-meta strong, .user-row .user-meta span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.user-row .user-meta span { color: var(--muted); font-size: 11px; }
.role-badge { color: var(--muted); font-size: 11px; background: var(--raised); padding: 4px 7px; border-radius: 999px; }
.export-scope { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 11px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.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; }
.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; }
.mobile-only { display: none; }
.auth-body { display: grid; min-height: 100dvh; place-items: center; background: radial-gradient(circle at 50% 0, #1c1a16 0, var(--bg) 38%); padding: 20px; }
.auth-shell { width: min(440px, 100%); }
.auth-card { background: rgba(22,24,27,.96); border: 1px solid var(--border); border-radius: 24px; padding: 30px; box-shadow: var(--shadow); }
.auth-card h1 { margin: 0 0 7px; font-size: 30px; letter-spacing: -.035em; }
.auth-card .muted { margin: 0 0 22px; line-height: 1.5; }
.form-stack { display: grid; gap: 15px; }
.form-stack .btn { margin-top: 5px; }
.auth-card .error-box { margin-top: 15px; }
@media (max-width: 1050px) {
.filter-bar { grid-template-columns: 1fr auto; }
.period-step { grid-column: 1 / -1; justify-self: end; }
.entry-row { grid-template-columns: minmax(110px,.8fr) minmax(140px,1.2fr) 110px 68px 20px; gap: 9px; }
}
@media (max-width: 820px) {
.app-shell { overflow: hidden; }
.topbar { height: 58px; flex-basis: 58px; padding: 0 15px; }
.metric-inline, #export-open { display: none; }
.main-grid { display: block; overflow: hidden; }
.tracker-pane, .history-pane { height: calc(100dvh - 58px); border-left: 0; }
.tracker-pane { padding: 15px; }
.history-pane { display: none; }
body.mobile-history .tracker-pane { display: none; }
body.mobile-history .history-pane { display: flex; }
.mobile-pane-head { display: flex; align-items: center; justify-content: space-between; }
.mobile-only { display: inline-block; }
.history-head { padding: 16px 15px 12px; align-items: center; }
.history-title .eyebrow { display: none; }
.history-title h1 { font-size: 25px; margin-top: 4px; }
.history-actions #history-export { display: none; }
.filter-bar { padding: 0 15px 12px; display: flex; flex-wrap: wrap; }
.filter-bar > input { flex: 1 1 100%; }
.period-tabs { flex: 1 1 auto; overflow-x: auto; }
.period-step { flex: 1 1 100%; justify-content: space-between; }
.history-scroll { padding: 0 15px 16px; }
.entry-row { grid-template-columns: 1fr auto 18px; grid-template-areas: "client dur chevron" "activity time chevron"; gap: 4px 10px; padding: 12px; }
.entry-client { grid-area: client; }
.entry-activity { grid-area: activity; }
.entry-time { grid-area: time; }
.entry-duration { grid-area: dur; }
.entry-chevron { grid-area: chevron; align-self: center; }
.history-footer { padding: 0 15px; }
.settings-grid { grid-template-columns: 1fr; gap: 20px; }
.user-row { grid-template-columns: 1fr auto; }
.user-row .role-badge { justify-self: end; }
.two-col, .export-scope, .export-buttons { grid-template-columns: 1fr; }
.modal { width: calc(100vw - 12px); max-height: calc(100dvh - 12px); border-radius: 17px; }
.modal-card { max-height: calc(100dvh - 14px); padding: 18px; }
}
@media (max-width: 430px) {
.tracker-card { padding: 17px; }
.metric-grid { grid-template-columns: 1fr 1fr; }
.history-actions .btn { padding-inline: 10px; }
.auth-card { padding: 23px; }
}
+299
View File
@@ -0,0 +1,299 @@
const state = {
me: null,
csrf: '',
settings: null,
running: null,
entries: [],
clients: [],
totalCount: 0,
totalDuration: 0,
filter: { q: '', period: 'all', anchor: new Date(), offset: 0, limit: 120 },
timerTick: null,
};
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
async function api(url, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body !== undefined && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json';
if (state.csrf && options.method && !['GET','HEAD'].includes(options.method.toUpperCase())) headers['X-CSRF-Token'] = state.csrf;
const res = await fetch(url, { ...options, headers });
if (res.status === 401) { location.assign('/login'); throw new Error('Nicht angemeldet.'); }
const ct = res.headers.get('content-type') || '';
const body = res.status === 204 ? null : (ct.includes('application/json') ? await res.json().catch(() => ({})) : await res.text());
if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`);
return body;
}
function toast(msg) {
const el = $('#toast'); el.textContent = msg; el.hidden = false;
clearTimeout(toast._t); toast._t = setTimeout(() => { el.hidden = true; }, 2600);
}
function showError(sel, err) { const el = $(sel); el.textContent = err?.message || String(err); el.hidden = false; }
function hideError(sel) { $(sel).hidden = true; }
function pad(n) { return String(n).padStart(2, '0'); }
function duration(ms, withSeconds = false) {
ms = Math.max(0, ms || 0);
const sec = Math.floor(ms / 1000), h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${h}:${pad(m)}`;
}
function rounded(ms) {
const mins = Math.max(1, Number(state.settings?.rounding_minutes || 1));
if (mins <= 1) return Math.max(0, ms);
const step = mins * 60000;
return state.settings?.round_up ? Math.ceil(ms / step) * step : Math.round(ms / step) * step;
}
function clock(ms) {
const d = new Date(ms);
return new Intl.DateTimeFormat(state.settings?.language === 'en' ? 'en' : 'de-DE', { hour: '2-digit', minute: '2-digit', hour12: state.settings?.time_format === '12' }).format(d);
}
function localDateTimeValue(ms) {
const d = new Date(ms); const off = d.getTimezoneOffset();
return new Date(d.getTime() - off * 60000).toISOString().slice(0,16);
}
function msFromLocalValue(v) { return v ? new Date(v).getTime() : null; }
function dayKey(ms) { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
function dayTitle(ms) {
const d = new Date(ms), now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
if (target === today) return 'Heute';
const yd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); yd.setDate(yd.getDate()-1);
if (target === yd.getTime()) return 'Gestern';
return new Intl.DateTimeFormat('de-DE', { weekday:'long', day:'2-digit', month:'long', year:'numeric' }).format(d);
}
function initials(s) { return (s || '?').trim().split(/\s+/).slice(0,2).map(x=>x[0]?.toUpperCase()||'').join('') || '?'; }
function escapeText(s) { const x=document.createElement('span'); x.textContent=s??''; return x.innerHTML; }
function rangeForFilter() {
const { period, anchor } = state.filter;
if (period === 'all') return { from:0, to:0, label:'Alle Zeiten' };
let from, to;
const d = new Date(anchor); d.setHours(0,0,0,0);
if (period === 'day') { from = d; to = new Date(d); to.setDate(to.getDate()+1); }
if (period === 'week') { const wd = (d.getDay()+6)%7; d.setDate(d.getDate()-wd); from = new Date(d); to = new Date(d); to.setDate(to.getDate()+7); }
if (period === 'month') { from = new Date(d.getFullYear(),d.getMonth(),1); to = new Date(d.getFullYear(),d.getMonth()+1,1); }
if (period === 'year') { from = new Date(d.getFullYear(),0,1); to = new Date(d.getFullYear()+1,0,1); }
let label='';
if (period === 'day') label = new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(from);
if (period === 'week') label = `${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit'}).format(from)} ${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(new Date(to.getTime()-1))}`;
if (period === 'month') label = new Intl.DateTimeFormat('de-DE',{month:'long',year:'numeric'}).format(from);
if (period === 'year') label = String(from.getFullYear());
return { from: from.getTime(), to: to.getTime(), label };
}
function stepPeriod(delta) {
const d = new Date(state.filter.anchor), p = state.filter.period;
if (p === 'day') d.setDate(d.getDate()+delta);
if (p === 'week') d.setDate(d.getDate()+7*delta);
if (p === 'month') d.setMonth(d.getMonth()+delta);
if (p === 'year') d.setFullYear(d.getFullYear()+delta);
state.filter.anchor = d; state.filter.offset = 0; refreshEntries();
}
function queryString(includePaging = true) {
const r = rangeForFilter(); const p = new URLSearchParams();
if (state.filter.q) p.set('q', state.filter.q);
if (r.from) p.set('from', r.from); if (r.to) p.set('to', r.to);
if (includePaging) { p.set('limit', state.filter.limit); p.set('offset', state.filter.offset); }
return p.toString();
}
async function init() {
try {
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 || [];
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if ((!state.settings.timezone || state.settings.timezone === 'UTC') && tz && tz !== 'UTC') {
state.settings.timezone = tz;
try { state.settings = await api('/api/settings', { method:'PUT', body:JSON.stringify(state.settings) }); } catch (_) {}
}
renderIdentity(); renderClients(); renderRunning(); fillSettings();
await Promise.all([refreshEntries(), refreshTotals()]);
startTicker();
} catch (e) { toast(e.message); }
}
function renderIdentity() {
$('#user-label').textContent = state.me.display_name || state.me.username;
$('#avatar-text').textContent = initials(state.me.display_name || state.me.username);
}
function renderClients() { $('#client-list').innerHTML = state.clients.map(x => `<option value="${escapeText(x)}"></option>`).join(''); }
function renderRunning() {
const e = state.running;
$('#running-hint').hidden = !e; $('#edit-running').hidden = !e;
$('#timer-button').textContent = e ? '■ Stop' : '▶ Start';
$('#timer-display').classList.toggle('running', !!e);
$('#client-input').disabled = !!e;
if (e) { $('#client-input').value=e.client||''; $('#activity-input').value=e.activity||''; }
updateTicker();
}
function startTicker() { clearInterval(state.timerTick); state.timerTick = setInterval(updateTicker, 1000); updateTicker(); }
function updateTicker() {
const e=state.running, elapsed=e ? Date.now()-e.start_ms : 0;
$('#timer-display').textContent=duration(elapsed,true);
const warn=!!(e && state.settings?.long_run_reminder && elapsed>8*3600000); $('#long-running-warning').hidden=!warn;
}
async function timerToggle() {
try {
if (!state.running) {
const x = await api('/api/entries/start', { method:'POST', body:JSON.stringify({ client:$('#client-input').value, activity:$('#activity-input').value, start_ms:Date.now() }) });
state.running=x; renderRunning(); toast('Timer gestartet.');
} else {
await saveRunningActivity();
await api(`/api/entries/${state.running.id}/stop`, { method:'POST', body:JSON.stringify({ end_ms:Date.now() }) });
state.running=null; $('#client-input').disabled=false; $('#client-input').value=''; $('#activity-input').value=''; renderRunning(); toast('Timer gestoppt.');
await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);
}
} catch(e) { toast(e.message); }
}
async function saveRunningActivity() {
if (!state.running) return;
const activity=$('#activity-input').value;
if (activity===state.running.activity) return;
const x=await api(`/api/entries/${state.running.id}`,{method:'PUT',body:JSON.stringify({client:state.running.client,activity,start_ms:state.running.start_ms,end_ms:null})});
state.running=x;
}
async function refreshClients(){const x=await api('/api/clients');state.clients=x.clients||[];renderClients();}
async function refreshTotals() {
const now=new Date(); const today=new Date(now.getFullYear(),now.getMonth(),now.getDate());
const week=new Date(today); week.setDate(week.getDate()-((week.getDay()+6)%7));
try {
const [t,w]=await Promise.all([
api(`/api/entries?from=${today.getTime()}&to=${new Date(today.getFullYear(),today.getMonth(),today.getDate()+1).getTime()}&limit=1&offset=0`),
api(`/api/entries?from=${week.getTime()}&limit=1&offset=0`)
]);
$('#today-total').textContent=duration(t.total_duration_ms);
$('#week-total').textContent=duration(w.total_duration_ms);
$('#header-week').textContent=duration(w.total_duration_ms);
$('#week-badge').hidden=!state.settings.show_week_total;
} catch(e){ console.warn(e); }
}
async function refreshEntries(reset=true) {
if(reset){state.filter.offset=0;state.entries=[];}
const range=rangeForFilter(); $('#period-label').textContent=range.label;
try {
const p=await api('/api/entries?'+queryString(true));
state.totalCount=p.total_count;state.totalDuration=p.total_duration_ms;
state.entries=reset?p.entries:[...state.entries,...p.entries];
renderEntries();
} catch(e){toast(e.message);}
}
function renderEntries() {
$('#result-count').textContent=state.totalCount; $('#result-total').textContent=duration(state.totalDuration);
$('#empty-state').hidden=state.totalCount!==0;
$('#load-more').hidden=state.entries.length>=state.totalCount;
const groups=[]; let current=null;
for(const e of state.entries){const k=dayKey(e.start_ms);if(!current||current.key!==k){current={key:k,start:e.start_ms,items:[]};groups.push(current)}current.items.push(e)}
$('#entries').innerHTML=groups.map(g=>{
const dayTotal=g.items.reduce((n,e)=>n+rounded((e.end_ms||e.start_ms)-e.start_ms),0);
return `<section class="day-group"><div class="day-heading"><strong>${escapeText(dayTitle(g.start))}</strong><span>${duration(dayTotal)}</span></div>${g.items.map(entryHTML).join('')}</section>`;
}).join('');
$$('.entry-row').forEach(el=>el.addEventListener('click',()=>openEntry(el.dataset.id)));
$$('.day-heading').forEach(el=>el.style.position=state.settings.sticky_days?'sticky':'static');
}
function entryHTML(e){return `<button class="entry-row" data-id="${e.id}"><span class="entry-client">${escapeText(e.client||'Kein Kunde')}</span><span class="entry-activity">${escapeText(e.activity||'—')}</span><span class="entry-time">${clock(e.start_ms)}${clock(e.end_ms)}</span><span class="entry-duration">${duration(rounded(e.end_ms-e.start_ms))}</span><span class="entry-chevron"></span></button>`}
function openEntry(id=null, running=false) {
hideError('#entry-error');
let e = null;
if (running) e=state.running; else if(id) e=state.entries.find(x=>x.id===id);
$('#entry-id').value=e?.id||''; $('#edit-client').value=e?.client||''; $('#edit-activity').value=e?.activity||'';
const now=Date.now(); $('#edit-start').value=localDateTimeValue(e?.start_ms||now); $('#edit-end').value=e?.end_ms?localDateTimeValue(e.end_ms):(running?'':localDateTimeValue(now+3600000));
$('#entry-dialog-title').textContent=e?'Eintrag bearbeiten':'Zeit nachtragen'; $('#delete-entry').hidden=!e;
updateEditDuration(); $('#entry-dialog').showModal();
}
function updateEditDuration(){const s=msFromLocalValue($('#edit-start').value),e=msFromLocalValue($('#edit-end').value);$('#edit-duration').textContent=s&&e&&e>=s?duration(rounded(e-s)):''}
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'}
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 changeOwnPassword(ev){
ev.preventDefault(); hideError('#account-password-error');
try {
await api('/api/account/password',{method:'POST',body:JSON.stringify({current_password:$('#current-password').value,new_password:$('#own-new-password').value})});
location.assign('/login');
} catch(e){ showError('#account-password-error',e); }
}
async function loadUsers(){try{const x=await api('/api/admin/users');$('#user-list').innerHTML=x.users.map(u=>`<div class="user-row" data-user-id="${u.id}"><div class="user-meta"><strong>${escapeText(u.display_name||u.username)}</strong><span>${escapeText(u.username)}</span></div><span class="role-badge">${u.role==='admin'?'Admin':'Benutzer'}</span><button type="button" class="btn subtle user-password" ${u.active?'':'disabled'}>Passwort</button><button type="button" class="btn ${u.active?'danger':'subtle'} user-toggle" ${u.id===state.me.id?'disabled':''}>${u.active?'Deaktivieren':'Aktivieren'}</button></div>`).join('');$$('.user-password').forEach(b=>b.addEventListener('click',()=>openPassword(b.closest('.user-row').dataset.userId)));$$('.user-toggle').forEach(b=>b.addEventListener('click',()=>toggleUser(b.closest('.user-row').dataset.userId,b.textContent==='Aktivieren')))}catch(e){showError('#settings-error',e)}}
function openPassword(id){$('#password-user-id').value=id;$('#reset-password').value='';hideError('#password-error');$('#password-dialog').showModal()}
async function toggleUser(id,active){try{await api(`/api/admin/users/${id}`,{method:'PATCH',body:JSON.stringify({active})});await loadUsers();toast(active?'Benutzer aktiviert.':'Benutzer deaktiviert.')}catch(e){showError('#settings-error',e)}}
async function createUser(ev){ev.preventDefault();hideError('#user-error');const body={username:$('#new-username').value,displayName:$('#new-display-name').value,password:$('#new-password').value,role:$('#new-role').value};try{await api('/api/admin/users',{method:'POST',body:JSON.stringify(body)});$('#user-dialog').close();ev.currentTarget.reset();await loadUsers();toast('Benutzer angelegt.')}catch(e){showError('#user-error',e)}}
async function resetPassword(ev){ev.preventDefault();hideError('#password-error');try{await api(`/api/admin/users/${$('#password-user-id').value}/password`,{method:'POST',body:JSON.stringify({password:$('#reset-password').value})});$('#password-dialog').close();toast('Passwort zurückgesetzt.')}catch(e){showError('#password-error',e)}}
function dateInputValue(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
function localDayStartMS(value) {
if (!value) return 0;
const [y,m,d]=value.split('-').map(Number);
return new Date(y,m-1,d,0,0,0,0).getTime();
}
function exportParams(includePresentation=true) {
const p=new URLSearchParams();
const scope=$('input[name="export-scope"]:checked')?.value||'view';
if(scope==='view') {
const r=rangeForFilter();
if(state.filter.q) p.set('q',state.filter.q);
if(r.from) p.set('from',r.from);
if(r.to) p.set('to',r.to);
} else {
const from=localDayStartMS($('#export-from').value);
const toStart=localDayStartMS($('#export-to').value);
if(from) p.set('from',from);
if(toStart) { const end=new Date(toStart); end.setDate(end.getDate()+1); p.set('to',end.getTime()); }
}
if(includePresentation) {
if($('#export-sort').value==='asc') p.set('sort','asc');
if($('#export-compact').checked) p.set('compact','1');
}
return p;
}
let exportPreviewSeq=0;
async function updateExportPreview() {
const seq=++exportPreviewSeq;
const scope=$('input[name="export-scope"]:checked')?.value||'view';
$('#export-custom').hidden=scope!=='custom';
if(scope==='view') { $('#export-preview').textContent=`${state.totalCount} Einträge · ${duration(state.totalDuration)}`; return; }
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; }
try {
const p=exportParams(false); p.set('limit','1'); p.set('offset','0');
const result=await api('/api/entries?'+p.toString());
if(seq===exportPreviewSeq) $('#export-preview').textContent=`${result.total_count} Einträge · ${duration(result.total_duration_ms)}`;
} catch(e) { if(seq===exportPreviewSeq) $('#export-preview').textContent=e.message; }
}
function openExport(){
const now=new Date(), first=new Date(now.getFullYear(),now.getMonth(),1);
if(!$('#export-from').value) $('#export-from').value=dateInputValue(first);
if(!$('#export-to').value) $('#export-to').value=dateInputValue(now);
updateExportPreview(); $('#export-dialog').showModal();
}
function downloadExport(type){
const p=exportParams(true), a=document.createElement('a');
a.href=`/api/export.${type}${p.toString()?'?'+p.toString():''}`; a.click();
}
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')}});
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'));
}
wireEvents(); init();
+174
View File
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0f11">
<title>Pocketwatch</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand"><span class="brand-dot"></span><span>pocketwatch</span></div>
<div class="topbar-actions">
<div id="week-badge" class="metric-inline"><strong id="header-week">0:00</strong><span>Diese Woche</span></div>
<button class="icon-btn" id="export-open" title="Exportieren" aria-label="Exportieren"></button>
<button class="icon-btn" id="settings-open" title="Einstellungen" aria-label="Einstellungen"></button>
<button class="avatar-btn" id="account-menu" title="Abmelden"><span id="avatar-text">U</span></button>
</div>
</header>
<main class="main-grid">
<aside class="tracker-pane">
<div class="mobile-pane-head"><h1>Erfassen</h1><button class="link-btn" id="mobile-history">Verlauf →</button></div>
<section class="tracker-card">
<div class="tracker-date"><span id="today-label">Heute</span><span id="running-hint" class="status-pill" hidden>läuft</span></div>
<label class="field-label">Kunde
<input id="client-input" list="client-list" maxlength="200" placeholder="Kunde / Projekt">
<datalist id="client-list"></datalist>
</label>
<label class="field-label">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>
<button id="timer-button" class="btn primary timer-btn">▶ Start</button>
<button id="edit-running" class="link-btn centered" hidden>Laufenden Eintrag bearbeiten</button>
</section>
<div class="metric-grid">
<article class="metric-card"><span>Heute</span><strong id="today-total">0:00</strong></article>
<article class="metric-card"><span>Diese Woche</span><strong id="week-total">0:00</strong></article>
</div>
<div id="long-running-warning" class="warning-box" hidden>Der Timer läuft seit mehr als 8 Stunden. Vielleicht vergessen zu stoppen?</div>
<footer class="sidebar-footer"><span id="user-label"></span><span>SQLite · Go</span></footer>
</aside>
<section class="history-pane" id="history-pane">
<div class="history-head">
<div class="history-title"><button class="link-btn mobile-only" id="mobile-track">← Erfassen</button><p class="eyebrow">ZEITEN</p><h1>Verlauf</h1></div>
<div class="history-actions">
<button class="btn subtle" id="add-entry">+ Nachtragen</button>
<button class="btn subtle" id="history-export">Export</button>
</div>
</div>
<div class="filter-bar">
<input id="search-input" type="search" placeholder="Kunde oder Tätigkeit suchen…" aria-label="Verlauf durchsuchen">
<div class="period-tabs" role="group" aria-label="Zeitraum">
<button data-period="day">Tag</button><button data-period="week">Woche</button><button data-period="month">Monat</button><button data-period="year">Jahr</button><button data-period="all" class="active">Alle</button>
</div>
<div class="period-step"><button id="period-prev" aria-label="Vorheriger Zeitraum"></button><span id="period-label">Alle Zeiten</span><button id="period-next" aria-label="Nächster Zeitraum"></button></div>
</div>
<div class="history-scroll">
<div id="empty-state" class="empty-state" hidden><div class="empty-icon"></div><h2>Noch keine Einträge</h2><p>Starte links einen Timer oder trage eine Zeit nach.</p></div>
<div id="entries" class="entries"></div>
<button id="load-more" class="btn subtle load-more" hidden>Weitere laden</button>
</div>
<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>
</div>
<dialog id="entry-dialog" class="modal">
<form id="entry-form" method="dialog" class="modal-card">
<div class="modal-head"><div><p class="eyebrow">ZEITEINTRAG</p><h2 id="entry-dialog-title">Eintrag bearbeiten</h2></div><button type="button" class="icon-btn" data-close="entry-dialog">×</button></div>
<input type="hidden" id="entry-id">
<label>Kunde<input id="edit-client" maxlength="200" list="client-list"></label>
<label>Tätigkeit<textarea id="edit-activity" rows="4" maxlength="4000"></textarea></label>
<div class="two-col"><label>Start<input id="edit-start" type="datetime-local" required></label><label>Ende<input id="edit-end" type="datetime-local"></label></div>
<p class="muted small">Dauer: <strong id="edit-duration"></strong></p>
<p id="entry-error" class="error-box" hidden></p>
<div class="modal-actions split"><button type="button" id="delete-entry" class="btn danger" hidden>Löschen</button><div class="spacer"></div><button type="button" class="btn subtle" data-close="entry-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
</form>
</dialog>
<dialog id="settings-dialog" class="modal modal-wide">
<form id="settings-form" method="dialog" class="modal-card">
<div class="modal-head"><div><p class="eyebrow">POCKETWATCH</p><h2>Einstellungen</h2></div><button type="button" class="icon-btn" data-close="settings-dialog">×</button></div>
<div class="settings-grid">
<section>
<h3>Anzeige & Auswertung</h3>
<label>Zeitformat<select id="setting-time-format"><option value="24">24 Stunden</option><option value="12">12 Stunden</option></select></label>
<label>Rundung in Minuten<input id="setting-rounding" type="number" min="1" max="60"></label>
<label class="check-row"><input id="setting-round-up" type="checkbox"><span>Immer aufrunden</span></label>
<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>
</section>
<section>
<h3>Export</h3>
<label>Name im PDF<input id="setting-export-name" maxlength="120"></label>
<label>Zeitzone für Exporte<input id="setting-timezone" maxlength="80" placeholder="Europe/Berlin"></label>
<label class="check-row"><input id="setting-export-date" type="checkbox"><span>Exportdatum im PDF</span></label>
<p class="muted small">Tipp: Die Zeitzone wird beim ersten Öffnen automatisch aus dem Browser übernommen.</p>
<button type="button" class="btn subtle" id="change-own-password">Eigenes Passwort ändern</button>
</section>
</div>
<section id="admin-section" class="admin-section" hidden>
<div class="section-title"><div><p class="eyebrow">ADMIN</p><h3>Benutzer</h3></div><button type="button" id="add-user" class="btn subtle">+ Benutzer</button></div>
<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>
</form>
</dialog>
<dialog id="user-dialog" class="modal">
<form id="user-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Benutzer anlegen</h2><button type="button" class="icon-btn" data-close="user-dialog">×</button></div>
<label>Benutzername<input id="new-username" minlength="3" maxlength="64" required></label>
<label>Anzeigename<input id="new-display-name" maxlength="120"></label>
<label>Passwort<input id="new-password" type="password" minlength="10" required></label>
<label>Rolle<select id="new-role"><option value="user">Benutzer</option><option value="admin">Administrator</option></select></label>
<p id="user-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="user-dialog">Abbrechen</button><button type="submit" class="btn primary">Anlegen</button></div>
</form>
</dialog>
<dialog id="account-password-dialog" class="modal">
<form id="account-password-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Eigenes Passwort ändern</h2><button type="button" class="icon-btn" data-close="account-password-dialog">×</button></div>
<label>Aktuelles Passwort<input id="current-password" type="password" autocomplete="current-password" required></label>
<label>Neues Passwort<input id="own-new-password" type="password" minlength="10" autocomplete="new-password" required></label>
<p id="account-password-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="account-password-dialog">Abbrechen</button><button type="submit" class="btn primary">Ändern</button></div>
</form>
</dialog>
<dialog id="password-dialog" class="modal">
<form id="password-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Passwort zurücksetzen</h2><button type="button" class="icon-btn" data-close="password-dialog">×</button></div>
<input type="hidden" id="password-user-id">
<label>Neues Passwort<input id="reset-password" type="password" minlength="10" required></label>
<p id="password-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="password-dialog">Abbrechen</button><button type="submit" class="btn primary">Zurücksetzen</button></div>
</form>
</dialog>
<dialog id="export-dialog" class="modal">
<div class="modal-card compact">
<div class="modal-head"><div><p class="eyebrow">EXPORT</p><h2>Zeiten exportieren</h2></div><button type="button" class="icon-btn" data-close="export-dialog">×</button></div>
<p class="muted">Rundung und Zeitzone werden aus deinen Einstellungen übernommen.</p>
<div class="export-scope" role="radiogroup" aria-label="Exportumfang">
<label class="check-row"><input type="radio" name="export-scope" value="view" checked><span>Aktuelle Ansicht</span></label>
<label class="check-row"><input type="radio" name="export-scope" value="custom"><span>Eigener Zeitraum</span></label>
</div>
<div id="export-custom" class="two-col" hidden>
<label>Von<input id="export-from" type="date"></label>
<label>Bis<input id="export-to" type="date"></label>
</div>
<div class="two-col">
<label>Sortierung<select id="export-sort"><option value="desc">Neueste zuerst</option><option value="asc">Älteste zuerst</option></select></label>
<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>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/static/app.js" defer></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0f11">
<title>Pocketwatch</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="auth-body">
<main class="auth-shell">
<section class="auth-card" aria-labelledby="auth-title">
<div class="brand brand-large"><span class="brand-dot"></span><span>pocketwatch</span></div>
<div id="login-panel">
<p class="eyebrow">SELF-HOSTED TIME TRACKING</p>
<h1 id="auth-title">Anmelden</h1>
<p class="muted">Deine Zeiten bleiben auf deinem Server.</p>
<form id="login-form" class="form-stack">
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<button class="btn primary wide" type="submit">Anmelden</button>
</form>
</div>
<div id="setup-panel" hidden>
<p class="eyebrow">ERSTEINRICHTUNG</p>
<h1>Admin anlegen</h1>
<p class="muted">Der erste Benutzer wird Administrator. Weitere Konten legst du später in den Einstellungen an.</p>
<form id="setup-form" class="form-stack">
<label>Benutzername<input name="username" minlength="3" maxlength="64" autocomplete="username" required autofocus></label>
<label>Anzeigename<input name="displayName" maxlength="120" autocomplete="name"></label>
<label>Passwort<input name="password" type="password" minlength="10" autocomplete="new-password" required></label>
<button class="btn primary wide" type="submit">Instanz einrichten</button>
</form>
</div>
<p id="auth-error" class="error-box" role="alert" hidden></p>
</section>
</main>
<script src="/static/login.js" defer></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
const errorBox = document.querySelector('#auth-error');
const loginPanel = document.querySelector('#login-panel');
const setupPanel = document.querySelector('#setup-panel');
function showError(message) {
errorBox.textContent = message;
errorBox.hidden = !message;
}
async function request(url, options = {}) {
const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
const body = res.status === 204 ? null : await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`);
return body;
}
(async () => {
try {
const x = await request('/api/bootstrap');
loginPanel.hidden = x.needs_setup;
setupPanel.hidden = !x.needs_setup;
} catch (e) { showError(e.message); }
})();
document.querySelector('#login-form').addEventListener('submit', async (ev) => {
ev.preventDefault(); showError('');
const fd = new FormData(ev.currentTarget);
try {
await request('/api/login', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), password: fd.get('password') }) });
location.assign('/');
} catch (e) { showError(e.message); }
});
document.querySelector('#setup-form').addEventListener('submit', async (ev) => {
ev.preventDefault(); showError('');
const fd = new FormData(ev.currentTarget);
try {
await request('/api/setup', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), displayName: fd.get('displayName'), password: fd.get('password') }) });
location.assign('/');
} catch (e) { showError(e.message); }
});