"""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 mirrored 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"`. Completion is never recorded from this app: you close a task where it lives, and the next refresh notices. That keeps one source of truth per task. """ __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) """The day the task was seen to close, in your timezone. Tasks are never ticked off here — Jira, Todoist and Google Tasks own that — so this is set when a refresh finds a task closed, or finds it gone from a source that was otherwise healthy. That is 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)