"""The user-editable settings, as one validated tree. Everything the UI can change lives here: timer lengths, which todo sources are on, what the blocker blocks, when Telegram gets poked and in what tone. It is stored as a single JSON row (see `app.models.SettingsRow`), which keeps "add a new option" to a one-line change instead of a migration. Defaults are deliberately a complete, working configuration: a fresh install is a classic 25/5/15 pomodoro with no integrations and nothing blocked. """ from __future__ import annotations from datetime import time from typing import Literal from pydantic import BaseModel, Field, field_validator # -------------------------------------------------------------------------- # Timer # -------------------------------------------------------------------------- class TimerPreset(BaseModel): """A named, user-editable recipe of durations the UI can apply in one tap.""" name: str work_minutes: int = Field(default=25, ge=1, le=240) short_break_minutes: int = Field(default=5, ge=1, le=120) long_break_minutes: int = Field(default=15, ge=1, le=240) cycles_before_long_break: int = Field(default=4, ge=1, le=12) def _default_timer_presets() -> list[TimerPreset]: return [ TimerPreset( name="Classic", work_minutes=45, short_break_minutes=15, long_break_minutes=30 ), TimerPreset( name="Пара", work_minutes=85, short_break_minutes=10, long_break_minutes=10 ), ] class PomodoroSettings(BaseModel): """Lengths and automation of the timer itself. All durations in minutes.""" work_minutes: int = Field(default=25, ge=1, le=240) short_break_minutes: int = Field(default=5, ge=1, le=120) long_break_minutes: int = Field(default=15, ge=1, le=240) cycles_before_long_break: int = Field(default=4, ge=1, le=12) presets: list[TimerPreset] = Field(default_factory=_default_timer_presets) """Saved work/break combinations, applied to the fields above in one tap.""" auto_start_breaks: bool = True """Roll straight into the break when a work interval ends.""" auto_start_work: bool = False """Roll straight into the next work interval when a break ends.""" daily_goal_pomodoros: int = Field(default=8, ge=1, le=40) """The bar the daily report measures you against.""" strict_mode: bool = False """Abandoning a running work interval requires a reason, and is counted.""" allow_pause: bool = True """When false the only ways out of a work interval are finishing or abandoning.""" count_interruptions: bool = True tick_sound: bool = False alarm_sound: bool = True alarm_volume: int = Field(default=70, ge=0, le=100) timer_enabled: bool = True """When false the timer controls are hidden — the app becomes view-only.""" desktop_notifications: bool = True keep_on_top_during_work: bool = False """Desktop app: float the window above everything while a work interval runs.""" # -------------------------------------------------------------------------- # Todo sources # -------------------------------------------------------------------------- class JiraSourceSettings(BaseModel): """Jira Cloud, authenticated with account email + API token (Basic auth).""" enabled: bool = False base_url: str = "" email: str = "" api_token: str = "" extra_jql: str = "" """ANDed onto every query, e.g. `assignee = currentUser()`.""" start_date_field: str = "customfield_10015" """Jira's "Start date"; its id differs per site (see /rest/api/3/field).""" include_all_open: bool = True """Fetch every open issue, not only the ones due today or overdue.""" max_issues: int = Field(default=200, ge=1, le=2000) class GoogleTasksSourceSettings(BaseModel): """Google Tasks, via an OAuth refresh token (see jira-helper's README).""" enabled: bool = False client_id: str = "" client_secret: str = "" refresh_token: str = "" lists: list[str] = Field(default_factory=list) """Titles or ids to limit to. Empty = every list in the account.""" include_undated: bool = True """Keep tasks with no due date (jira-helper drops them; here they are todos).""" class TodoistSourceSettings(BaseModel): """Todoist, via a personal API token.""" enabled: bool = False api_token: str = "" projects: list[str] = Field(default_factory=list) """Names or ids to limit to. Empty = every project.""" filter_query: str = "" """Override the server-side filter. Empty = all open tasks.""" class LaundrySourceSettings(BaseModel): """Campus laundry bookings — not a todo, but a thing that owns your time.""" enabled: bool = False base_url: str = "https://4ka.inbicst.ru" session_cookie: str = "" """The `sessionid` cookie, or a whole `name=value; ...` cookie string.""" class SourcesSettings(BaseModel): jira: JiraSourceSettings = Field(default_factory=JiraSourceSettings) google_tasks: GoogleTasksSourceSettings = Field( default_factory=GoogleTasksSourceSettings ) todoist: TodoistSourceSettings = Field(default_factory=TodoistSourceSettings) laundry: LaundrySourceSettings = Field(default_factory=LaundrySourceSettings) refresh_interval_minutes: int = Field(default=15, ge=1, le=1440) """How often the background job re-pulls every source.""" request_timeout_seconds: int = Field(default=30, ge=5, le=120) # -------------------------------------------------------------------------- # Blocker (enforced by the desktop app) # -------------------------------------------------------------------------- class BlockWindow(BaseModel): """A recurring time window in which blocking is on regardless of the timer.""" label: str = "" start: time = time(9, 0) end: time = time(18, 0) days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4]) """ISO weekdays, Monday = 0 .. Sunday = 6.""" class BlockerSettings(BaseModel): enabled: bool = False mode: Literal["during_work", "always", "scheduled"] = "during_work" """`during_work` blocks only while a work interval runs — the useful default.""" block_during_breaks: bool = False blocked_domains: list[str] = Field(default_factory=list) """Hostnames or masks. `youtube.com` blocks the site (plus `www.` and `m.`); `*.youtube.com` blocks its subdomains too, expanded with `subdomain_expansions` because a hosts file has no wildcards of its own. Put `*` in that list and `*.youtube.com` means every subdomain — the desktop app answers those at DNS level, the phone matches them in the URL bar. """ allowed_domains: list[str] = Field(default_factory=list) """Exceptions, masks allowed: `music.youtube.com`, `*.docs.google.com`.""" subdomain_expansions: list[str] = Field( default_factory=lambda: ["www", "m", "mobile", "app", "web", "i", "old", "new"] ) """What `*.example.com` turns into. Add the subdomains you actually visit. A bare `*` means *every* subdomain instead: the hosts file still only gets the labels listed here, while the rest is caught by the phone's URL-bar matcher and by the desktop app's local resolver stub. Anything else that is not a DNS label is ignored and reported as `invalid_expansions`. """ blocked_apps: list[str] = Field(default_factory=list) """Process names or masks: `steam`, `*discord*`, `telegram-*`.""" kill_blocked_apps: bool = True """Terminate a blocked app when it appears; otherwise only warn and log.""" schedule: list[BlockWindow] = Field(default_factory=list) redirect_ip: str = "127.0.0.1" app_poll_seconds: int = Field(default=5, ge=1, le=60) notify_on_block: bool = True # -------------------------------------------------------------------------- # Notifications and reports # -------------------------------------------------------------------------- class QuietHours(BaseModel): enabled: bool = False start: time = time(23, 0) end: time = time(8, 0) class TelegramSettings(BaseModel): """Everything about the bot that nags, congratulates and reports.""" enabled: bool = False bot_token: str = "" chat_id: str = "" commands_enabled: bool = True """Run a long-polling bot so /today, /report, /stats work in the chat.""" daily_report: bool = True daily_report_time: time = time(21, 0) morning_briefing: bool = True """A "here is your day" message with the todo list and yesterday's streak.""" morning_briefing_time: time = time(9, 0) weekly_report: bool = True weekly_report_weekday: int = Field(default=6, ge=0, le=6) """ISO weekday for the weekly wrap-up, Monday = 0 (default Sunday).""" celebrate_every_n_pomodoros: int = Field(default=4, ge=0, le=50) """Cheer in the chat every N completed pomodoros today. 0 = never.""" nudge_enabled: bool = True nudge_after_idle_minutes: int = Field(default=90, ge=10, le=600) """Poke you when you have not run a pomodoro for this long during the day.""" nudge_window_start: time = time(10, 0) nudge_window_end: time = time(20, 0) nudge_max_per_day: int = Field(default=4, ge=0, le=24) goal_warning: bool = True """Warn in the evening when the daily goal is still out of reach.""" quiet_hours: QuietHours = Field(default_factory=QuietHours) class SlackSettings(BaseModel): enabled: bool = False webhook_url: str = "" class ReportSettings(BaseModel): tone: Literal["friendly", "coach", "drill_sergeant", "dry"] = "friendly" language: Literal["en", "ru"] = "en" include_funny_stats: bool = True include_task_list: bool = True include_blocked_sites: bool = True include_achievements: bool = True include_comparison: bool = True """Compare today against your 7-day average.""" max_tasks_listed: int = Field(default=15, ge=1, le=100) class NotificationsSettings(BaseModel): telegram: TelegramSettings = Field(default_factory=TelegramSettings) slack: SlackSettings = Field(default_factory=SlackSettings) report: ReportSettings = Field(default_factory=ReportSettings) # -------------------------------------------------------------------------- # Web login # -------------------------------------------------------------------------- class AuthSettings(BaseModel): """Gates the browser UI. The desktop app and scripts keep using `API_TOKEN`. The web UI is closed the moment either credential is configured: a password (`password_hash` set) or Telegram sign-in (`telegram_login_enabled` with an `telegram_owner_id`). With neither set, the browser is open — the same "friction-free on a laptop" default `API_TOKEN` already has. """ username: str = "timka" password_hash: str = "" """`pbkdf2_sha256$$$`, never the plaintext password.""" telegram_login_enabled: bool = False telegram_owner_id: str = "" """The numeric Telegram user id allowed to sign in with the Login Widget.""" telegram_bot_username: str = "" """Without the `@`, e.g. `kadence_bot` — the widget needs it to render.""" session_days: int = Field(default=30, ge=1, le=365) # -------------------------------------------------------------------------- # Root # -------------------------------------------------------------------------- class AppearanceSettings(BaseModel): theme: Literal["light", "dark", "system"] = "system" accent: str = "#6750A4" """Material 3 seed colour; the whole palette is derived from it.""" compact: bool = False class AppSettings(BaseModel): """The single settings document stored in the database.""" timezone: str = "Europe/Moscow" week_starts_monday: bool = True pomodoro: PomodoroSettings = Field(default_factory=PomodoroSettings) sources: SourcesSettings = Field(default_factory=SourcesSettings) blocker: BlockerSettings = Field(default_factory=BlockerSettings) notifications: NotificationsSettings = Field(default_factory=NotificationsSettings) appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) auth: AuthSettings = Field(default_factory=AuthSettings) @field_validator("timezone") @classmethod def _known_timezone(cls, value: str) -> str: from zoneinfo import ZoneInfo, ZoneInfoNotFoundError try: ZoneInfo(value) except (ZoneInfoNotFoundError, ValueError) as exc: # pragma: no cover raise ValueError(f"unknown timezone: {value!r}") from exc return value # Keys whose values are never sent to a browser in full; the API replaces them # with a masked placeholder and the UI sends the placeholder back unchanged. SECRET_FIELDS = frozenset( { "api_token", "client_secret", "refresh_token", "session_cookie", "bot_token", "webhook_url", "password_hash", } ) SECRET_MASK = "••••••••"