/** * Every setting, grouped the way you think about them. * * Secrets come back masked (`••••••••`) and are only sent when you actually type * a new one, so saving this form never leaks — or clobbers — a token. */ import { useEffect, useState } from "react"; import { Alert, Box, Button, Chip, FormControlLabel, Grid, MenuItem, Slider, Stack, Switch, TextField, Typography, } from "@mui/material"; import { PageHeader, Panel, SectionTitle } from "../components/Layout"; import { api, getToken, setToken } from "../lib/api"; import { useSettings } from "../lib/settings"; import type { AppSettings } from "../lib/types"; type Patch = Record; export default function SettingsPage({ onToast }: { onToast: (message: string) => void }) { const { settings, reload, save } = useSettings(); const [token, setLocalToken] = useState(getToken()); const [testing, setTesting] = useState(null); const patch = async (change: Patch, message = "Saved") => { try { await save(change); onToast(message); } catch (exc) { onToast(exc instanceof Error ? exc.message : String(exc)); } }; if (!settings) return Loading…; const testSource = async (name: string) => { setTesting(name); try { const result = await api.post<{ ok: boolean; detail: string }>(`/api/tasks/sources/${name}/test`); onToast(`${name}: ${result.ok ? "✓ " : "✗ "}${result.detail}`); } catch (exc) { onToast(exc instanceof Error ? exc.message : String(exc)); } finally { setTesting(null); } }; return ( <>
{( [ ["work_minutes", "Focus (min)", 1, 240], ["short_break_minutes", "Short break", 1, 120], ["long_break_minutes", "Long break", 1, 240], ["cycles_before_long_break", "Long break every", 1, 12], ["daily_goal_pomodoros", "Daily goal", 1, 40], ] as const ).map(([key, label, min, max]) => ( void patch({ pomodoro: { [key]: value } })} /> ))} {( [ ["timer_enabled", "Timer enabled (off = view only)"], ["auto_start_breaks", "Auto-start breaks"], ["auto_start_work", "Auto-start next focus"], ["allow_pause", "Allow pausing"], ["strict_mode", "Strict mode (a reason to abandon)"], ["count_interruptions", "Count interruptions"], ["alarm_sound", "Alarm sound"], ["desktop_notifications", "Browser notifications"], ] as const ).map(([key, label]) => ( void patch({ pomodoro: { [key]: event.target.checked } })} /> } label={label} /> ))} Alarm volume void patch({ pomodoro: { alarm_volume: value as number } }) } />
void patch({ sources: { jira: { enabled: value } } })} onTest={() => void testSource("jira")} testing={testing === "jira"} > void patch({ sources: { jira: { base_url: value } } })} /> void patch({ sources: { jira: { email: value } } })} /> void patch({ sources: { jira: { api_token: value } } })} /> void patch({ sources: { jira: { extra_jql: value } } })} /> void patch({ sources: { jira: { start_date_field: value } } })} /> void patch({ sources: { todoist: { enabled: value } } })} onTest={() => void testSource("todoist")} testing={testing === "todoist"} > void patch({ sources: { todoist: { api_token: value } } })} /> void patch({ sources: { todoist: { projects: value.split(",").map((item) => item.trim()).filter(Boolean) }, }, }) } /> void patch({ sources: { todoist: { filter_query: value } } })} /> void patch({ sources: { google_tasks: { enabled: value } } })} onTest={() => void testSource("google_tasks")} testing={testing === "google_tasks"} > void patch({ sources: { google_tasks: { client_id: value } } })} /> void patch({ sources: { google_tasks: { client_secret: value } } })} /> void patch({ sources: { google_tasks: { refresh_token: value } } })} /> void patch({ sources: { laundry: { enabled: value } } })} onTest={() => void testSource("laundry")} testing={testing === "laundry"} > void patch({ sources: { laundry: { base_url: value } } })} /> void patch({ sources: { laundry: { session_cookie: value } } })} /> void patch({ sources: { refresh_interval_minutes: value } })} />
void patch({ notifications: { telegram: { enabled: event.target.checked } } }) } /> } label="Enabled" /> void patch({ notifications: { telegram: { bot_token: value } } })} /> void patch({ notifications: { telegram: { chat_id: value } } })} /> void patch({ notifications: { telegram: { daily_report_time: value } } }) } /> void patch({ notifications: { telegram: { morning_briefing_time: value } } }) } /> void patch({ notifications: { telegram: { nudge_after_idle_minutes: value } } }) } /> void patch({ notifications: { telegram: { celebrate_every_n_pomodoros: value } } }) } /> {( [ ["daily_report", "Daily report"], ["morning_briefing", "Morning briefing"], ["weekly_report", "Weekly review"], ["nudge_enabled", "Nudge me when idle"], ["commands_enabled", "Chat commands"], ["goal_warning", "Warn when the goal slips away"], ] as const ).map(([key, label]) => ( void patch({ notifications: { telegram: { [key]: event.target.checked } } }) } /> } label={label} /> ))} void patch({ notifications: { report: { tone: event.target.value } } }) } > Friendly Coach Drill sergeant Dry void patch({ notifications: { report: { language: event.target.value } } }) } > English Русский void patch({ notifications: { report: { include_funny_stats: event.target.checked } } }) } /> } label="Funny stats" /> void patch({ notifications: { telegram: { quiet_hours: { enabled: event.target.checked } } }, }) } /> } label="Quiet hours" />
void patch({ timezone: value })} /> void patch({ appearance: { theme: event.target.value } })} > Follow the system Light Dark setLocalToken(event.target.value)} onBlur={() => { setToken(token); void reload(); onToast("Token stored in this browser"); }} /> Website and application blocking is enforced by the native desktop app — install it from{" "} desktop/; its lists live on the Blocker page.
); } function Section({ title, subtitle, children, }: { title: string; subtitle?: string; children: React.ReactNode; }) { return ( {title} {subtitle && ( {subtitle} )} {children} ); } function SourceBlock({ label, enabled, onToggle, onTest, testing, children, }: { name: string; label: string; enabled: boolean; onToggle: (value: boolean) => void; onTest: () => void; testing: boolean; children: React.ReactNode; }) { return ( {label} {enabled && } onToggle(event.target.checked)} /> {enabled && ( {children} )} ); } /** A text field that only reports a change when you leave it (one save per edit). */ function Field({ label, value, placeholder, secret, onCommit, }: { label: string; value: string; placeholder?: string; secret?: boolean; onCommit: (value: string) => void; }) { const [draft, setDraft] = useState(value); useEffect(() => setDraft(value), [value]); return ( setDraft(event.target.value)} onBlur={() => draft !== value && onCommit(draft)} onKeyDown={(event) => { if (event.key === "Enter" && draft !== value) onCommit(draft); }} /> ); } function NumberField({ label, value, min, max, onCommit, }: { label: string; value: number; min: number; max: number; onCommit: (value: number) => void; }) { const [draft, setDraft] = useState(String(value)); useEffect(() => setDraft(String(value)), [value]); const commit = () => { const parsed = Number(draft); if (Number.isFinite(parsed) && parsed >= min && parsed <= max && parsed !== value) onCommit(parsed); else setDraft(String(value)); }; return ( setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => event.key === "Enter" && commit()} /> ); } function TimeField({ label, value, onCommit, }: { label: string; value: string; onCommit: (value: string) => void; }) { return ( onCommit(`${event.target.value}:00`)} /> ); } export type { AppSettings };