184 lines
7.4 KiB
Python
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)
|