commit 25eb7f50e66b0efadfa779a36cf3b7a675123c80 Author: Florian Egger Date: Mon Sep 14 13:14:47 2026 +0200 Add self-hosted combined timesheet server diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ce7545 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73088e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +.pytest_cache/ +*.pyc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3884a4d --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..945f089 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..16992fb --- /dev/null +++ b/README.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..8c47951 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..262d95e --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e51bb59 --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..3f15d5d --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..973238f --- /dev/null +++ b/app/services.py @@ -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} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..56fb8a8 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,10 @@ + +Timesheets · Time Track Server +
+

Combined timesheet

+
Accepted total
{{ format_seconds(accepted) }}
Suggested, not counted
{{ format_seconds(suggested) }}
+
Add manual time
+
Upload mobile-provider call log (PDF)

Uploads are extracted and retained for review. A provider-specific parser is added after a redacted sample statement is available.

+

Entries

+{% for entry in entries %} +{% else %}{% endfor %}
StartEndSourceTask / projectDurationStatusActions
{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%d %H:%M') }}{{ entry.end_at.astimezone(display_tz).strftime('%H:%M') }}{{ entry.source }}{% if entry.kind == 'zed_inferred' %}
inferred{% if overlaps_accepted(entry) %}; overlaps accepted time{% endif %}{% endif %}
{{ entry.task or '—' }}{% if entry.project_slug %}
{{ entry.project_slug }}{% endif %}
{{ format_seconds(entry.duration_seconds) }}{{ entry.status }}{% if entry.status == 'suggested' %}
{% endif %}
Edit
No entries yet. Sync a tracker or add manual time.
diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..edb195e --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,3 @@ + +Time Track Server +

Time Track Server

Sign in to browse and edit your timesheets.

{% if error %}

{{ error }}

{% endif %}
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d78548 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71a90e5 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/time_track b/time_track new file mode 160000 index 0000000..74d2bfd --- /dev/null +++ b/time_track @@ -0,0 +1 @@ +Subproject commit 74d2bfd3a7cf727740c66de69fba84da7fb04642 diff --git a/zed_time_tracker b/zed_time_tracker new file mode 160000 index 0000000..26041ef --- /dev/null +++ b/zed_time_tracker @@ -0,0 +1 @@ +Subproject commit 26041efd8d113016eababc7e63c789753eb0bd6d