"""Database tables. The shape follows the three things the app records: **settings** (one JSON document), **what you did** (pomodoro sessions, task completions) and **what you were tempted by** (block events). Reports are derived from those and cached per day so a re-send shows the same numbers. """ from __future__ import annotations from datetime import date, datetime, timezone from typing import Any from sqlalchemy import ( JSON, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base def utcnow() -> datetime: return datetime.now(timezone.utc) class SettingsRow(Base): """The single settings document (`id = 1`), validated by `AppSettings`.""" __tablename__ = "settings" id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utcnow, onupdate=utcnow ) class PomodoroSession(Base): """One interval of the timer — work or break — as it actually happened. `planned_seconds` is what the settings asked for, `actual_seconds` is what the clock recorded, and they differ whenever you finish early or abandon. """ __tablename__ = "pomodoro_sessions" id: Mapped[int] = mapped_column(Integer, primary_key=True) kind: Mapped[str] = mapped_column(String(16), index=True) # work|short_break|long_break status: Mapped[str] = mapped_column(String(16), index=True, default="running") # running | paused | completed | abandoned started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utcnow, index=True ) ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) local_date: Mapped[date] = mapped_column(Date, index=True) """The day this interval belongs to in the user's timezone — reports group on it.""" planned_seconds: Mapped[int] = mapped_column(Integer, default=1500) actual_seconds: Mapped[int] = mapped_column(Integer, default=0) paused_seconds: Mapped[int] = mapped_column(Integer, default=0) paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) interruptions: Mapped[int] = mapped_column(Integer, default=0) cycle_index: Mapped[int] = mapped_column(Integer, default=1) """Position in the current long-break cycle (1..cycles_before_long_break).""" task_source: Mapped[str | None] = mapped_column(String(32)) task_external_id: Mapped[str | None] = mapped_column(String(128)) task_title: Mapped[str | None] = mapped_column(Text) note: Mapped[str | None] = mapped_column(Text) abandon_reason: Mapped[str | None] = mapped_column(Text) block_events: Mapped[list["BlockEvent"]] = relationship(back_populates="session") @property def is_work(self) -> bool: return self.kind == "work" class TaskRecord(Base): """A todo item, wherever it came from. External tasks are cached here on every refresh so the UI is instant and the daily report still works when Jira is down. Locally created tasks live here too, with `source = "local"` — they are the only ones we own outright. """ __tablename__ = "tasks" __table_args__ = ( UniqueConstraint("source", "external_id", name="uq_task_source_external"), Index("ix_task_source_done", "source", "is_done"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) source: Mapped[str] = mapped_column(String(32), index=True) external_id: Mapped[str] = mapped_column(String(128)) title: Mapped[str] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text) url: Mapped[str | None] = mapped_column(Text) project: Mapped[str | None] = mapped_column(Text) status: Mapped[str | None] = mapped_column(String(64)) priority: Mapped[str | None] = mapped_column(String(32)) due_date: Mapped[date | None] = mapped_column(Date, index=True) start_date: Mapped[date | None] = mapped_column(Date) labels: Mapped[list[str]] = mapped_column(JSON, default=list) raw: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) completed_date: Mapped[date | None] = mapped_column(Date, index=True) completed_here: Mapped[bool] = mapped_column(Boolean, default=False) """True when *this app* closed it — what the daily report counts.""" estimated_pomodoros: Mapped[int | None] = mapped_column(Integer) pinned: Mapped[bool] = mapped_column(Boolean, default=False) """Shown at the top of the timer's task picker.""" first_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utcnow ) last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) stale: Mapped[bool] = mapped_column(Boolean, default=False) """Set when a refresh no longer returns the task (closed elsewhere, or filtered).""" class BlockEvent(Base): """A blocked site or app you reached for anyway. Pure gold for the report.""" __tablename__ = "block_events" id: Mapped[int] = mapped_column(Integer, primary_key=True) kind: Mapped[str] = mapped_column(String(16), index=True) # domain | app target: Mapped[str] = mapped_column(String(255), index=True) at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) local_date: Mapped[date] = mapped_column(Date, index=True) action: Mapped[str] = mapped_column(String(16), default="blocked") # blocked|killed|warned details: Mapped[str | None] = mapped_column(Text) session_id: Mapped[int | None] = mapped_column(ForeignKey("pomodoro_sessions.id")) session: Mapped[PomodoroSession | None] = relationship(back_populates="block_events") class DailyReport(Base): """A generated report, kept so re-sends and the web view agree.""" __tablename__ = "daily_reports" __table_args__ = (UniqueConstraint("report_date", name="uq_report_date"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) report_date: Mapped[date] = mapped_column(Date, index=True) payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sent_channel: Mapped[str | None] = mapped_column(String(32)) score: Mapped[float] = mapped_column(Float, default=0.0) class EventLog(Base): """Small append-only log used for "did we already send this?" questions. Nudges, celebrations and briefings all need to be idempotent per day, and a row here is cheaper than a column per message type. """ __tablename__ = "event_log" __table_args__ = (Index("ix_eventlog_kind_date", "kind", "local_date"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) kind: Mapped[str] = mapped_column(String(48), index=True) local_date: Mapped[date] = mapped_column(Date, index=True) at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)