Files
kadence/backend/app/models.py
T
2026-10-18 18:02:05 +03:00

184 lines
7.4 KiB
Python

"""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)