- 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.
606 lines
20 KiB
TypeScript
606 lines
20 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
|
|
export default function SettingsPage({ onToast }: { onToast: (message: string) => void }) {
|
|
const { settings, reload, save } = useSettings();
|
|
const [token, setLocalToken] = useState(getToken());
|
|
const [testing, setTesting] = useState<string | null>(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 <Typography>Loading…</Typography>;
|
|
|
|
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 (
|
|
<>
|
|
<PageHeader
|
|
eyebrow="Shared with the desktop app and the Telegram bot"
|
|
title="Settings"
|
|
caption="Secrets are masked once stored — leave the dots alone to keep the saved value."
|
|
/>
|
|
<Stack spacing={5}>
|
|
<Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot.">
|
|
<Grid container spacing={2}>
|
|
{(
|
|
[
|
|
["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]) => (
|
|
<Grid item xs={6} md={2.4} key={key}>
|
|
<NumberField
|
|
label={label}
|
|
value={settings.pomodoro[key] as number}
|
|
min={min}
|
|
max={max}
|
|
onCommit={(value) => void patch({ pomodoro: { [key]: value } })}
|
|
/>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
<Stack direction="row" sx={{ flexWrap: "wrap", mt: 1 }}>
|
|
{(
|
|
[
|
|
["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]) => (
|
|
<FormControlLabel
|
|
key={key}
|
|
control={
|
|
<Switch
|
|
checked={settings.pomodoro[key] as boolean}
|
|
onChange={(event) => void patch({ pomodoro: { [key]: event.target.checked } })}
|
|
/>
|
|
}
|
|
label={label}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
<Box sx={{ maxWidth: 320, mt: 1 }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Alarm volume
|
|
</Typography>
|
|
<Slider
|
|
value={settings.pomodoro.alarm_volume}
|
|
min={0}
|
|
max={100}
|
|
valueLabelDisplay="auto"
|
|
onChangeCommitted={(_event, value) =>
|
|
void patch({ pomodoro: { alarm_volume: value as number } })
|
|
}
|
|
/>
|
|
</Box>
|
|
</Section>
|
|
|
|
<Section title="Task sources" subtitle="The same credentials jira-helper uses.">
|
|
<SourceBlock
|
|
name="jira"
|
|
label="Jira"
|
|
enabled={settings.sources.jira.enabled}
|
|
onToggle={(value) => void patch({ sources: { jira: { enabled: value } } })}
|
|
onTest={() => void testSource("jira")}
|
|
testing={testing === "jira"}
|
|
>
|
|
<Field
|
|
label="Site URL"
|
|
value={settings.sources.jira.base_url}
|
|
placeholder="https://your-site.atlassian.net"
|
|
onCommit={(value) => void patch({ sources: { jira: { base_url: value } } })}
|
|
/>
|
|
<Field
|
|
label="Account email"
|
|
value={settings.sources.jira.email}
|
|
onCommit={(value) => void patch({ sources: { jira: { email: value } } })}
|
|
/>
|
|
<Field
|
|
label="API token"
|
|
value={settings.sources.jira.api_token}
|
|
secret
|
|
onCommit={(value) => void patch({ sources: { jira: { api_token: value } } })}
|
|
/>
|
|
<Field
|
|
label="Extra JQL"
|
|
value={settings.sources.jira.extra_jql}
|
|
placeholder="assignee = currentUser()"
|
|
onCommit={(value) => void patch({ sources: { jira: { extra_jql: value } } })}
|
|
/>
|
|
<Field
|
|
label="Start-date field"
|
|
value={settings.sources.jira.start_date_field}
|
|
onCommit={(value) => void patch({ sources: { jira: { start_date_field: value } } })}
|
|
/>
|
|
</SourceBlock>
|
|
|
|
<SourceBlock
|
|
name="todoist"
|
|
label="Todoist"
|
|
enabled={settings.sources.todoist.enabled}
|
|
onToggle={(value) => void patch({ sources: { todoist: { enabled: value } } })}
|
|
onTest={() => void testSource("todoist")}
|
|
testing={testing === "todoist"}
|
|
>
|
|
<Field
|
|
label="API token"
|
|
value={settings.sources.todoist.api_token}
|
|
secret
|
|
onCommit={(value) => void patch({ sources: { todoist: { api_token: value } } })}
|
|
/>
|
|
<Field
|
|
label="Projects (comma separated, blank = all)"
|
|
value={settings.sources.todoist.projects.join(", ")}
|
|
onCommit={(value) =>
|
|
void patch({
|
|
sources: {
|
|
todoist: { projects: value.split(",").map((item) => item.trim()).filter(Boolean) },
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
<Field
|
|
label="Filter query (blank = every open task)"
|
|
value={settings.sources.todoist.filter_query}
|
|
placeholder="overdue | today"
|
|
onCommit={(value) => void patch({ sources: { todoist: { filter_query: value } } })}
|
|
/>
|
|
</SourceBlock>
|
|
|
|
<SourceBlock
|
|
name="google_tasks"
|
|
label="Google Tasks"
|
|
enabled={settings.sources.google_tasks.enabled}
|
|
onToggle={(value) => void patch({ sources: { google_tasks: { enabled: value } } })}
|
|
onTest={() => void testSource("google_tasks")}
|
|
testing={testing === "google_tasks"}
|
|
>
|
|
<Field
|
|
label="Client id"
|
|
value={settings.sources.google_tasks.client_id}
|
|
onCommit={(value) => void patch({ sources: { google_tasks: { client_id: value } } })}
|
|
/>
|
|
<Field
|
|
label="Client secret"
|
|
value={settings.sources.google_tasks.client_secret}
|
|
secret
|
|
onCommit={(value) => void patch({ sources: { google_tasks: { client_secret: value } } })}
|
|
/>
|
|
<Field
|
|
label="Refresh token"
|
|
value={settings.sources.google_tasks.refresh_token}
|
|
secret
|
|
onCommit={(value) => void patch({ sources: { google_tasks: { refresh_token: value } } })}
|
|
/>
|
|
</SourceBlock>
|
|
|
|
<SourceBlock
|
|
name="laundry"
|
|
label="Laundry bookings"
|
|
enabled={settings.sources.laundry.enabled}
|
|
onToggle={(value) => void patch({ sources: { laundry: { enabled: value } } })}
|
|
onTest={() => void testSource("laundry")}
|
|
testing={testing === "laundry"}
|
|
>
|
|
<Field
|
|
label="Site"
|
|
value={settings.sources.laundry.base_url}
|
|
onCommit={(value) => void patch({ sources: { laundry: { base_url: value } } })}
|
|
/>
|
|
<Field
|
|
label="sessionid cookie"
|
|
value={settings.sources.laundry.session_cookie}
|
|
secret
|
|
onCommit={(value) => void patch({ sources: { laundry: { session_cookie: value } } })}
|
|
/>
|
|
</SourceBlock>
|
|
|
|
<NumberField
|
|
label="Refresh every (minutes)"
|
|
value={settings.sources.refresh_interval_minutes}
|
|
min={1}
|
|
max={1440}
|
|
onCommit={(value) => void patch({ sources: { refresh_interval_minutes: value } })}
|
|
/>
|
|
</Section>
|
|
|
|
<Section
|
|
title="Telegram"
|
|
subtitle="Daily report, morning briefing, nudges — and /today, /report commands in the chat."
|
|
>
|
|
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", rowGap: 2 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={settings.notifications.telegram.enabled}
|
|
onChange={(event) =>
|
|
void patch({ notifications: { telegram: { enabled: event.target.checked } } })
|
|
}
|
|
/>
|
|
}
|
|
label="Enabled"
|
|
/>
|
|
<Field
|
|
label="Bot token"
|
|
value={settings.notifications.telegram.bot_token}
|
|
secret
|
|
onCommit={(value) => void patch({ notifications: { telegram: { bot_token: value } } })}
|
|
/>
|
|
<Field
|
|
label="Chat id"
|
|
value={settings.notifications.telegram.chat_id}
|
|
onCommit={(value) => void patch({ notifications: { telegram: { chat_id: value } } })}
|
|
/>
|
|
<Button
|
|
onClick={async () => {
|
|
const result = await api.post<{ deliveries: { channel: string; ok: boolean; detail: string }[] }>(
|
|
"/api/notifications/test",
|
|
{},
|
|
);
|
|
onToast(result.deliveries.map((d) => `${d.channel}: ${d.ok ? "ok" : d.detail}`).join(" · "));
|
|
}}
|
|
>
|
|
Send a test message
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
|
<Grid item xs={6} md={3}>
|
|
<TimeField
|
|
label="Daily report at"
|
|
value={settings.notifications.telegram.daily_report_time}
|
|
onCommit={(value) =>
|
|
void patch({ notifications: { telegram: { daily_report_time: value } } })
|
|
}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} md={3}>
|
|
<TimeField
|
|
label="Morning briefing at"
|
|
value={settings.notifications.telegram.morning_briefing_time}
|
|
onCommit={(value) =>
|
|
void patch({ notifications: { telegram: { morning_briefing_time: value } } })
|
|
}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} md={3}>
|
|
<NumberField
|
|
label="Nudge after idle (min)"
|
|
value={settings.notifications.telegram.nudge_after_idle_minutes}
|
|
min={10}
|
|
max={600}
|
|
onCommit={(value) =>
|
|
void patch({ notifications: { telegram: { nudge_after_idle_minutes: value } } })
|
|
}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} md={3}>
|
|
<NumberField
|
|
label="Cheer every N pomodoros"
|
|
value={settings.notifications.telegram.celebrate_every_n_pomodoros}
|
|
min={0}
|
|
max={50}
|
|
onCommit={(value) =>
|
|
void patch({ notifications: { telegram: { celebrate_every_n_pomodoros: value } } })
|
|
}
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<Stack direction="row" sx={{ flexWrap: "wrap", mt: 1 }}>
|
|
{(
|
|
[
|
|
["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]) => (
|
|
<FormControlLabel
|
|
key={key}
|
|
control={
|
|
<Switch
|
|
checked={settings.notifications.telegram[key] as boolean}
|
|
onChange={(event) =>
|
|
void patch({ notifications: { telegram: { [key]: event.target.checked } } })
|
|
}
|
|
/>
|
|
}
|
|
label={label}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
|
|
<Stack direction="row" spacing={2} sx={{ mt: 1, flexWrap: "wrap", rowGap: 2 }}>
|
|
<TextField
|
|
select
|
|
label="Tone"
|
|
sx={{ minWidth: 200 }}
|
|
value={settings.notifications.report.tone}
|
|
onChange={(event) =>
|
|
void patch({ notifications: { report: { tone: event.target.value } } })
|
|
}
|
|
>
|
|
<MenuItem value="friendly">Friendly</MenuItem>
|
|
<MenuItem value="coach">Coach</MenuItem>
|
|
<MenuItem value="drill_sergeant">Drill sergeant</MenuItem>
|
|
<MenuItem value="dry">Dry</MenuItem>
|
|
</TextField>
|
|
<TextField
|
|
select
|
|
label="Language"
|
|
sx={{ minWidth: 160 }}
|
|
value={settings.notifications.report.language}
|
|
onChange={(event) =>
|
|
void patch({ notifications: { report: { language: event.target.value } } })
|
|
}
|
|
>
|
|
<MenuItem value="en">English</MenuItem>
|
|
<MenuItem value="ru">Русский</MenuItem>
|
|
</TextField>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={settings.notifications.report.include_funny_stats}
|
|
onChange={(event) =>
|
|
void patch({ notifications: { report: { include_funny_stats: event.target.checked } } })
|
|
}
|
|
/>
|
|
}
|
|
label="Funny stats"
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={settings.notifications.telegram.quiet_hours.enabled}
|
|
onChange={(event) =>
|
|
void patch({
|
|
notifications: { telegram: { quiet_hours: { enabled: event.target.checked } } },
|
|
})
|
|
}
|
|
/>
|
|
}
|
|
label="Quiet hours"
|
|
/>
|
|
</Stack>
|
|
</Section>
|
|
|
|
<Section title="General" subtitle="Timezone, theme, and this browser's API token.">
|
|
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", rowGap: 2 }}>
|
|
<Field
|
|
label="Timezone"
|
|
value={settings.timezone}
|
|
onCommit={(value) => void patch({ timezone: value })}
|
|
/>
|
|
<TextField
|
|
select
|
|
label="Theme"
|
|
sx={{ minWidth: 160 }}
|
|
value={settings.appearance.theme}
|
|
onChange={(event) => void patch({ appearance: { theme: event.target.value } })}
|
|
>
|
|
<MenuItem value="system">Follow the system</MenuItem>
|
|
<MenuItem value="light">Light</MenuItem>
|
|
<MenuItem value="dark">Dark</MenuItem>
|
|
</TextField>
|
|
<TextField
|
|
label="API token (this browser)"
|
|
type="password"
|
|
value={token}
|
|
onChange={(event) => setLocalToken(event.target.value)}
|
|
onBlur={() => {
|
|
setToken(token);
|
|
void reload();
|
|
onToast("Token stored in this browser");
|
|
}}
|
|
/>
|
|
</Stack>
|
|
<Alert severity="info" variant="outlined" sx={{ mt: 2 }}>
|
|
Website and application blocking is enforced by the native desktop app — install it from{" "}
|
|
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
|
|
</Alert>
|
|
</Section>
|
|
</Stack>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
subtitle,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Box>
|
|
<SectionTitle>{title}</SectionTitle>
|
|
{subtitle && (
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
{subtitle}
|
|
</Typography>
|
|
)}
|
|
<Stack spacing={2}>{children}</Stack>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Panel>
|
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: enabled ? 1.5 : 0 }}>
|
|
<Typography sx={{ fontWeight: 600, flexGrow: 1 }}>{label}</Typography>
|
|
{enabled && <Chip size="small" color="success" variant="outlined" label="on" />}
|
|
<Button size="small" disabled={!enabled || testing} onClick={onTest}>
|
|
{testing ? "Testing…" : "Test connection"}
|
|
</Button>
|
|
<Switch checked={enabled} onChange={(event) => onToggle(event.target.checked)} />
|
|
</Stack>
|
|
{enabled && (
|
|
<Stack spacing={1.5} direction="row" sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
|
|
{children}
|
|
</Stack>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<TextField
|
|
label={label}
|
|
value={draft}
|
|
placeholder={placeholder}
|
|
type={secret ? "password" : "text"}
|
|
sx={{ minWidth: 260 }}
|
|
onChange={(event) => 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 (
|
|
<TextField
|
|
label={label}
|
|
type="number"
|
|
value={draft}
|
|
fullWidth
|
|
inputProps={{ min, max }}
|
|
onChange={(event) => 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 (
|
|
<TextField
|
|
label={label}
|
|
type="time"
|
|
fullWidth
|
|
InputLabelProps={{ shrink: true }}
|
|
value={(value || "00:00").slice(0, 5)}
|
|
onChange={(event) => onCommit(`${event.target.value}:00`)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export type { AppSettings };
|