Add self-hosted combined timesheet server

This commit is contained in:
2026-09-14 13:14:47 +02:00
commit 25eb7f50e6
17 changed files with 584 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Bind directly to the Tailscale address in production.
BIND_IP=100.103.83.12
POSTGRES_DB=time_track
POSTGRES_USER=time_track
POSTGRES_PASSWORD=replace-with-a-long-random-password
ADMIN_PASSWORD=replace-with-a-long-random-password
SYNC_API_TOKEN=replace-with-a-long-random-token
SESSION_SECRET=replace-with-a-long-random-session-secret
APP_TIMEZONE=Europe/Vienna
OLLAMA_BASE_URL=http://100.103.83.12:11345
OLLAMA_MODEL=qwen3:8b
+4
View File
@@ -0,0 +1,4 @@
.env
__pycache__/
.pytest_cache/
*.pyc
+6
View File
@@ -0,0 +1,6 @@
[submodule "time_track"]
path = time_track
url = http://100.103.83.12:3003/fegger/waybar_time_track.git
[submodule "zed_time_tracker"]
path = zed_time_tracker
url = http://100.103.83.12:3003/fegger/zed_time_tracker.git
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/srv
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN useradd --system --create-home appuser && mkdir -p /data/call-imports && chown -R appuser:appuser /srv /data
USER appuser
EXPOSE 3000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]
+56
View File
@@ -0,0 +1,56 @@
# Time Track Server
A self-hosted, single-user combined timesheet server for `time_track` manual intervals and `zed_time_tracker` activity. It is designed for private access over Tailscale and groups days in `Europe/Vienna` by default.
## What is implemented
- Docker Compose deployment: FastAPI app and private PostgreSQL database.
- Password-protected single-user web UI for browsing combined entries, filtering dates, adding manual time, and accepting/rejecting Zed suggestions.
- Token-authenticated, idempotent ingestion endpoints for both local trackers.
- Explicit `time_track` intervals are accepted time; Zed heartbeat blocks are **suggested only**, never included in accepted totals until approved.
- PDF call-log upload with durable source-file storage and text extraction. A provider-specific parser is intentionally deferred until a redacted mobile-provider sample statement establishes the layout.
## Deploy
```sh
cp .env.example .env
# Generate three distinct high-entropy values for POSTGRES_PASSWORD, ADMIN_PASSWORD,
# SYNC_API_TOKEN, and SESSION_SECRET. Keep .env private.
docker compose up --build -d
```
Set `BIND_IP=100.103.83.12` to listen only on the Tailscale address. Browse to `http://100.103.83.12:3008` and log in using `ADMIN_PASSWORD`.
`postgres` is not published to the host. Docker volumes `postgres_data` and `call_imports` contain the state that must be backed up.
## Connect `time_track`
```sh
export TIME_TRACK_SERVER_URL=http://100.103.83.12:3008
export TIME_TRACK_SERVER_TOKEN='the SYNC_API_TOKEN value'
export TIME_TRACK_DEVICE_ID='desktop-1'
python3 time_track/time_track.py sync
```
## Connect the Zed tracker
```sh
export ZED_HOURS_SERVER_URL=http://100.103.83.12:3008
export ZED_HOURS_SERVER_TOKEN='the SYNC_API_TOKEN value'
export ZED_HOURS_DEVICE_ID='desktop-1'
python3 zed_time_tracker/sync.py --project-root /path/to/project --project-slug customer-project
```
The Zed client sends a project slug rather than local absolute project paths. Repeated requests are safe: source IDs and server uniqueness rules prevent duplicate events.
## Call PDFs and Ollama
The initial upload path accepts mobile-provider PDF call-detail records and safely records extracted text, including a clear `needs_ocr` status for image-only PDFs. It deliberately does **not** generate timesheet durations until a parser is implemented and tested against redacted sample PDFs from the provider.
The Compose configuration reserves the local Ollama endpoint `http://100.103.83.12:11345` and recommends `qwen3:8b` for optional, review-only task/project suggestions. No Ollama requests are made in this initial release because deterministic PDF parsing and phone matching must come first.
## Operational notes
- Use an application password even on Tailscale; the network is not the sole access-control layer.
- Back up the Postgres volume before upgrades. A simple host backup is `docker compose exec -T postgres pg_dump -U time_track time_track > backup.sql`.
- HTTPS is not configured: Tailscale encrypts node-to-node traffic. If browser HTTPS is required, add a Tailscale-aware reverse proxy or Tailscale Serve in front of port 3008.
View File
+20
View File
@@ -0,0 +1,20 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./time_track.db")
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
class Base(DeclarativeBase):
pass
def get_db():
db: Session = SessionLocal()
try:
yield db
finally:
db.close()
+221
View File
@@ -0,0 +1,221 @@
import os
import secrets
import shutil
from datetime import datetime
from pathlib import Path
from typing import Annotated
from uuid import uuid4
import pdfplumber
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pypdf import PdfReader
from sqlalchemy import select
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from .database import Base, engine, get_db
from .models import CallImport, RawEvent, TimeEntry
from .schemas import TimeTrackSync, ZedSync
from .services import TZ, derive_zed_suggestions, overlaps_accepted, parse_timestamp
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Europe/Vienna")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
SYNC_API_TOKEN = os.environ.get("SYNC_API_TOKEN", "")
UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "./uploads"))
app = FastAPI(title="Time Track Server", version="0.1.0")
app.add_middleware(SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET", "development-only-change-me"), https_only=False)
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
@app.on_event("startup")
def startup() -> None:
if not ADMIN_PASSWORD or not SYNC_API_TOKEN:
raise RuntimeError("ADMIN_PASSWORD and SYNC_API_TOKEN must be configured")
Base.metadata.create_all(bind=engine)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def require_api(request: Request) -> None:
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer ") or not secrets.compare_digest(authorization[7:], SYNC_API_TOKEN):
raise HTTPException(status_code=401, detail="Valid bearer token required")
def require_web(request: Request) -> None:
if not request.session.get("authenticated"):
raise HTTPException(status_code=401, detail="Login required")
def insert_raw_event(db: Session, source: str, device_id: str, external_id: str, project_slug: str | None, occurred_at: datetime | None, payload: dict) -> bool:
existing = db.scalar(select(RawEvent.id).where(
RawEvent.source == source, RawEvent.device_id == device_id, RawEvent.external_id == external_id,
))
if existing is not None:
return False
db.add(RawEvent(source=source, device_id=device_id, external_id=external_id,
project_slug=project_slug, occurred_at=occurred_at, payload=payload))
return True
@app.get("/healthz")
def healthz() -> dict:
return {"status": "ok", "timezone": APP_TIMEZONE}
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse(request, "login.html", {"error": None})
@app.post("/login", response_class=HTMLResponse)
def login(request: Request, password: Annotated[str, Form()]):
if not secrets.compare_digest(password, ADMIN_PASSWORD):
return templates.TemplateResponse(request, "login.html", {"error": "Invalid password"}, status_code=401)
request.session["authenticated"] = True
return RedirectResponse("/", status_code=303)
@app.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@app.post("/api/v1/sync/time-track", dependencies=[Depends(require_api)])
def sync_time_track(payload: TimeTrackSync, db: Session = Depends(get_db)) -> dict:
inserted = 0
for item in payload.entries:
start_at, end_at = parse_timestamp(item.start), parse_timestamp(item.end)
if end_at < start_at:
raise HTTPException(422, "Entry end must not precede start")
if insert_raw_event(db, "time_track", payload.device_id, item.id, None, start_at, item.model_dump()):
db.add(TimeEntry(source="time_track", device_id=payload.device_id, external_id=item.id,
kind="tracked", status="accepted", start_at=start_at, end_at=end_at,
duration_seconds=item.duration, task=item.task))
inserted += 1
db.commit()
return {"received": len(payload.entries), "inserted": inserted}
@app.post("/api/v1/sync/zed-heartbeats", dependencies=[Depends(require_api)])
def sync_zed_heartbeats(payload: ZedSync, db: Session = Depends(get_db)) -> dict:
inserted = 0
for item in payload.records:
occurred_at = parse_timestamp(item.timestamp)
record = item.model_dump(by_alias=True)
if insert_raw_event(db, "zed_heartbeat", payload.device_id, item.source_id, payload.project_slug, occurred_at, record):
inserted += 1
db.flush()
blocks = derive_zed_suggestions(db, payload.device_id, payload.project_slug)
db.commit()
return {"received": len(payload.records), "inserted": inserted, "suggestion_blocks": blocks}
@app.post("/api/v1/sync/zed-commits", dependencies=[Depends(require_api)])
def sync_zed_commits(payload: ZedSync, db: Session = Depends(get_db)) -> dict:
inserted = 0
for item in payload.records:
occurred_at = parse_timestamp(item.timestamp)
if insert_raw_event(db, "zed_commit", payload.device_id, item.source_id, payload.project_slug, occurred_at, item.model_dump(by_alias=True)):
inserted += 1
db.commit()
return {"received": len(payload.records), "inserted": inserted}
@app.get("/", response_class=HTMLResponse)
def index(request: Request, start: str | None = None, end: str | None = None, db: Session = Depends(get_db)):
require_web(request)
query = select(TimeEntry).order_by(TimeEntry.start_at.desc())
entries = db.scalars(query).all()
if start:
start_date = datetime.fromisoformat(start).replace(tzinfo=TZ)
entries = [e for e in entries if e.start_at >= start_date]
if end:
end_date = datetime.fromisoformat(end).replace(tzinfo=TZ).replace(hour=23, minute=59, second=59)
entries = [e for e in entries if e.start_at <= end_date]
accepted = sum(e.duration_seconds for e in entries if e.status == "accepted")
suggested = sum(e.duration_seconds for e in entries if e.status == "suggested")
return templates.TemplateResponse(request, "index.html", {
"entries": entries, "accepted": accepted, "suggested": suggested,
"start": start or "", "end": end or "", "timezone": APP_TIMEZONE, "display_tz": TZ,
"format_seconds": lambda seconds: f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m",
"overlaps_accepted": lambda entry: overlaps_accepted(db, entry),
})
@app.post("/entries/manual")
def create_manual(request: Request, start_at: Annotated[str, Form()], end_at: Annotated[str, Form()], task: Annotated[str, Form()] = "", project_slug: Annotated[str, Form()] = "", db: Session = Depends(get_db)):
require_web(request)
start, end = parse_timestamp(start_at), parse_timestamp(end_at)
if end <= start:
raise HTTPException(422, "End must be after start")
db.add(TimeEntry(source="manual", device_id="server", external_id=str(uuid4()), kind="manual", status="accepted",
start_at=start, end_at=end, duration_seconds=int((end - start).total_seconds()), task=task, project_slug=project_slug or None))
db.commit()
return RedirectResponse("/", status_code=303)
@app.post("/entries/{entry_id}/edit")
def edit_entry(entry_id: int, request: Request, start_at: Annotated[str, Form()], end_at: Annotated[str, Form()], task: Annotated[str, Form()] = "", project_slug: Annotated[str, Form()] = "", notes: Annotated[str, Form()] = "", db: Session = Depends(get_db)):
require_web(request)
entry = db.get(TimeEntry, entry_id)
if not entry:
raise HTTPException(404, "Entry not found")
start, end = parse_timestamp(start_at), parse_timestamp(end_at)
if end <= start:
raise HTTPException(422, "End must be after start")
entry.start_at = start
entry.end_at = end
entry.duration_seconds = int((end - start).total_seconds())
entry.task = task
entry.project_slug = project_slug or None
entry.notes = notes
# Editing an inference explicitly makes it a durable manual timesheet row.
if entry.kind == "zed_inferred":
entry.kind = "manual"
db.commit()
return RedirectResponse("/", status_code=303)
@app.post("/entries/{entry_id}/status")
def set_entry_status(entry_id: int, request: Request, status: Annotated[str, Form()], db: Session = Depends(get_db)):
require_web(request)
if status not in {"accepted", "rejected", "suggested"}:
raise HTTPException(422, "Invalid status")
entry = db.get(TimeEntry, entry_id)
if not entry:
raise HTTPException(404, "Entry not found")
entry.status = status
# An accepted inference becomes a durable user-approved manual entry.
if status == "accepted" and entry.kind == "zed_inferred":
entry.kind = "manual"
db.commit()
return RedirectResponse("/", status_code=303)
@app.post("/call-imports", response_class=HTMLResponse)
def upload_call_pdf(request: Request, file: Annotated[UploadFile, File()], db: Session = Depends(get_db)):
require_web(request)
if not file.filename or not file.filename.lower().endswith(".pdf"):
raise HTTPException(422, "Only PDF call-detail logs are accepted")
destination = UPLOAD_DIR / f"{uuid4()}.pdf"
with destination.open("wb") as output:
shutil.copyfileobj(file.file, output)
try:
with pdfplumber.open(destination) as pdf:
text = "\n".join(page.extract_text() or "" for page in pdf.pages)
status = "extracted" if text.strip() else "needs_ocr"
except Exception:
try:
reader = PdfReader(destination)
text = "\n".join(page.extract_text() or "" for page in reader.pages)
status = "extracted" if text.strip() else "needs_ocr"
except Exception as exc:
text, status = f"Extraction failed: {exc}", "failed"
call_import = CallImport(filename=file.filename, stored_path=str(destination), extraction_status=status, extracted_text=text)
db.add(call_import)
db.commit()
return RedirectResponse("/", status_code=303)
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class RawEvent(Base):
__tablename__ = "raw_events"
__table_args__ = (UniqueConstraint("source", "device_id", "external_id", name="uq_raw_event"),)
id: Mapped[int] = mapped_column(primary_key=True)
source: Mapped[str] = mapped_column(String(32), index=True)
device_id: Mapped[str] = mapped_column(String(128), index=True)
external_id: Mapped[str] = mapped_column(String(128))
project_slug: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
occurred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
payload: Mapped[dict] = mapped_column(JSON)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class TimeEntry(Base):
__tablename__ = "time_entries"
__table_args__ = (UniqueConstraint("source", "device_id", "external_id", name="uq_time_entry"),)
id: Mapped[int] = mapped_column(primary_key=True)
source: Mapped[str] = mapped_column(String(32), index=True)
device_id: Mapped[str] = mapped_column(String(128), default="server", index=True)
external_id: Mapped[str] = mapped_column(String(128))
kind: Mapped[str] = mapped_column(String(32), default="tracked", index=True)
status: Mapped[str] = mapped_column(String(32), default="accepted", index=True)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
duration_seconds: Mapped[int] = mapped_column(Integer)
task: Mapped[str] = mapped_column(String(512), default="")
project_slug: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
notes: Mapped[str] = mapped_column(Text, default="")
raw_event_id: Mapped[int | None] = mapped_column(ForeignKey("raw_events.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
class CallImport(Base):
__tablename__ = "call_imports"
id: Mapped[int] = mapped_column(primary_key=True)
filename: Mapped[str] = mapped_column(String(512))
stored_path: Mapped[str] = mapped_column(String(1024))
extraction_status: Mapped[str] = mapped_column(String(32), default="uploaded")
extracted_text: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class TimeTrackEntry(BaseModel):
id: str = Field(min_length=1, max_length=128)
task: str = ""
start: str
end: str
duration: int = Field(ge=0)
class TimeTrackSync(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
entries: list[TimeTrackEntry] = Field(default_factory=list, max_length=1000)
class ZedRecord(BaseModel):
source_id: str = Field(min_length=1, max_length=128)
timestamp: str
zed_active: bool | None = None
type: str | None = None
branch: str | None = None
subject: str | None = None
message: str | None = None
author: str | None = None
source: str | None = None
title: str | None = None
class_: str | None = Field(default=None, alias="class")
model_config = {"extra": "allow", "populate_by_name": True}
class ZedSync(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
project_slug: str = Field(min_length=1, max_length=255)
records: list[ZedRecord] = Field(default_factory=list, max_length=2000)
class EntryUpdate(BaseModel):
task: str | None = Field(default=None, max_length=512)
project_slug: str | None = Field(default=None, max_length=255)
notes: str | None = None
status: str | None = None
start_at: datetime | None = None
end_at: datetime | None = None
+87
View File
@@ -0,0 +1,87 @@
import hashlib
import os
from collections import defaultdict
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from .models import RawEvent, TimeEntry
TZ = ZoneInfo(os.environ.get("APP_TIMEZONE", "Europe/Vienna"))
def parse_timestamp(value: str) -> datetime:
value = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=TZ)
return parsed
def stable_id(*parts: str) -> str:
return hashlib.sha256("\x1f".join(parts).encode()).hexdigest()
def derive_zed_suggestions(db: Session, device_id: str, project_slug: str, idle_seconds: int = 300) -> int:
events = db.scalars(
select(RawEvent).where(
RawEvent.source == "zed_heartbeat",
RawEvent.device_id == device_id,
RawEvent.project_slug == project_slug,
).order_by(RawEvent.occurred_at)
).all()
active = [event for event in events if event.payload.get("zed_active") is True and event.occurred_at is not None]
# Accepted suggestions become manual entries and are intentionally retained.
db.execute(delete(TimeEntry).where(
TimeEntry.source == "zed",
TimeEntry.device_id == device_id,
TimeEntry.project_slug == project_slug,
TimeEntry.kind == "zed_inferred",
TimeEntry.status == "suggested",
))
blocks: list[tuple[datetime, datetime, int]] = []
start = previous = None
for event in active:
current = event.occurred_at
if current is None:
continue
if previous is None or (current - previous).total_seconds() > idle_seconds:
if start is not None and previous is not None and previous > start:
blocks.append((start, previous, int((previous - start).total_seconds())))
start = current
previous = current
if start is not None and previous is not None and previous > start:
blocks.append((start, previous, int((previous - start).total_seconds())))
for start_at, end_at, duration in blocks:
entry_id = stable_id(device_id, project_slug, start_at.isoformat(), end_at.isoformat())
db.add(TimeEntry(
source="zed", device_id=device_id, external_id=entry_id, kind="zed_inferred",
status="suggested", start_at=start_at, end_at=end_at, duration_seconds=duration,
task="Zed activity", project_slug=project_slug,
))
return len(blocks)
def overlaps_accepted(db: Session, entry: TimeEntry) -> bool:
matching = db.scalars(select(TimeEntry).where(
TimeEntry.status == "accepted", TimeEntry.id != entry.id,
TimeEntry.start_at < entry.end_at, TimeEntry.end_at > entry.start_at,
)).all()
return bool(matching)
def daily_summary(entries: list[TimeEntry]) -> dict:
totals: dict[str, int] = defaultdict(int)
suggestions = 0
for entry in entries:
day = entry.start_at.astimezone(TZ).date().isoformat()
if entry.status == "accepted":
totals[day] += entry.duration_seconds
elif entry.status == "suggested":
suggestions += entry.duration_seconds
return {"by_day": dict(totals), "suggested_seconds": suggestions}
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Timesheets · Time Track Server</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"><style>small.suggested{color:#9a6700}.rejected{opacity:.5}td{vertical-align:top}form.inline{display:inline}</style></head>
<body><main class="container"><nav><ul><li><strong>Time Track Server</strong></li></ul><ul><li>{{ timezone }}</li><li><form class="inline" method="post" action="/logout"><button class="secondary outline">Sign out</button></form></li></ul></nav>
<h1>Combined timesheet</h1><form method="get" class="grid"><label>From <input type="date" name="start" value="{{ start }}"></label><label>To <input type="date" name="end" value="{{ end }}"></label><button type="submit">Filter</button></form>
<section class="grid"><article><header>Accepted total</header><strong>{{ format_seconds(accepted) }}</strong></article><article><header>Suggested, not counted</header><strong>{{ format_seconds(suggested) }}</strong></article></section>
<details><summary>Add manual time</summary><form method="post" action="/entries/manual" class="grid"><label>Start <input name="start_at" type="datetime-local" required></label><label>End <input name="end_at" type="datetime-local" required></label><label>Task <input name="task"></label><label>Project <input name="project_slug"></label><button type="submit">Add accepted time</button></form></details>
<details><summary>Upload mobile-provider call log (PDF)</summary><p>Uploads are extracted and retained for review. A provider-specific parser is added after a redacted sample statement is available.</p><form method="post" action="/call-imports" enctype="multipart/form-data"><input type="file" name="file" accept="application/pdf,.pdf" required><button type="submit">Upload PDF</button></form></details>
<h2>Entries</h2><figure><table><thead><tr><th>Start</th><th>End</th><th>Source</th><th>Task / project</th><th>Duration</th><th>Status</th><th>Actions</th></tr></thead><tbody>
{% for entry in entries %}<tr class="{{ entry.status }}"><td>{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%d %H:%M') }}</td><td>{{ entry.end_at.astimezone(display_tz).strftime('%H:%M') }}</td><td>{{ entry.source }}{% if entry.kind == 'zed_inferred' %}<br><small class="suggested">inferred{% if overlaps_accepted(entry) %}; overlaps accepted time{% endif %}</small>{% endif %}</td><td>{{ entry.task or '—' }}{% if entry.project_slug %}<br><small>{{ entry.project_slug }}</small>{% endif %}</td><td>{{ format_seconds(entry.duration_seconds) }}</td><td>{{ entry.status }}</td><td>{% if entry.status == 'suggested' %}<form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="accepted"><button>Accept</button></form><form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="rejected"><button class="secondary">Reject</button></form>{% endif %}<details><summary>Edit</summary><form method="post" action="/entries/{{ entry.id }}/edit"><label>Start <input name="start_at" type="datetime-local" value="{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>End <input name="end_at" type="datetime-local" value="{{ entry.end_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>Task <input name="task" value="{{ entry.task }}"></label><label>Project <input name="project_slug" value="{{ entry.project_slug or '' }}"></label><label>Notes <textarea name="notes">{{ entry.notes }}</textarea></label><button type="submit">Save</button></form></details></td></tr>
{% else %}<tr><td colspan="7">No entries yet. Sync a tracker or add manual time.</td></tr>{% endfor %}</tbody></table></figure></main></body></html>
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Time Track Server</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"></head>
<body><main class="container" style="max-width: 28rem; padding-top: 5rem"><h1>Time Track Server</h1><p>Sign in to browse and edit your timesheets.</p>{% if error %}<p role="alert">{{ error }}</p>{% endif %}<form method="post" action="/login"><label>Password <input type="password" name="password" required autofocus></label><button type="submit">Sign in</button></form></main></body></html>
+39
View File
@@ -0,0 +1,39 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-time_track}
POSTGRES_USER: ${POSTGRES_USER:-time_track}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
app:
build: .
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-time_track}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-time_track}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
SYNC_API_TOKEN: ${SYNC_API_TOKEN:?Set SYNC_API_TOKEN in .env}
SESSION_SECRET: ${SESSION_SECRET:?Set SESSION_SECRET in .env}
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Vienna}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://100.103.83.12:11345}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:8b}
UPLOAD_DIR: /data/call-imports
volumes:
- call_imports:/data/call-imports
ports:
- "${BIND_IP:-127.0.0.1}:3008:3000"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
volumes:
postgres_data:
call_imports:
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
sqlalchemy==2.0.43
psycopg[binary]==3.2.9
jinja2==3.1.6
python-multipart==0.0.20
itsdangerous==2.2.0
pypdf==6.0.0
pdfplumber==0.11.7
Submodule
+1
Submodule time_track added at 74d2bfd3a7
+1
Submodule zed_time_tracker added at 26041efd8d