Files
kadence/backend/app/models.py
T
timka123 77b26757bf refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency
- Refactored TimerPage component to improve layout and user experience.
- Updated theme configuration to utilize design tokens for better maintainability.
- Introduced new tokens for colors and typography to align with design principles.
- Simplified state management and improved timer functionality.
- Enhanced accessibility and responsiveness of UI elements.
2026-10-18 23:57:36 +03:00

191 lines
7.6 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 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)