add blocker presets
This commit is contained in:
12 files changed
+426
-83
No files matched your search
+27
-2
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import env_settings
|
||||
@@ -32,11 +32,36 @@ engine = create_engine(env_settings().database_url, **_engine_kwargs(env_setting
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
# Columns a refactor later dropped from the model but, with no migration tool
|
||||
# in this project, left behind on any database that was already running —
|
||||
# still `NOT NULL` with no server-side default, so every insert that no
|
||||
# longer sets them fails. `create_all` only ever adds; this is the other half.
|
||||
_DEAD_COLUMNS: dict[str, tuple[str, ...]] = {
|
||||
"tasks": ("completed_here",),
|
||||
}
|
||||
|
||||
|
||||
def _drop_dead_columns() -> None:
|
||||
inspector = inspect(engine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
with engine.begin() as conn:
|
||||
for table, columns in _DEAD_COLUMNS.items():
|
||||
if table not in existing_tables:
|
||||
continue
|
||||
existing_columns = {c["name"] for c in inspector.get_columns(table)}
|
||||
for column in columns:
|
||||
if column in existing_columns:
|
||||
conn.execute(text(f'ALTER TABLE {table} DROP COLUMN {column}'))
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create any missing tables. The schema is additive, so this is enough."""
|
||||
"""Create any missing tables. The schema is additive, so this is enough —
|
||||
plus the one-time cleanup above for columns a refactor removed from the
|
||||
model."""
|
||||
from app import models # noqa: F401 (register the mappers)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_drop_dead_columns()
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -216,6 +216,25 @@ def extend(session: Session, minutes: int) -> PomodoroSession:
|
||||
return row
|
||||
|
||||
|
||||
def rescale_current(session: Session, settings: AppSettings) -> PomodoroSession | None:
|
||||
"""Resize the running/paused interval to match a just-saved duration.
|
||||
|
||||
Without this, a preset (or any Focus/Short/Long edit) only takes effect
|
||||
from the *next* interval — the one already ticking keeps whatever length
|
||||
it was born with. `elapsed_seconds` is wall-clock, not stored, so this
|
||||
only has to move `planned_seconds`; time already spent still counts, it
|
||||
is just measured against the new total.
|
||||
"""
|
||||
row = current_session(session)
|
||||
if row is None:
|
||||
return None
|
||||
planned = duration_for(settings, row.kind) # type: ignore[arg-type]
|
||||
if planned != row.planned_seconds:
|
||||
row.planned_seconds = planned
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if task_id is None:
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.models import SettingsRow
|
||||
from app.services import pomodoro
|
||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||
|
||||
_SETTINGS_ID = 1
|
||||
@@ -193,4 +194,11 @@ def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||
current = load_settings(session).model_dump(mode="json")
|
||||
patch = unmask_secrets(current, patch)
|
||||
merged = _merge(current, patch)
|
||||
return save_settings(session, AppSettings.model_validate(merged))
|
||||
updated = save_settings(session, AppSettings.model_validate(merged))
|
||||
|
||||
# A changed Focus/Short/Long minute (directly, or via a preset) resizes
|
||||
# whatever interval is already running or paused — see
|
||||
# `pomodoro.rescale_current`. Cheap and a no-op when nothing moved, so it
|
||||
# is simplest to always run it rather than detect which keys changed.
|
||||
pomodoro.rescale_current(session, updated)
|
||||
return updated
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Regression test for a schema-drift bug: a database created by an older
|
||||
build of the app still had a `completed_here NOT NULL` column on `tasks`
|
||||
that a later refactor dropped from the model. `create_all` never alters an
|
||||
existing table, so every task insert on such a database failed with
|
||||
|
||||
IntegrityError: null value in column "completed_here" ... violates
|
||||
not-null constraint
|
||||
|
||||
which, since task refresh runs from both the scheduler and `/api/tasks`
|
||||
endpoints, looked like "everything 500s". `app.db.init_db` now reconciles
|
||||
known-dead columns after `create_all`; this pins that behaviour down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal, engine, init_db
|
||||
from app.models import TaskRecord
|
||||
|
||||
|
||||
def _simulate_pre_refactor_database() -> None:
|
||||
"""Recreate the exact drift: today's `tasks` table, plus the long-gone
|
||||
`completed_here` column, `NOT NULL` and with no database-side default —
|
||||
just like a database that has been running since before the column was
|
||||
removed from the model.
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("DROP TABLE IF EXISTS tasks"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
external_id VARCHAR(128) NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
url TEXT,
|
||||
project TEXT,
|
||||
status VARCHAR(64),
|
||||
priority VARCHAR(32),
|
||||
due_date DATE,
|
||||
start_date DATE,
|
||||
labels JSON,
|
||||
raw JSON,
|
||||
is_done BOOLEAN NOT NULL DEFAULT 0,
|
||||
completed_at DATETIME,
|
||||
completed_date DATE,
|
||||
completed_here BOOLEAN NOT NULL,
|
||||
estimated_pomodoros INTEGER,
|
||||
pinned BOOLEAN NOT NULL DEFAULT 0,
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
stale BOOLEAN NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_init_db_drops_the_dead_not_null_column(session):
|
||||
_simulate_pre_refactor_database()
|
||||
|
||||
init_db()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# This is exactly what `tasks_service._upsert` + `refresh_tasks` do on
|
||||
# a newly-seen task: insert a row without ever mentioning the column
|
||||
# the model no longer has.
|
||||
db.add(TaskRecord(source="jira", external_id="VPN-48", title="Renew VPN"))
|
||||
db.commit() # pre-fix: IntegrityError (null value in "completed_here")
|
||||
|
||||
stored = db.query(TaskRecord).filter_by(external_id="VPN-48").one()
|
||||
assert stored.title == "Renew VPN"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_init_db_is_a_no_op_once_the_column_is_already_gone(session):
|
||||
"""Running it twice (every boot does) must not blow up when there is
|
||||
nothing left to drop."""
|
||||
init_db()
|
||||
init_db()
|
||||
@@ -2,10 +2,34 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services import settings_service
|
||||
from app.services import pomodoro, settings_service
|
||||
from app.settings_schema import SECRET_MASK
|
||||
|
||||
|
||||
def test_a_preset_change_resizes_the_running_interval(session, settings):
|
||||
row = pomodoro.start(session, settings, kind="work")
|
||||
assert row.planned_seconds == settings.pomodoro.work_minutes * 60
|
||||
|
||||
updated = settings_service.apply_patch(
|
||||
session, {"pomodoro": {"work_minutes": 85, "short_break_minutes": 10}}
|
||||
)
|
||||
|
||||
current = pomodoro.current_session(session)
|
||||
assert current.id == row.id
|
||||
assert current.planned_seconds == 85 * 60
|
||||
# Elapsed time is wall-clock, not stored — resizing never has to touch it.
|
||||
assert pomodoro.elapsed_seconds(current) >= 0
|
||||
assert updated.pomodoro.work_minutes == 85
|
||||
|
||||
|
||||
def test_a_patch_unrelated_to_durations_leaves_the_running_interval_alone(session, settings):
|
||||
row = pomodoro.start(session, settings, kind="work")
|
||||
settings_service.apply_patch(session, {"pomodoro": {"daily_goal_pomodoros": 12}})
|
||||
current = pomodoro.current_session(session)
|
||||
assert current.id == row.id
|
||||
assert current.planned_seconds == row.planned_seconds
|
||||
|
||||
|
||||
def test_patch_merges_without_clobbering_siblings(session):
|
||||
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
|
||||
updated = settings_service.apply_patch(
|
||||
|
||||
@@ -261,6 +261,24 @@ class Controller(QObject):
|
||||
def settings(self) -> dict[str, Any]:
|
||||
return self._settings
|
||||
|
||||
@pyqtProperty(int, notify=settingsChanged)
|
||||
def activePresetIndex(self) -> int:
|
||||
"""Which saved preset the live Focus/Short/Long minutes match, or -1.
|
||||
|
||||
One source of truth for SettingsPage (the full switcher, with the
|
||||
numbers editable below it) and TimerPage (a quick switcher next to
|
||||
the dial) so the two never compute "which preset is this" differently.
|
||||
"""
|
||||
pomodoro = self._settings.get("pomodoro", {})
|
||||
for index, preset in enumerate(pomodoro.get("presets", [])):
|
||||
if (
|
||||
preset.get("work_minutes") == pomodoro.get("work_minutes")
|
||||
and preset.get("short_break_minutes") == pomodoro.get("short_break_minutes")
|
||||
and preset.get("long_break_minutes") == pomodoro.get("long_break_minutes")
|
||||
):
|
||||
return index
|
||||
return -1
|
||||
|
||||
@pyqtProperty(bool, notify=blockerChanged)
|
||||
def blockerActive(self) -> bool:
|
||||
return bool(self._policy.get("active"))
|
||||
@@ -426,6 +444,28 @@ class Controller(QObject):
|
||||
def patchSettings(self, patch: dict[str, Any]) -> None:
|
||||
self._send("patch_settings", {"patch": patch})
|
||||
|
||||
@pyqtSlot(int)
|
||||
def applyPreset(self, index: int) -> None:
|
||||
"""Copy a saved preset's minutes onto the live timer settings.
|
||||
|
||||
The backend resizes whatever interval is already running or paused
|
||||
to match, so this takes effect at once — not just on the next one.
|
||||
"""
|
||||
presets = self._settings.get("pomodoro", {}).get("presets", [])
|
||||
if not (0 <= index < len(presets)):
|
||||
return
|
||||
preset = presets[index]
|
||||
self.patchSettings(
|
||||
{
|
||||
"pomodoro": {
|
||||
"work_minutes": preset["work_minutes"],
|
||||
"short_break_minutes": preset["short_break_minutes"],
|
||||
"long_break_minutes": preset["long_break_minutes"],
|
||||
"cycles_before_long_break": preset["cycles_before_long_break"],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@pyqtSlot(str, "QVariant")
|
||||
def setConfig(self, key: str, value: Any) -> None:
|
||||
"""Change a desktop-local setting and apply it immediately."""
|
||||
|
||||
@@ -29,27 +29,21 @@ ScrollView {
|
||||
controller.patchSettings({ notifications: { telegram: patch } })
|
||||
}
|
||||
|
||||
// Presets are a saved list on the server (`pomodoro.presets`), each one a
|
||||
// name plus its own work/short/long minutes — editable here, not baked
|
||||
// into the UI. `_merge` on the backend replaces list fields wholesale, so
|
||||
// editing one preset's number means resending the whole list.
|
||||
function patchPreset(index, key, value) {
|
||||
if (!page.pomodoro || !page.pomodoro.presets) return
|
||||
var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) })
|
||||
presets[index][key] = value
|
||||
controller.patchSettings({ pomodoro: { presets: presets } })
|
||||
}
|
||||
function applyPreset(index) {
|
||||
if (!page.pomodoro || !page.pomodoro.presets) return
|
||||
var preset = page.pomodoro.presets[index]
|
||||
controller.patchSettings({
|
||||
pomodoro: {
|
||||
work_minutes: preset.work_minutes,
|
||||
short_break_minutes: preset.short_break_minutes,
|
||||
long_break_minutes: preset.long_break_minutes,
|
||||
cycles_before_long_break: preset.cycles_before_long_break
|
||||
}
|
||||
})
|
||||
// The Focus/Short break/Long break fields always show and edit the live
|
||||
// settings. When they still match a saved preset, the edit also rewrites
|
||||
// that preset's own numbers, so picking it again later gets you back to
|
||||
// where you left off rather than to some older saved value.
|
||||
function patchDuration(key, value) {
|
||||
if (!page.pomodoro) return
|
||||
var patch = {}; patch[key] = value
|
||||
var idx = controller.activePresetIndex
|
||||
if (idx >= 0 && page.pomodoro.presets) {
|
||||
var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) })
|
||||
presets[idx] = Object.assign(presets[idx], patch)
|
||||
controller.patchSettings({ pomodoro: Object.assign({ presets: presets }, patch) })
|
||||
} else {
|
||||
controller.patchSettings({ pomodoro: patch })
|
||||
}
|
||||
}
|
||||
|
||||
component Field: ColumnLayout {
|
||||
@@ -127,68 +121,32 @@ ScrollView {
|
||||
spacing: 12
|
||||
SectionTitle { text: "Timer" }
|
||||
|
||||
// Presets: a saved list of work/short/long combos. "Apply" copies
|
||||
// one onto the live settings below; the numbers on each row are
|
||||
// the preset's own and edit it in place.
|
||||
ColumnLayout {
|
||||
// The preset switcher: pick one and it applies at once. The fields
|
||||
// below always belong to whichever one is lit — there is nothing
|
||||
// to show for the others until you switch to them.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: page.pomodoro && page.pomodoro.presets ? page.pomodoro.presets : []
|
||||
|
||||
RowLayout {
|
||||
id: presetRow
|
||||
delegate: PresetChip {
|
||||
required property var modelData
|
||||
required property int index
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
readonly property bool isActive: page.pomodoro
|
||||
&& page.pomodoro.work_minutes === modelData.work_minutes
|
||||
&& page.pomodoro.short_break_minutes === modelData.short_break_minutes
|
||||
&& page.pomodoro.long_break_minutes === modelData.long_break_minutes
|
||||
|
||||
Label {
|
||||
text: presetRow.modelData.name
|
||||
color: presetRow.isActive ? Theme.ember : Theme.ink
|
||||
font.pixelSize: 13
|
||||
font.weight: presetRow.isActive ? Font.DemiBold : Font.Medium
|
||||
font.family: Theme.sansFamily
|
||||
Layout.preferredWidth: 70
|
||||
}
|
||||
Field {
|
||||
label: "Focus"
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: presetRow.modelData.work_minutes
|
||||
onValueModified: page.patchPreset(presetRow.index, "work_minutes", value)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
label: "Short break"
|
||||
SpinBox {
|
||||
from: 1; to: 120
|
||||
value: presetRow.modelData.short_break_minutes
|
||||
onValueModified: page.patchPreset(presetRow.index, "short_break_minutes", value)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
label: "Long break"
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: presetRow.modelData.long_break_minutes
|
||||
onValueModified: page.patchPreset(presetRow.index, "long_break_minutes", value)
|
||||
}
|
||||
}
|
||||
GhostButton {
|
||||
text: presetRow.isActive ? "Active" : "Apply"
|
||||
enabled: !presetRow.isActive
|
||||
onClicked: page.applyPreset(presetRow.index)
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
text: modelData.name
|
||||
active: controller.activePresetIndex === index
|
||||
onClicked: controller.applyPreset(index)
|
||||
}
|
||||
}
|
||||
Label {
|
||||
visible: controller.activePresetIndex === -1
|
||||
text: "Custom"
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
leftPadding: 6
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Flow {
|
||||
@@ -199,7 +157,7 @@ ScrollView {
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.work_minutes : 25
|
||||
onValueModified: page.patchPomodoro("work_minutes", value)
|
||||
onValueModified: page.patchDuration("work_minutes", value)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
@@ -207,7 +165,7 @@ ScrollView {
|
||||
SpinBox {
|
||||
from: 1; to: 120
|
||||
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
|
||||
onValueModified: page.patchPomodoro("short_break_minutes", value)
|
||||
onValueModified: page.patchDuration("short_break_minutes", value)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
@@ -215,7 +173,7 @@ ScrollView {
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
|
||||
onValueModified: page.patchPomodoro("long_break_minutes", value)
|
||||
onValueModified: page.patchDuration("long_break_minutes", value)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
|
||||
@@ -102,6 +102,29 @@ ScrollView {
|
||||
idle: !controller.timerEnabled || !controller.running
|
||||
}
|
||||
|
||||
// Switching here takes effect at once — it resizes whatever
|
||||
// is already running or paused too, not just the next one.
|
||||
RowLayout {
|
||||
visible: controller.timerEnabled
|
||||
&& controller.settings.pomodoro
|
||||
&& controller.settings.pomodoro.presets
|
||||
&& controller.settings.pomodoro.presets.length > 0
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: 14
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: controller.settings.pomodoro ? controller.settings.pomodoro.presets : []
|
||||
delegate: PresetChip {
|
||||
required property var modelData
|
||||
required property int index
|
||||
text: modelData.name
|
||||
active: controller.activePresetIndex === index
|
||||
onClicked: controller.applyPreset(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
visible: controller.timerEnabled && controller.running
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// A pill in a preset switcher: filled ember when it is the one the live
|
||||
// settings currently match, outlined otherwise. Clicking any pill applies it
|
||||
// at once — there is no separate "Apply" step. Shared by SettingsPage (the
|
||||
// full switcher, with the numbers editable below it) and TimerPage (a quick
|
||||
// way to change the length of the *next* interval without leaving the dial).
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
import ".."
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
property string text: ""
|
||||
property bool active: false
|
||||
signal clicked()
|
||||
|
||||
implicitWidth: label.implicitWidth + 28
|
||||
implicitHeight: 32
|
||||
radius: 16
|
||||
color: chip.active ? Theme.ember : (chipHover.hovered ? Qt.alpha(Theme.ember, 0.10) : "transparent")
|
||||
border.width: chip.active ? 0 : 1
|
||||
border.color: chipHover.hovered ? Theme.ember : Theme.rule
|
||||
Behavior on color { ColorAnimation { duration: 110 } }
|
||||
|
||||
Label {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: chip.text
|
||||
color: chip.active ? "white" : Theme.ink
|
||||
font.pixelSize: 12
|
||||
font.weight: chip.active ? Font.DemiBold : Font.Medium
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
HoverHandler { id: chipHover; cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler {
|
||||
grabPermissions: PointerHandler.TakeOverForbidden
|
||||
onTapped: chip.clicked()
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,13 @@ export interface AppSettings {
|
||||
short_break_minutes: number;
|
||||
long_break_minutes: number;
|
||||
cycles_before_long_break: number;
|
||||
presets: {
|
||||
name: string;
|
||||
work_minutes: number;
|
||||
short_break_minutes: number;
|
||||
long_break_minutes: number;
|
||||
cycles_before_long_break: number;
|
||||
}[];
|
||||
auto_start_breaks: boolean;
|
||||
auto_start_work: boolean;
|
||||
daily_goal_pomodoros: number;
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
@@ -44,6 +46,48 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
|
||||
if (!settings) return <Typography>Loading…</Typography>;
|
||||
|
||||
// Which saved preset the live Focus/Short/Long minutes currently match, if
|
||||
// any — "Custom" the moment they drift from every one of them.
|
||||
const presets = settings.pomodoro.presets;
|
||||
const activePresetIndex = presets.findIndex(
|
||||
(preset) =>
|
||||
preset.work_minutes === settings.pomodoro.work_minutes &&
|
||||
preset.short_break_minutes === settings.pomodoro.short_break_minutes &&
|
||||
preset.long_break_minutes === settings.pomodoro.long_break_minutes,
|
||||
);
|
||||
|
||||
const applyPreset = (index: number) => {
|
||||
const preset = presets[index];
|
||||
void patch(
|
||||
{
|
||||
pomodoro: {
|
||||
work_minutes: preset.work_minutes,
|
||||
short_break_minutes: preset.short_break_minutes,
|
||||
long_break_minutes: preset.long_break_minutes,
|
||||
cycles_before_long_break: preset.cycles_before_long_break,
|
||||
},
|
||||
},
|
||||
`${preset.name} applied`,
|
||||
);
|
||||
};
|
||||
|
||||
// Editing Focus/Short/Long always changes the live settings; when they
|
||||
// still match a saved preset it also rewrites that preset's own numbers,
|
||||
// so applying it again later lands where you left off, not on a stale value.
|
||||
const patchDuration = (
|
||||
key: "work_minutes" | "short_break_minutes" | "long_break_minutes",
|
||||
value: number,
|
||||
) => {
|
||||
if (activePresetIndex === -1) {
|
||||
void patch({ pomodoro: { [key]: value } });
|
||||
return;
|
||||
}
|
||||
const updated = presets.map((preset, index) =>
|
||||
index === activePresetIndex ? { ...preset, [key]: value } : preset,
|
||||
);
|
||||
void patch({ pomodoro: { presets: updated, [key]: value } });
|
||||
};
|
||||
|
||||
const testSource = async (name: string) => {
|
||||
setTesting(name);
|
||||
try {
|
||||
@@ -65,6 +109,21 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
/>
|
||||
<Stack spacing={5}>
|
||||
<Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot.">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }}>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={activePresetIndex === -1 ? null : activePresetIndex}
|
||||
onChange={(_event, value) => value !== null && applyPreset(value as number)}
|
||||
>
|
||||
{presets.map((preset, index) => (
|
||||
<ToggleButton key={preset.name} value={index}>
|
||||
{preset.name}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
{activePresetIndex === -1 && <Chip size="small" variant="outlined" label="Custom" />}
|
||||
</Stack>
|
||||
<Grid container spacing={2}>
|
||||
{(
|
||||
[
|
||||
@@ -81,7 +140,11 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
value={settings.pomodoro[key] as number}
|
||||
min={min}
|
||||
max={max}
|
||||
onCommit={(value) => void patch({ pomodoro: { [key]: value } })}
|
||||
onCommit={(value) =>
|
||||
key === "work_minutes" || key === "short_break_minutes" || key === "long_break_minutes"
|
||||
? patchDuration(key, value)
|
||||
: void patch({ pomodoro: { [key]: value } })
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
@@ -136,6 +138,39 @@ export default function TimerPage({
|
||||
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
|
||||
const accent = kindColor(kind, palette);
|
||||
|
||||
// Which saved preset the live Focus/Short/Long minutes match, if any.
|
||||
const presets = settings?.pomodoro.presets ?? [];
|
||||
const activePresetIndex = settings
|
||||
? presets.findIndex(
|
||||
(preset) =>
|
||||
preset.work_minutes === settings.pomodoro.work_minutes &&
|
||||
preset.short_break_minutes === settings.pomodoro.short_break_minutes &&
|
||||
preset.long_break_minutes === settings.pomodoro.long_break_minutes,
|
||||
)
|
||||
: -1;
|
||||
|
||||
// The backend resizes whatever interval is already running or paused to
|
||||
// match, so a fresh `/api/timer/state` right away makes it feel instant
|
||||
// instead of waiting out this page's own 10s poll.
|
||||
const applyPreset = async (index: number) => {
|
||||
const preset = presets[index];
|
||||
if (!preset) return;
|
||||
try {
|
||||
await save({
|
||||
pomodoro: {
|
||||
work_minutes: preset.work_minutes,
|
||||
short_break_minutes: preset.short_break_minutes,
|
||||
long_break_minutes: preset.long_break_minutes,
|
||||
cycles_before_long_break: preset.cycles_before_long_break,
|
||||
},
|
||||
});
|
||||
await timer.reload();
|
||||
onToast(`${preset.name} applied`);
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -220,8 +255,25 @@ export default function TimerPage({
|
||||
idle={!timerEnabled || !current}
|
||||
/>
|
||||
|
||||
{timerEnabled && presets.length > 0 && (
|
||||
<Stack direction="row" justifyContent="center" sx={{ mt: 3 }}>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={activePresetIndex === -1 ? null : activePresetIndex}
|
||||
onChange={(_event, value) => value !== null && void applyPreset(value as number)}
|
||||
>
|
||||
{presets.map((preset, index) => (
|
||||
<ToggleButton key={preset.name} value={index}>
|
||||
{preset.name}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{current && timerEnabled && (
|
||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
|
||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 2 }}>
|
||||
<Tooltip title="Add five minutes">
|
||||
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/extend", { minutes: 5 })}>
|
||||
<AddAlarmOutlinedIcon fontSize="small" />
|
||||
|
||||
Reference in new issue
Block a user