refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency

- 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.
This commit is contained in:
timka123 committed 2026-10-18 23:57:36 +03:00
1 parent 17a0a3c4c9
commit 77b26757bf
84 files changed
+4501 -2682

No files matched your search

+222 -237
View File
@@ -1,17 +1,15 @@
/**
* The blocker's rules and its live state.
* The focus guard: what is blocked, when, and what it caught.
*
* The rules are server-side so every device agrees; the *enforcement* happens in
* the native desktop app, which polls the same policy this page edits. The web
* UI is honest about that: it shows whether anything is currently enforcing.
* Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
* expanded server-side into real hostnames — this page shows that expansion, and
* says plainly when a pattern (`*gram*`) cannot be expressed that way.
*/
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Divider,
FormControlLabel,
@@ -22,21 +20,27 @@ import {
TextField,
Typography,
} from "@mui/material";
import ShieldIcon from "@mui/icons-material/Shield";
import StatTile from "../components/StatTile";
import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
import { useResource } from "../lib/hooks";
import { useSettings } from "../lib/settings";
import type { BlockEvents, BlockerPolicy } from "../lib/types";
const MASK_HELP = [
["youtube.com", "the site itself, plus www. and m."],
["*.google.com", "every subdomain in the expansion list below"],
["*gram*", "matches apps; a hosts file cannot express it"],
];
export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) {
const { settings, defaults, save } = useSettings();
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 5000);
const policy = useResource<BlockerPolicy & { unexpandable?: string[] }>("/api/blocker/policy", 5000);
const events = useResource<BlockEvents>("/api/blocker/events", 20000);
const [domains, setDomains] = useState("");
const [allowed, setAllowed] = useState("");
const [apps, setApps] = useState("");
const [subdomains, setSubdomains] = useState("");
const [dirty, setDirty] = useState(false);
useEffect(() => {
@@ -44,257 +48,238 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
setDomains(settings.blocker.blocked_domains.join("\n"));
setAllowed(settings.blocker.allowed_domains.join("\n"));
setApps(settings.blocker.blocked_apps.join("\n"));
setSubdomains((settings.blocker.subdomain_expansions ?? []).join(", "));
}, [settings, dirty]);
const lines = (value: string) =>
value
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
const lines = (value: string) => value.split("\n").map((item) => item.trim()).filter(Boolean);
const patch = async (change: Record<string, unknown>) => {
const patch = async (change: Record<string, unknown>, message = "Saved") => {
try {
await save({ blocker: change });
await policy.reload();
onToast("Blocker updated");
onToast(message);
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
}
};
const saveLists = async () => {
await patch({
blocked_domains: lines(domains),
allowed_domains: lines(allowed),
blocked_apps: lines(apps),
});
await patch(
{
blocked_domains: lines(domains),
allowed_domains: lines(allowed),
blocked_apps: lines(apps),
subdomain_expansions: subdomains.split(",").map((item) => item.trim()).filter(Boolean),
},
"Lists saved",
);
setDirty(false);
};
const addDefaults = () => {
const current = new Set(lines(domains));
(defaults?.blocked_domains ?? []).forEach((domain) => current.add(domain));
setDomains([...current].join("\n"));
setDirty(true);
};
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
const blocker = settings?.blocker;
const unexpandable = policy.data?.unexpandable ?? [];
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
return (
<Stack spacing={3}>
<Card>
<CardContent>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
<ShieldIcon color={policy.data?.active ? "error" : "disabled"} fontSize="large" />
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6">
{policy.data?.active ? "Blocking is ON" : "Blocking is off"}
</Typography>
<Typography variant="body2" color="text.secondary">
{policy.data?.reason}
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={blocker?.enabled ?? false}
onChange={(event) => void patch({ enabled: event.target.checked })}
/>
}
label="Enabled"
/>
</Stack>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<StatTile label="Domains" value={policy.data?.all_domains.length ?? 0} caption="redirected" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Apps" value={policy.data?.all_apps.length ?? 0} caption="closed on sight" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Attempts today" value={events.data?.total ?? 0} caption="turned away" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile
label="Mode"
value={
{ during_work: "Focus", always: "Always", scheduled: "Schedule" }[
blocker?.mode ?? "during_work"
]
}
caption="when it applies"
<>
<PageHeader
eyebrow={policy.data?.reason ?? "—"}
title={policy.data?.active ? "Blocking is on" : "Blocking is off"}
caption="Rules live here; the desktop app enforces them on your machine."
actions={
<FormControlLabel
control={
<Switch
checked={blocker?.enabled ?? false}
onChange={(event) => void patch({ enabled: event.target.checked })}
/>
</Grid>
}
label="Enabled"
/>
}
/>
<StatRow>
<Stat label="Hostnames" value={policy.data?.all_domains.length ?? 0} caption="after expansion" />
<Stat label="Apps" value={policy.data?.all_apps.length ?? 0} caption="closed on sight" />
<Stat label="Attempts today" value={events.data?.total ?? 0} caption="turned away" />
<Stat
label="Mode"
value={{ during_work: "Focus", always: "Always", scheduled: "Schedule" }[blocker?.mode ?? "during_work"]}
caption="when it applies"
size="sm"
/>
</StatRow>
<Box sx={{ mt: 4 }}>
<SectionTitle>When</SectionTitle>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }}>
<TextField
select
label="Mode"
sx={{ minWidth: 240 }}
value={blocker?.mode ?? "during_work"}
onChange={(event) => void patch({ mode: event.target.value })}
>
<MenuItem value="during_work">While a focus interval runs</MenuItem>
<MenuItem value="always">Always</MenuItem>
<MenuItem value="scheduled">On a schedule</MenuItem>
</TextField>
<FormControlLabel
control={
<Switch
checked={blocker?.block_during_breaks ?? false}
onChange={(event) => void patch({ block_during_breaks: event.target.checked })}
/>
}
label="Keep blocking through breaks"
/>
<FormControlLabel
control={
<Switch
checked={blocker?.kill_blocked_apps ?? true}
onChange={(event) => void patch({ kill_blocked_apps: event.target.checked })}
/>
}
label="Close blocked apps (otherwise only warn)"
/>
</Stack>
</Box>
<Box sx={{ mt: 4 }}>
<SectionTitle>Masks</SectionTitle>
<Panel sx={{ mb: 3 }}>
<Stack divider={<Divider />}>
{MASK_HELP.map(([pattern, meaning]) => (
<Stack key={pattern} direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ py: 1 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", minWidth: 150 }}>
{pattern}
</Typography>
<Typography variant="body2" color="text.secondary">
{meaning}
</Typography>
</Stack>
))}
</Stack>
</Panel>
{unexpandable.length > 0 && (
<Alert severity="info" variant="outlined" sx={{ mb: 3 }}>
{unexpandable.join(", ")} {unexpandable.length === 1 ? "is a pattern" : "are patterns"} a hosts file
cannot express — {unexpandable.length === 1 ? "it is" : "they are"} still matched against application
names.
</Alert>
)}
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<SectionTitle>Blocked websites</SectionTitle>
<TextField
multiline
fullWidth
minRows={9}
value={domains}
placeholder={"youtube.com\n*.reddit.com\ntwitter.com"}
onChange={(event) => {
setDomains(event.target.value);
setDirty(true);
}}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
/>
<Button
size="small"
sx={{ mt: 1 }}
onClick={() => {
const current = new Set(lines(domains));
(defaults?.blocked_domains ?? []).forEach((item) => current.add(item));
setDomains([...current].join("\n"));
setDirty(true);
}}
>
Add the usual suspects
</Button>
</Grid>
<Alert severity="info" sx={{ mt: 2 }}>
Enforcement happens in the desktop app — it rewrites <code>/etc/hosts</code> through a
privileged helper and closes blocked applications. This page decides <em>what</em> and{" "}
<em>when</em>.
</Alert>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
When to block
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<SectionTitle>Exceptions</SectionTitle>
<TextField
select
label="Mode"
sx={{ minWidth: 220 }}
value={blocker?.mode ?? "during_work"}
onChange={(event) => void patch({ mode: event.target.value })}
>
<MenuItem value="during_work">While a focus interval runs</MenuItem>
<MenuItem value="always">Always</MenuItem>
<MenuItem value="scheduled">On a schedule</MenuItem>
</TextField>
<FormControlLabel
control={
<Switch
checked={blocker?.block_during_breaks ?? false}
onChange={(event) => void patch({ block_during_breaks: event.target.checked })}
/>
}
label="Keep blocking during breaks"
multiline
fullWidth
minRows={9}
value={allowed}
placeholder={"music.youtube.com\n*.docs.google.com"}
onChange={(event) => {
setAllowed(event.target.value);
setDirty(true);
}}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
/>
<FormControlLabel
control={
<Switch
checked={blocker?.kill_blocked_apps ?? true}
onChange={(event) => void patch({ kill_blocked_apps: event.target.checked })}
/>
}
label="Close blocked apps (otherwise just warn)"
/>
</Stack>
</CardContent>
</Card>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<Card sx={{ height: "100%" }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Blocked websites
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
One hostname per line. <code>www.</code> and <code>m.</code> are added for you.
</Typography>
<TextField
multiline
fullWidth
minRows={8}
value={domains}
placeholder={"youtube.com\nreddit.com\ntwitter.com"}
onChange={(event) => {
setDomains(event.target.value);
setDirty(true);
}}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }}
/>
<Button size="small" sx={{ mt: 1 }} onClick={addDefaults}>
Add the usual suspects
</Button>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card sx={{ height: "100%" }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Exceptions
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Survive a blocked parent domain — e.g. <code>music.youtube.com</code>.
</Typography>
<TextField
multiline
fullWidth
minRows={8}
value={allowed}
placeholder="music.youtube.com"
onChange={(event) => {
setAllowed(event.target.value);
setDirty(true);
}}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card sx={{ height: "100%" }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Blocked applications
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Process names, matched loosely: <code>steam</code> also catches{" "}
<code>steamwebhelper</code>.
</Typography>
<TextField
multiline
fullWidth
minRows={8}
value={apps}
placeholder={"steam\ndiscord\ntelegram-desktop"}
onChange={(event) => {
setApps(event.target.value);
setDirty(true);
}}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }}
/>
</CardContent>
</Card>
</Grid>
</Grid>
<Stack direction="row" spacing={1}>
<Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}>
Save lists
</Button>
<Button
disabled={!dirty}
onClick={() => {
setDirty(false);
}}
>
Discard changes
</Button>
</Stack>
<Card>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Today's temptations
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Every attempt the blocker turned away. This is what the daily report teases you about.
</Typography>
{top.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Nothing yet today — either the blocker is off, or you are behaving.
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
Masks allowed here too — an exception wins over a block.
</Typography>
) : (
<Stack divider={<Divider flexItem />}>
{top.map((entry) => (
<Stack key={entry.target} direction="row" sx={{ py: 1 }} alignItems="center">
<Typography sx={{ flexGrow: 1 }}>{entry.target}</Typography>
<Chip size="small" label={`× ${entry.count}`} />
</Stack>
))}
</Stack>
)}
</CardContent>
</Card>
</Stack>
</Grid>
<Grid item xs={12} md={4}>
<SectionTitle>Blocked applications</SectionTitle>
<TextField
multiline
fullWidth
minRows={9}
value={apps}
placeholder={"steam*\n*discord*\ntelegram-desktop"}
onChange={(event) => {
setApps(event.target.value);
setDirty(true);
}}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
Matched against the process name and its command line.
</Typography>
</Grid>
</Grid>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }} sx={{ mt: 3 }}>
<TextField
label="Subdomains that *.site expands to"
fullWidth
sx={{ maxWidth: 520 }}
value={subdomains}
onChange={(event) => {
setSubdomains(event.target.value);
setDirty(true);
}}
helperText="Comma separated. Add the subdomains you actually visit."
/>
<Box sx={{ flexGrow: 1 }} />
<Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}>
Save lists
</Button>
<Button disabled={!dirty} onClick={() => setDirty(false)}>
Discard
</Button>
</Stack>
</Box>
<Box sx={{ mt: 5 }}>
<SectionTitle>Today's temptations</SectionTitle>
{top.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Nothing yet — either the blocker is off, or you are behaving.
</Typography>
) : (
<Stack divider={<Divider />}>
{top.map((entry) => (
<Stack key={entry.target} direction="row" alignItems="center" sx={{ py: 1.25 }}>
<Typography variant="body2" sx={{ flexGrow: 1, fontFamily: "monospace" }}>
{entry.target}
</Typography>
<Chip size="small" variant="outlined" label={entry.count} />
</Stack>
))}
</Stack>
)}
</Box>
</>
);
}
+56 -76
View File
@@ -3,26 +3,13 @@
* so the chat and the browser can never disagree.
*/
import { useMemo, useState } from "react";
import {
Box,
Button,
Card,
CardContent,
Chip,
Divider,
Grid,
Stack,
Tab,
Tabs,
Typography,
} from "@mui/material";
import SendIcon from "@mui/icons-material/Send";
import { Box, Button, Chip, Divider, Grid, Stack, Tab, Tabs, Typography } from "@mui/material";
import FocusBars from "../components/charts/FocusBars";
import Heatmap from "../components/charts/Heatmap";
import HourHistogram from "../components/charts/HourHistogram";
import SessionTimeline from "../components/charts/SessionTimeline";
import StatTile from "../components/StatTile";
import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
import { api } from "../lib/api";
import { humanMinutes } from "../lib/format";
import { useResource } from "../lib/hooks";
@@ -62,48 +49,42 @@ export default function ReportPage({
};
return (
<Stack spacing={3}>
<Stack direction="row" alignItems="center" spacing={2} sx={{ flexWrap: "wrap", rowGap: 1 }}>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{report ? `Report for ${report.date}` : "Report"}
</Typography>
{daily.data?.sent_at && (
<Chip size="small" variant="outlined" label={`sent ${new Date(daily.data.sent_at).toLocaleTimeString()}`} />
)}
<Button variant="contained" startIcon={<SendIcon />} disabled={sending} onClick={() => void send()}>
Send to Telegram
</Button>
</Stack>
<>
<PageHeader
eyebrow={daily.data?.sent_at ? `sent ${new Date(daily.data.sent_at).toLocaleTimeString()}` : "not sent yet"}
title={report ? `Report · ${report.date}` : "Report"}
caption={report?.verdict}
actions={
<Button variant="contained" disabled={sending} onClick={() => void send()}>
{sending ? "Sending…" : "Send to Telegram"}
</Button>
}
/>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<StatTile
label="Pomodoros"
value={metrics ? `${metrics.pomodoros}/${metrics.goal}` : "—"}
caption={metrics?.goal_met ? "goal met 🎯" : "goal not met yet"}
accent={palette.work}
/>
</Grid>
<Grid item xs={6} md={3}>
<StatTile
label="Focus"
value={humanMinutes(metrics?.focus_minutes ?? 0)}
caption={`${humanMinutes(metrics?.break_minutes ?? 0)} resting`}
/>
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Score" value={report ? Math.round(report.score) : "—"} caption="out of 100" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile
label="Streak"
value={report ? `${report.streak.current}d` : "—"}
caption={report ? `best ${report.streak.best}` : ""}
/>
</Grid>
</Grid>
<StatRow>
<Stat
label="Pomodoros"
value={metrics ? `${metrics.pomodoros}/${metrics.goal}` : "—"}
caption={metrics?.goal_met ? "goal met" : "goal not met yet"}
accent={palette.work}
size="lg"
/>
<Stat
label="Focus"
value={humanMinutes(metrics?.focus_minutes ?? 0)}
caption={`${humanMinutes(metrics?.break_minutes ?? 0)} resting`}
size="lg"
/>
<Stat label="Score" value={report ? Math.round(report.score) : "—"} caption="out of 100" size="lg" />
<Stat
label="Streak"
value={report ? `${report.streak.current}d` : "—"}
caption={report ? `best ${report.streak.best}` : ""}
size="lg"
/>
</StatRow>
<Grid container spacing={3}>
<Grid container spacing={3} sx={{ mt: 0 }}>
<Grid item xs={12} md={8}>
<FocusBars days={stats.data?.range ?? []} goal={metrics?.goal ?? 8} palette={palette} />
</Grid>
@@ -112,12 +93,16 @@ export default function ReportPage({
</Grid>
</Grid>
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
<Box sx={{ mt: 3 }}>
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
</Box>
<Heatmap days={stats.data?.heatmap ?? []} goal={metrics?.goal ?? 8} palette={palette} />
<Box sx={{ mt: 3 }}>
<Heatmap days={stats.data?.heatmap ?? []} goal={metrics?.goal ?? 8} palette={palette} />
</Box>
<Card>
<CardContent>
<Box sx={{ mt: 4 }}>
<Panel>
<Tabs value={tab} onChange={(_event, value) => setTab(value)} sx={{ mb: 2 }}>
<Tab label={`Achievements (${report?.achievements.length ?? 0})`} />
<Tab label="The funny part" />
@@ -200,23 +185,18 @@ export default function ReportPage({
{(daily.data?.telegram_preview ?? "").replace(/<[^>]+>/g, "")}
</Box>
)}
</CardContent>
</Card>
</Panel>
</Box>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<StatTile label="All-time pomodoros" value={stats.data?.lifetime.pomodoros ?? 0} />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Hours focused" value={stats.data?.lifetime.focus_hours ?? 0} />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Tasks closed" value={stats.data?.lifetime.tasks_completed ?? 0} />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Blocked attempts" value={stats.data?.lifetime.blocked_attempts ?? 0} />
</Grid>
</Grid>
</Stack>
<Box sx={{ mt: 4 }}>
<SectionTitle>All time</SectionTitle>
<StatRow>
<Stat label="Pomodoros" value={stats.data?.lifetime.pomodoros ?? 0} />
<Stat label="Hours focused" value={stats.data?.lifetime.focus_hours ?? 0} />
<Stat label="Tasks closed" value={stats.data?.lifetime.tasks_completed ?? 0} />
<Stat label="Blocked" value={stats.data?.lifetime.blocked_attempts ?? 0} />
</StatRow>
</Box>
</>
);
}
+24 -45
View File
@@ -6,9 +6,6 @@
*/
import { useEffect, useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
@@ -22,8 +19,8 @@ import {
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
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";
@@ -59,7 +56,13 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
};
return (
<Stack spacing={2}>
<>
<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}>
{(
@@ -85,6 +88,7 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
<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"],
@@ -159,17 +163,6 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
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
@@ -443,13 +436,6 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
<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"
@@ -462,12 +448,13 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
}}
/>
</Stack>
<Alert severity="info" sx={{ mt: 2 }}>
<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>, then its lists live on the <strong>Blocker</strong> page.
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
</Alert>
</Section>
</Stack>
</Stack>
</>
);
}
@@ -481,23 +468,15 @@ function Section({
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>
<Box>
<SectionTitle>{title}</SectionTitle>
{subtitle && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{subtitle}
</Typography>
)}
<Stack spacing={2}>{children}</Stack>
</Box>
);
}
@@ -518,7 +497,7 @@ function SourceBlock({
children: React.ReactNode;
}) {
return (
<Box sx={{ border: 1, borderColor: "divider", borderRadius: 3, p: 2 }}>
<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" />}
@@ -532,7 +511,7 @@ function SourceBlock({
{children}
</Stack>
)}
</Box>
</Panel>
);
}
+168 -153
View File
@@ -1,9 +1,9 @@
/**
* Everything you owe, from every source, in one list.
* Everything you owe, from every source, grouped by deadline.
*
* Grouped by deadline rather than by source, because "what is late" is the
* question you actually have; the source is a chip on the row. Ticking a task
* closes it in its own system too, and says so when that write-back fails.
* There is no checkbox. A task is closed in Jira, Todoist or Google Tasks —
* wherever it lives — and the next refresh notices and credits it to the day.
* Two places to tick something off is two places to be wrong.
*/
import { useMemo, useState } from "react";
import {
@@ -11,9 +11,6 @@ import {
AlertTitle,
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
Collapse,
Dialog,
@@ -31,30 +28,36 @@ import {
Tooltip,
Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import RefreshIcon from "@mui/icons-material/Refresh";
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
import StarIcon from "@mui/icons-material/Star";
import StarBorderIcon from "@mui/icons-material/StarBorder";
import { useNavigate } from "react-router-dom";
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
import { api } from "../lib/api";
import { BUCKET_LABEL } from "../lib/format";
import { useResource } from "../lib/hooks";
import type { RefreshResult, Task, TaskListResponse } from "../lib/types";
const BUCKETS: Task["bucket"][] = ["overdue", "today", "upcoming", "someday"];
const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"];
const BUCKET_COLOR: Record<string, string> = {
overdue: "error.main",
today: "warning.main",
active: "success.main",
upcoming: "text.disabled",
someday: "divider",
};
export default function TasksPage({ onToast }: { onToast: (message: string) => void }) {
const navigate = useNavigate();
const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
const [refreshing, setRefreshing] = useState(false);
const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null);
const [filter, setFilter] = useState<string>("all");
const [filter, setFilter] = useState("all");
const [addOpen, setAddOpen] = useState(false);
const [draft, setDraft] = useState({ title: "", due_date: "", estimated_pomodoros: "" });
const [draft, setDraft] = useState({ title: "", due_date: "" });
const refresh = async () => {
setRefreshing(true);
@@ -63,10 +66,11 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
setSourceStatus(result.sources);
await tasks.reload();
const failed = result.sources.filter((item) => !item.ok);
const closed = result.sources.reduce((sum, item) => sum + ((item as { closed?: number }).closed ?? 0), 0);
onToast(
failed.length
? `${failed.length} source(s) failed — see the banner`
: `Pulled ${result.total} tasks from ${result.sources.length} source(s)`,
? `${failed.length} source(s) failed — see above`
: `${result.total} open · ${closed} closed since last refresh`,
);
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
@@ -75,43 +79,54 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
}
};
const toggleDone = async (task: Task, done: boolean) => {
try {
const result = await api.post<{ warning: string | null }>(`/api/tasks/${task.id}/done`, { done });
await tasks.reload();
onToast(result.warning ?? (done ? `Closed "${task.title}"` : "Reopened"));
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
}
};
const togglePin = async (task: Task) => {
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
await tasks.reload();
};
const startOn = async (task: Task) => {
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
onToast(`Focusing on "${task.title}"`);
navigate("/");
};
const visible = useMemo(() => {
const all = tasks.data?.tasks ?? [];
return filter === "all" ? all : all.filter((task) => task.source === filter);
}, [tasks.data, filter]);
const grouped = useMemo(() => {
const map: Record<string, Task[]> = { overdue: [], today: [], upcoming: [], someday: [] };
for (const task of visible) map[task.bucket].push(task);
const map: Record<string, Task[]> = { overdue: [], today: [], active: [], upcoming: [], someday: [] };
for (const task of visible) {
const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday";
map[bucket].push(task);
}
return map;
}, [visible]);
const failed = sourceStatus?.filter((item) => !item.ok) ?? [];
return (
<Stack spacing={3}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
<>
<PageHeader
eyebrow="From Jira, Todoist, Google Tasks and your own list"
title="Tasks"
caption="Close them where they live — the next refresh credits them to today."
actions={
<>
<Button variant="outlined" onClick={() => setAddOpen(true)}>
Add
</Button>
<Button variant="contained" disabled={refreshing} onClick={() => void refresh()}>
{refreshing ? "Refreshing…" : "Refresh sources"}
</Button>
</>
}
/>
{refreshing && <LinearProgress sx={{ mb: 2 }} />}
<Collapse in={failed.length > 0}>
<Alert severity="warning" variant="outlined" sx={{ mb: 3 }} onClose={() => setSourceStatus(null)}>
<AlertTitle sx={{ fontWeight: 600 }}>Some sources could not be read</AlertTitle>
{failed.map((item) => (
<Typography key={item.source} variant="body2">
<strong>{item.label}:</strong> {item.error}
</Typography>
))}
</Alert>
</Collapse>
<Stack direction="row" spacing={1} sx={{ mb: 3, flexWrap: "wrap", rowGap: 1 }}>
<ToggleButtonGroup
size="small"
exclusive
@@ -126,78 +141,60 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
))}
</ToggleButtonGroup>
<Box sx={{ flexGrow: 1 }} />
<Button startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>
Add task
</Button>
<Button variant="contained" startIcon={<RefreshIcon />} disabled={refreshing} onClick={() => void refresh()}>
Refresh sources
</Button>
{sourceStatus && failed.length === 0 && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
{sourceStatus.map((item) => (
<Chip key={item.source} size="small" variant="outlined" label={`${item.label} ${item.count}`} />
))}
</Stack>
)}
</Stack>
{refreshing && <LinearProgress />}
<Collapse in={failed.length > 0}>
<Alert severity="warning" onClose={() => setSourceStatus(null)}>
<AlertTitle>Some sources could not be read</AlertTitle>
{failed.map((item) => (
<Typography key={item.source} variant="body2">
<strong>{item.label}:</strong> {item.error}
</Typography>
))}
</Alert>
</Collapse>
{sourceStatus && failed.length === 0 && (
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}>
{sourceStatus.map((item) => (
<Chip key={item.source} size="small" color="success" variant="outlined" label={`${item.label}: ${item.count}`} />
))}
</Stack>
)}
{tasks.data && visible.length === 0 && (
<Card>
<CardContent>
<Typography variant="h6">Nothing on the list</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Connect Jira, Todoist or Google Tasks in Settings → Sources, or add something yourself.
</Typography>
</CardContent>
</Card>
<Panel>
<Typography variant="h6" sx={{ mb: 1 }}>
Nothing on the list
</Typography>
<Typography variant="body2" color="text.secondary">
Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself.
</Typography>
</Panel>
)}
{BUCKETS.map((bucket) =>
grouped[bucket].length ? (
<Card key={bucket}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{BUCKET_LABEL[bucket]}
<Box key={bucket} sx={{ mb: 4 }}>
<SectionTitle
action={
<Typography variant="caption" color="text.secondary">
{grouped[bucket].length}
</Typography>
<Chip
size="small"
label={grouped[bucket].length}
color={bucket === "overdue" ? "error" : bucket === "today" ? "warning" : "default"}
}
>
{BUCKET_LABEL[bucket]}
</SectionTitle>
<Stack divider={<Divider />}>
{grouped[bucket].map((task) => (
<TaskRow
key={task.id}
task={task}
onPin={async () => {
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
await tasks.reload();
}}
onStart={async () => {
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
onToast(`Focusing on “${task.title}”`);
navigate("/");
}}
onDelete={async () => {
await api.del(`/api/tasks/${task.id}`);
await tasks.reload();
}}
/>
</Stack>
<Divider />
<Stack divider={<Divider flexItem />}>
{grouped[bucket].map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={toggleDone}
onPin={togglePin}
onStart={startOn}
onDelete={async () => {
await api.del(`/api/tasks/${task.id}`);
await tasks.reload();
}}
/>
))}
</Stack>
</CardContent>
</Card>
))}
</Stack>
</Box>
) : null,
)}
@@ -207,7 +204,7 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
autoFocus
label="Title"
label="What needs doing?"
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
@@ -218,12 +215,9 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
value={draft.due_date}
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
/>
<TextField
label="Estimated pomodoros"
type="number"
value={draft.estimated_pomodoros}
onChange={(event) => setDraft({ ...draft, estimated_pomodoros: event.target.value })}
/>
<Typography variant="caption" color="text.secondary">
Local tasks live only here, so they are the one kind you can delete.
</Typography>
</Stack>
</DialogContent>
<DialogActions>
@@ -235,9 +229,8 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
await api.post("/api/tasks", {
title: draft.title.trim(),
due_date: draft.due_date || null,
estimated_pomodoros: draft.estimated_pomodoros ? Number(draft.estimated_pomodoros) : null,
});
setDraft({ title: "", due_date: "", estimated_pomodoros: "" });
setDraft({ title: "", due_date: "" });
setAddOpen(false);
await tasks.reload();
}}
@@ -246,71 +239,93 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
</Button>
</DialogActions>
</Dialog>
</Stack>
</>
);
}
function TaskRow({
task,
onToggle,
onPin,
onStart,
onDelete,
}: {
task: Task;
onToggle: (task: Task, done: boolean) => Promise<void>;
onPin: (task: Task) => Promise<void>;
onStart: (task: Task) => Promise<void>;
onPin: () => Promise<void>;
onStart: () => Promise<void>;
onDelete: () => Promise<void>;
}) {
return (
<Stack direction="row" spacing={1} alignItems="center" sx={{ py: 1 }}>
<Checkbox checked={task.is_done} onChange={(event) => void onToggle(task, event.target.checked)} />
<Stack
direction="row"
spacing={2}
alignItems="center"
sx={{ py: 1.5, "&:hover .row-actions": { opacity: 1 } }}
>
<Box sx={{ width: 2, alignSelf: "stretch", bgcolor: BUCKET_COLOR[task.bucket] ?? BUCKET_COLOR.someday, borderRadius: 1 }} />
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 0.5 }}>
<Typography
variant="body1"
sx={{ textDecoration: task.is_done ? "line-through" : "none", opacity: task.is_done ? 0.6 : 1 }}
>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body1" noWrap sx={{ fontWeight: task.pinned ? 600 : 400 }}>
{task.title}
</Typography>
{task.url && (
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex" }}>
<OpenInNewIcon fontSize="inherit" />
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex", color: "text.disabled" }}>
<OpenInNewIcon sx={{ fontSize: 14 }} />
</Link>
)}
</Stack>
<Stack direction="row" spacing={1} sx={{ mt: 0.5, flexWrap: "wrap", rowGap: 0.5 }}>
<Chip size="small" variant="outlined" label={task.source_label} />
{task.project && <Chip size="small" variant="outlined" label={task.project} />}
{task.priority && <Chip size="small" variant="outlined" label={task.priority} />}
{task.status && <Chip size="small" variant="outlined" label={task.status} />}
{task.days_overdue > 0 && (
<Chip size="small" color="error" label={`${task.days_overdue}d overdue`} />
<Stack direction="row" spacing={1.5} sx={{ mt: 0.25, flexWrap: "wrap", rowGap: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{task.source_label}
</Typography>
{task.project && (
<Typography variant="caption" color="text.secondary">
{task.project}
</Typography>
)}
{task.due_date && task.days_overdue === 0 && (
<Chip size="small" variant="outlined" label={task.due_date} />
{task.status && (
<Typography variant="caption" color="text.secondary">
{task.status}
</Typography>
)}
{task.priority && (
<Typography variant="caption" color="text.secondary">
{task.priority}
</Typography>
)}
{task.days_overdue > 0 ? (
<Typography variant="caption" color="error.main" sx={{ fontWeight: 600 }}>
{task.days_overdue}d overdue
</Typography>
) : (
task.due_date && (
<Typography variant="caption" color="text.secondary">
due {task.due_date}
</Typography>
)
)}
{task.stale && <Chip size="small" color="warning" variant="outlined" label="gone from source" />}
</Stack>
</Box>
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
<IconButton size="small" onClick={() => void onPin(task)}>
{task.pinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Start a pomodoro on this">
<IconButton size="small" color="primary" onClick={() => void onStart(task)}>
<PlayArrowIcon fontSize="small" />
</IconButton>
</Tooltip>
{task.source === "local" && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => void onDelete()}>
<DeleteOutlineIcon fontSize="small" />
<Stack direction="row" spacing={0.5} className="row-actions" sx={{ opacity: { xs: 1, md: 0.25 }, transition: "opacity .15s" }}>
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
<IconButton size="small" onClick={() => void onPin()}>
{task.pinned ? <StarIcon sx={{ fontSize: 17 }} /> : <StarBorderIcon sx={{ fontSize: 17 }} />}
</IconButton>
</Tooltip>
)}
<Tooltip title="Start a pomodoro on this">
<IconButton size="small" onClick={() => void onStart()}>
<PlayArrowOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{task.source === "local" && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => void onDelete()}>
<DeleteOutlineIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
)}
</Stack>
</Stack>
);
}
+236 -251
View File
@@ -2,9 +2,9 @@
* The timer.
*
* The server owns the clock, so this page polls `/api/timer/state` and ticks
* locally in between: the countdown is smooth, and a reload (or the desktop app
* taking over) picks up exactly where the browser left off. When the local
* countdown reaches zero it tells the server to complete the interval — the
* locally in between: the countdown is smooth, and a reload — or the desktop
* app taking over — picks up exactly where the browser left off. When the local
* countdown reaches zero it tells the server to complete the interval; the
* server decides what comes next.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -12,9 +12,6 @@ import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogActions,
DialogContent,
@@ -22,23 +19,18 @@ import {
Divider,
Grid,
IconButton,
LinearProgress,
MenuItem,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import AddAlarmIcon from "@mui/icons-material/AddAlarm";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CoffeeIcon from "@mui/icons-material/Coffee";
import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
import PauseIcon from "@mui/icons-material/Pause";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import StopIcon from "@mui/icons-material/Stop";
import AddAlarmOutlinedIcon from "@mui/icons-material/AddAlarmOutlined";
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
import StopOutlinedIcon from "@mui/icons-material/StopOutlined";
import SessionTimeline from "../components/charts/SessionTimeline";
import StatTile from "../components/StatTile";
import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
import TimerDial from "../components/TimerDial";
import { api } from "../lib/api";
import { KIND_LABEL, clock, humanMinutes } from "../lib/format";
@@ -55,12 +47,12 @@ export default function TimerPage({
onToast: (message: string) => void;
dark: boolean;
}) {
const { settings } = useSettings();
const { settings, save } = useSettings();
const palette = useMemo(() => vizPalette(dark), [dark]);
const timer = useResource<TimerState>("/api/timer/state", 10000);
const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
const today = useResource<{ sessions: unknown[]; metrics: DayMetrics }>("/api/timer/sessions", 30000);
const today = useResource<{ metrics: DayMetrics }>("/api/timer/sessions", 30000);
const [abandonOpen, setAbandonOpen] = useState(false);
const [abandonReason, setAbandonReason] = useState("");
@@ -70,27 +62,23 @@ export default function TimerPage({
const state = timer.data;
const current = state?.current ?? null;
const running = current?.status === "running";
const timerEnabled = settings?.pomodoro.timer_enabled ?? true;
// Local countdown between polls.
const tick = useTick(Boolean(current));
const anchor = useRef<{ at: number; remaining: number; id: number } | null>(null);
useEffect(() => {
if (current) anchor.current = { at: Date.now(), remaining: current.remaining_seconds, id: current.id };
else anchor.current = null;
anchor.current = current
? { at: Date.now(), remaining: current.remaining_seconds, id: current.id }
: null;
}, [current?.id, current?.remaining_seconds, current?.status]);
const remaining = useMemo(() => {
if (!current) return 0;
if (!anchor.current || anchor.current.id !== current.id) return current.remaining_seconds;
if (current.status === "paused") return anchor.current.remaining;
const drift = Math.floor((Date.now() - anchor.current.at) / 1000);
return Math.max(0, anchor.current.remaining - drift);
return Math.max(0, anchor.current.remaining - Math.floor((Date.now() - anchor.current.at) / 1000));
}, [current, tick]);
const refreshAll = useCallback(async () => {
await Promise.all([timer.reload(), today.reload()]);
}, [timer, today]);
const call = useCallback(
async (path: string, body?: unknown, successToast?: string) => {
setBusy(true);
@@ -110,7 +98,6 @@ export default function TimerPage({
[onToast, timer, today],
);
// When the countdown hits zero, hand it to the server and announce it.
const firing = useRef(false);
useEffect(() => {
if (!current || remaining > 0 || firing.current) return;
@@ -123,12 +110,9 @@ export default function TimerPage({
wasWork ? sound.workDone(volume) : sound.breakDone(volume);
}
if (settings?.pomodoro.desktop_notifications ?? true) {
notify(
wasWork ? "Pomodoro complete 🍅" : "Break over ☕",
wasWork ? "Time for a break." : "Back to it.",
);
notify(wasWork ? "Pomodoro complete" : "Break over", wasWork ? "Time for a break." : "Back to it.");
}
if (next?.celebration) onToast("Milestone reached — check Telegram 🎉");
if (next?.celebration) onToast("Milestone reached — Telegram got a cheer");
firing.current = false;
})();
}, [remaining, current, call, settings, onToast]);
@@ -138,7 +122,7 @@ export default function TimerPage({
await call(
"/api/timer/start",
{ kind, task_id: kind === "work" && selectedTask !== "" ? selectedTask : undefined },
kind === "work" ? "Focus interval started" : "Break started",
kind === "work" ? "Focus started" : "Break started",
);
};
@@ -150,243 +134,245 @@ export default function TimerPage({
const kind = current?.kind ?? state?.next_kind ?? "work";
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
const accent = kindColor(kind, palette);
return (
<Stack spacing={3}>
<Grid container spacing={3}>
<Grid item xs={12} md={7}>
<Card>
<CardContent sx={{ textAlign: "center", pb: 3 }}>
<TimerDial
remaining={current ? remaining : total}
total={total}
label={current ? KIND_LABEL[current.kind] : `Next: ${KIND_LABEL[kind]}`}
sublabel={current?.task?.title ?? (current ? undefined : "Press start when you are ready")}
color={kindColor(kind, palette)}
paused={current?.status === "paused"}
/>
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3, flexWrap: "wrap", rowGap: 1 }}>
{!current && (
<>
<Button
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
disabled={busy}
onClick={() => void start("work")}
>
Start focus
</Button>
<>
<PageHeader
eyebrow={new Date().toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" })}
title={!timerEnabled ? "View mode" : (current ? KIND_LABEL[current.kind] : "Ready when you are")}
caption={
!timerEnabled
? "Timer is turned off · spectator mode"
: (current?.task
? `Tracking “${current.task.title}”`
: `Cycle ${state?.cycle_position ?? 1} of ${state?.cycles_before_long_break ?? 4} before a long break`)
}
actions={
<Stack direction="row" spacing={1} alignItems="center">
<Button
variant="outlined"
size="small"
onClick={async () => {
try {
await save({ pomodoro: { timer_enabled: !timerEnabled } });
onToast(!timerEnabled ? "Timer enabled" : "Switched to view-only mode");
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
}
}}
>
{timerEnabled ? "Turn off timer" : "Turn on timer"}
</Button>
{timerEnabled && (
current ? (
<>
{running ? (
<Button
variant="outlined"
size="large"
startIcon={<CoffeeIcon />}
disabled={busy}
onClick={() => void start(state?.next_kind === "long_break" ? "long_break" : "short_break")}
disabled={busy || !(settings?.pomodoro.allow_pause ?? true)}
onClick={() => void call("/api/timer/pause")}
>
{state?.next_kind === "long_break" ? "Long break" : "Short break"}
Pause
</Button>
</>
)}
{current && (
<>
{running ? (
<Button
variant="outlined"
size="large"
startIcon={<PauseIcon />}
disabled={busy || !(settings?.pomodoro.allow_pause ?? true)}
onClick={() => void call("/api/timer/pause")}
>
Pause
</Button>
) : (
<Button
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
disabled={busy}
onClick={() => void call("/api/timer/resume")}
>
Resume
</Button>
)}
<Button
variant="contained"
color="success"
size="large"
startIcon={<CheckCircleIcon />}
disabled={busy}
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval completed")}
>
Done
) : (
<Button variant="outlined" disabled={busy} onClick={() => void call("/api/timer/resume")}>
Resume
</Button>
<Tooltip title="Add five minutes">
<IconButton
disabled={busy}
onClick={() => void call("/api/timer/extend", { minutes: 5 })}
>
<AddAlarmIcon />
</IconButton>
</Tooltip>
<Tooltip title="Count an interruption without stopping">
<IconButton disabled={busy} onClick={() => void call("/api/timer/interrupt")}>
<NotificationsActiveIcon />
</IconButton>
</Tooltip>
<Tooltip title="Abandon this interval">
<IconButton color="error" disabled={busy} onClick={() => setAbandonOpen(true)}>
<StopIcon />
</IconButton>
</Tooltip>
</>
)}
</Stack>
)}
<Button
variant="contained"
disabled={busy}
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval finished")}
>
Finish
</Button>
</>
) : (
<>
<Button variant="contained" disabled={busy} onClick={() => void start("work")}>
Start focus
</Button>
<Button
variant="outlined"
disabled={busy}
onClick={() => void start(state?.next_kind === "long_break" ? "long_break" : "short_break")}
>
{state?.next_kind === "long_break" ? "Long break" : "Short break"}
</Button>
</>
)
)}
</Stack>
}
/>
{current && current.interruptions > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
{current.interruptions} interruption{current.interruptions === 1 ? "" : "s"} this interval
</Typography>
)}
</CardContent>
</Card>
<Grid container spacing={4}>
<Grid item xs={12} md={7}>
<Panel sx={{ py: { xs: 4, md: 5 } }}>
<TimerDial
remaining={timerEnabled ? (current ? remaining : total) : 0}
total={total}
label={!timerEnabled ? "View mode" : (current ? KIND_LABEL[current.kind] : `Next · ${KIND_LABEL[kind]}`)}
sublabel={!timerEnabled ? "Timer disabled" : (current?.task?.title ?? (current ? undefined : "Pick something below, or just begin"))}
color={timerEnabled ? accent : palette.axis}
paused={current?.status === "paused"}
idle={!timerEnabled || !current}
/>
{current && timerEnabled && (
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
<Tooltip title="Add five minutes">
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/extend", { minutes: 5 })}>
<AddAlarmOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Count an interruption without stopping the clock">
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/interrupt")}>
<NotificationsNoneOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Abandon this interval">
<IconButton size="small" disabled={busy} onClick={() => setAbandonOpen(true)}>
<StopOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
)}
{current && current.interruptions > 0 && (
<Typography variant="caption" color="text.secondary" align="center" sx={{ display: "block", mt: 1.5 }}>
{current.interruptions} interruption{current.interruptions === 1 ? "" : "s"} so far
</Typography>
)}
</Panel>
</Grid>
<Grid item xs={12} md={5}>
<Stack spacing={3} sx={{ height: "100%" }}>
<Card>
<CardContent>
<Typography variant="overline" color="text.secondary">
Today
</Typography>
<Stack direction="row" alignItems="baseline" spacing={1}>
<Typography variant="h4" sx={{ fontVariantNumeric: "tabular-nums" }}>
{doneToday}
</Typography>
<Typography variant="h6" color="text.secondary">
/ {goal} pomodoros
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={Math.min(100, goal ? (doneToday / goal) * 100 : 0)}
sx={{ height: 8, borderRadius: 4, my: 1.5 }}
/>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap", rowGap: 0.75 }}>
{Array.from({ length: Math.max(goal, doneToday) }, (_unused, index) => (
<Box
key={index}
sx={{
width: 14,
height: 14,
borderRadius: "50%",
bgcolor: index < doneToday ? palette.work : "action.hover",
outline: index < doneToday ? "none" : `1px solid ${palette.grid}`,
outlineOffset: "-1px",
}}
/>
))}
</Stack>
{metrics && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5 }}>
{humanMinutes(metrics.focus_minutes)} focused · {humanMinutes(metrics.break_minutes)} rested
{metrics.longest_stretch_minutes > 0 &&
` · longest stretch ${humanMinutes(metrics.longest_stretch_minutes)}`}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
Cycle {state?.cycle_position ?? 1} of {state?.cycles_before_long_break ?? 4} before a long break
</Typography>
</CardContent>
</Card>
<Card sx={{ flexGrow: 1 }}>
<CardContent>
<Typography variant="overline" color="text.secondary">
What are you working on?
</Typography>
<TextField
select
fullWidth
sx={{ mt: 1 }}
value={selectedTask}
onChange={(event) => {
const value = event.target.value === "" ? "" : Number(event.target.value);
setSelectedTask(value);
if (current) void call("/api/timer/task", { task_id: value === "" ? null : value });
<Stack spacing={3}>
<Box>
<SectionTitle>Today</SectionTitle>
<Stack direction="row" alignItems="baseline" spacing={1}>
<Typography
sx={{
fontFamily: (theme) => theme.typography.h1.fontFamily,
fontSize: 44,
lineHeight: 1,
letterSpacing: "-0.03em",
}}
helperText={
current?.task
? `This interval is tracked against "${current.task.title}"`
: "Pick a task so the report knows where the time went"
>
{doneToday}
</Typography>
<Typography variant="body1" color="text.secondary">
of {goal} pomodoros
</Typography>
</Stack>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap", rowGap: 0.75, mt: 2 }}>
{Array.from({ length: Math.max(goal, doneToday) }, (_unused, index) => (
<Box
key={index}
sx={{
width: 11,
height: 11,
borderRadius: "50%",
bgcolor: index < doneToday ? palette.work : "transparent",
border: index < doneToday ? "none" : "1px solid",
borderColor: "divider",
}}
/>
))}
</Stack>
{metrics && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{humanMinutes(metrics.focus_minutes)} focused · {humanMinutes(metrics.break_minutes)} rested
{metrics.longest_stretch_minutes > 0 &&
` · longest run ${humanMinutes(metrics.longest_stretch_minutes)}`}
</Typography>
)}
</Box>
<Box>
<SectionTitle>Working on</SectionTitle>
<TextField
select
fullWidth
SelectProps={{ displayEmpty: true }}
value={selectedTask}
onChange={(event) => {
const value = event.target.value === "" ? "" : Number(event.target.value);
setSelectedTask(value);
if (current) void call("/api/timer/task", { task_id: value === "" ? null : value });
}}
>
<MenuItem value="">
<em>Nothing in particular</em>
</MenuItem>
{openTasks.slice(0, 60).map((task) => (
<MenuItem key={task.id} value={task.id}>
{task.pinned ? "★ " : ""}
{task.title}
{task.due_date ? ` · ${task.due_date}` : ""}
</MenuItem>
))}
</TextField>
{suggestion && selectedTask === "" && (
<Alert
severity={suggestion.days_overdue > 0 ? "warning" : "info"}
variant="outlined"
sx={{ mt: 1.5 }}
action={
<Button size="small" onClick={() => setSelectedTask(suggestion.id)}>
Use
</Button>
}
>
<MenuItem value="">
<em>No task</em>
</MenuItem>
{openTasks.slice(0, 50).map((task) => (
<MenuItem key={task.id} value={task.id}>
{task.pinned ? "📌 " : ""}
{task.title}
{task.due_date ? ` · ${task.due_date}` : ""}
</MenuItem>
))}
</TextField>
{suggestion.days_overdue > 0 ? "Most overdue: " : "Next up: "}
<strong>{suggestion.title}</strong>
</Alert>
)}
{suggestion && selectedTask === "" && (
<Alert
severity={suggestion.days_overdue > 0 ? "warning" : "info"}
sx={{ mt: 2 }}
action={
<Button size="small" onClick={() => setSelectedTask(suggestion.id)}>
Use
</Button>
}
>
Suggested: <strong>{suggestion.title}</strong>
{suggestion.days_overdue > 0 ? ` — ${suggestion.days_overdue} days overdue` : ""}
</Alert>
)}
<Divider sx={{ my: 2 }} />
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}>
<Chip size="small" label={`${tasks.data?.counts.overdue ?? 0} overdue`} color="error" variant="outlined" />
<Chip size="small" label={`${tasks.data?.counts.today ?? 0} due today`} color="warning" variant="outlined" />
<Chip size="small" label={`${tasks.data?.counts.upcoming ?? 0} upcoming`} variant="outlined" />
</Stack>
</CardContent>
</Card>
<Stack direction="row" spacing={2} sx={{ mt: 2 }} divider={<Divider flexItem orientation="vertical" />}>
<Typography variant="caption" color="text.secondary">
{tasks.data?.counts.overdue ?? 0} overdue
</Typography>
<Typography variant="caption" color="text.secondary">
{tasks.data?.counts.today ?? 0} due today
</Typography>
<Typography variant="caption" color="text.secondary">
{tasks.data?.counts.upcoming ?? 0} upcoming
</Typography>
</Stack>
</Box>
</Stack>
</Grid>
</Grid>
<Grid container spacing={3}>
<Grid item xs={6} md={3}>
<StatTile label="Focus today" value={humanMinutes(metrics?.focus_minutes ?? 0)} caption={`${metrics?.pomodoros ?? 0} pomodoros`} />
</Grid>
<Grid item xs={6} md={3}>
<StatTile
<Box sx={{ mt: 4 }}>
<StatRow>
<Stat label="Focus today" value={humanMinutes(metrics?.focus_minutes ?? 0)} caption={`${metrics?.pomodoros ?? 0} intervals`} />
<Stat
label="Interruptions"
value={metrics?.interruptions ?? 0}
caption={metrics?.abandoned ? `${metrics.abandoned} abandoned` : "none dropped"}
/>
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Tasks closed" value={metrics?.tasks_completed_count ?? 0} caption="today" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Blocked attempts" value={metrics?.blocked_attempts ?? 0} caption="temptations turned away" />
</Grid>
</Grid>
<Stat label="Closed today" value={metrics?.tasks_completed_count ?? 0} caption="in your own tools" />
<Stat label="Blocked" value={metrics?.blocked_attempts ?? 0} caption="temptations turned away" />
</StatRow>
</Box>
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
<Box sx={{ mt: 4 }}>
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
</Box>
<Dialog open={abandonOpen} onClose={() => setAbandonOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Abandon this interval?</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{clock(remaining)} left. {settings?.pomodoro.strict_mode ? "Strict mode wants a reason." : "A reason is optional."}
{clock(remaining)} left.{" "}
{settings?.pomodoro.strict_mode ? "Strict mode wants a reason." : "A reason is optional."}
</Typography>
<TextField
autoFocus
@@ -405,7 +391,6 @@ export default function TimerPage({
if (result) {
setAbandonOpen(false);
setAbandonReason("");
void refreshAll();
}
}}
>
@@ -413,17 +398,17 @@ export default function TimerPage({
</Button>
</DialogActions>
</Dialog>
</Stack>
</>
);
}
/** Overdue first, then due today, then whatever is pinned — the obvious next thing. */
/** Pinned first, then the most overdue, then what is due today. */
function pickSuggestion(tasks: Task[]): Task | null {
const pinned = tasks.find((task) => task.pinned);
if (pinned) return pinned;
const overdue = tasks.filter((task) => task.bucket === "overdue");
if (overdue.length) return overdue[0];
const dueToday = tasks.filter((task) => task.bucket === "today");
if (dueToday.length) return dueToday[0];
return tasks[0] ?? null;
return (
tasks.find((task) => task.pinned) ??
tasks.find((task) => task.bucket === "overdue") ??
tasks.find((task) => task.bucket === "today") ??
tasks[0] ??
null
);
}