init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* 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 {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
MenuItem,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
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 (
|
||||
<Stack spacing={2}>
|
||||
<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 }}>
|
||||
{(
|
||||
[
|
||||
["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 } } })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.sources.jira.writeback_done}
|
||||
onChange={(event) =>
|
||||
void patch({ sources: { jira: { writeback_done: event.target.checked } } })
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Completing a task here transitions the Jira issue to Done"
|
||||
/>
|
||||
</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="Accent colour"
|
||||
type="color"
|
||||
sx={{ width: 120 }}
|
||||
value={settings.appearance.accent}
|
||||
onChange={(event) => void patch({ appearance: { accent: event.target.value } })}
|
||||
/>
|
||||
<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" sx={{ mt: 2 }}>
|
||||
Website and application blocking is enforced by the native desktop app — install it from{" "}
|
||||
<code>desktop/</code>, then 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 (
|
||||
<Accordion defaultExpanded disableGutters sx={{ borderRadius: 3, "&::before": { display: "none" } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={1.5}>{children}</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ border: 1, borderColor: "divider", borderRadius: 3, p: 2 }}>
|
||||
<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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
Reference in new issue
Block a user