init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* The blocker's rules and its live state.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ShieldIcon from "@mui/icons-material/Shield";
|
||||
|
||||
import StatTile from "../components/StatTile";
|
||||
import { useResource } from "../lib/hooks";
|
||||
import { useSettings } from "../lib/settings";
|
||||
import type { BlockEvents, BlockerPolicy } from "../lib/types";
|
||||
|
||||
export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) {
|
||||
const { settings, defaults, save } = useSettings();
|
||||
const policy = useResource<BlockerPolicy>("/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 [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || dirty) return;
|
||||
setDomains(settings.blocker.blocked_domains.join("\n"));
|
||||
setAllowed(settings.blocker.allowed_domains.join("\n"));
|
||||
setApps(settings.blocker.blocked_apps.join("\n"));
|
||||
}, [settings, dirty]);
|
||||
|
||||
const lines = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const patch = async (change: Record<string, unknown>) => {
|
||||
try {
|
||||
await save({ blocker: change });
|
||||
await policy.reload();
|
||||
onToast("Blocker updated");
|
||||
} 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),
|
||||
});
|
||||
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;
|
||||
|
||||
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"
|
||||
/>
|
||||
</Grid>
|
||||
</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">
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* The day in numbers — the same payload the Telegram message is rendered from,
|
||||
* 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 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 { api } from "../lib/api";
|
||||
import { humanMinutes } from "../lib/format";
|
||||
import { useResource } from "../lib/hooks";
|
||||
import type { DailyReport, StatsSummary } from "../lib/types";
|
||||
import { vizPalette } from "../lib/viz";
|
||||
|
||||
export default function ReportPage({
|
||||
dark,
|
||||
onToast,
|
||||
}: {
|
||||
dark: boolean;
|
||||
onToast: (message: string) => void;
|
||||
}) {
|
||||
const palette = useMemo(() => vizPalette(dark), [dark]);
|
||||
const daily = useResource<{ report: DailyReport; sent_at: string | null; telegram_preview: string }>(
|
||||
"/api/reports/daily",
|
||||
60000,
|
||||
);
|
||||
const stats = useResource<StatsSummary>("/api/stats/summary?days=30", 60000);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const report = daily.data?.report;
|
||||
const metrics = report?.metrics;
|
||||
|
||||
const send = async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
const result = await api.post<{ sent: boolean }>("/api/reports/daily/send", { force: true });
|
||||
onToast(result.sent ? "Report sent" : "Nothing sent — configure Telegram in Settings");
|
||||
await daily.reload();
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<FocusBars days={stats.data?.range ?? []} goal={metrics?.goal ?? 8} palette={palette} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<HourHistogram byHour={metrics?.by_hour ?? []} palette={palette} peak={metrics?.peak_hour ?? null} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
|
||||
|
||||
<Heatmap days={stats.data?.heatmap ?? []} goal={metrics?.goal ?? 8} palette={palette} />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onChange={(_event, value) => setTab(value)} sx={{ mb: 2 }}>
|
||||
<Tab label={`Achievements (${report?.achievements.length ?? 0})`} />
|
||||
<Tab label="The funny part" />
|
||||
<Tab label={`Closed today (${metrics?.tasks_completed_count ?? 0})`} />
|
||||
<Tab label="Telegram preview" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 &&
|
||||
(report?.achievements.length ? (
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}>
|
||||
{report.achievements.map((item) => (
|
||||
<Chip
|
||||
key={item.code}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{item.emoji} {item.title}
|
||||
</Typography>
|
||||
{item.detail && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.detail}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
sx={{ height: "auto", py: 1, px: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nothing unlocked yet today.
|
||||
</Typography>
|
||||
))}
|
||||
|
||||
{tab === 1 && (
|
||||
<Stack spacing={1}>
|
||||
{(report?.fun_facts ?? []).map((fact) => (
|
||||
<Typography key={fact} variant="body1">
|
||||
{fact}
|
||||
</Typography>
|
||||
))}
|
||||
{report?.verdict && (
|
||||
<Typography variant="body1" sx={{ fontStyle: "italic", pt: 1 }}>
|
||||
{report.verdict}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab === 2 && (
|
||||
<Stack divider={<Divider flexItem />}>
|
||||
{(metrics?.tasks_completed ?? []).map((task) => (
|
||||
<Stack key={task.id} direction="row" spacing={1} sx={{ py: 1 }} alignItems="center">
|
||||
<Typography sx={{ flexGrow: 1 }}>{task.title}</Typography>
|
||||
<Chip size="small" variant="outlined" label={task.source} />
|
||||
</Stack>
|
||||
))}
|
||||
{!metrics?.tasks_completed.length && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No tasks closed here today.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab === 3 && (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: "action.hover",
|
||||
fontSize: 13,
|
||||
whiteSpace: "pre-wrap",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{(daily.data?.telegram_preview ?? "").replace(/<[^>]+>/g, "")}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Everything you owe, from every source, in one list.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Link,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
|
||||
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"];
|
||||
|
||||
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 [addOpen, setAddOpen] = useState(false);
|
||||
const [draft, setDraft] = useState({ title: "", due_date: "", estimated_pomodoros: "" });
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const result = await api.post<RefreshResult>("/api/tasks/refresh");
|
||||
setSourceStatus(result.sources);
|
||||
await tasks.reload();
|
||||
const failed = result.sources.filter((item) => !item.ok);
|
||||
onToast(
|
||||
failed.length
|
||||
? `${failed.length} source(s) failed — see the banner`
|
||||
: `Pulled ${result.total} tasks from ${result.sources.length} source(s)`,
|
||||
);
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
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 }}>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={filter}
|
||||
onChange={(_event, value) => value && setFilter(value)}
|
||||
>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
{(tasks.data?.sources ?? []).map((source) => (
|
||||
<ToggleButton key={source} value={source}>
|
||||
{tasks.data?.tasks.find((task) => task.source === source)?.source_label ?? source}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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]}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={grouped[bucket].length}
|
||||
color={bucket === "overdue" ? "error" : bucket === "today" ? "warning" : "default"}
|
||||
/>
|
||||
</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>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>New task</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
label="Title"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Due date"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
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 })}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!draft.title.trim()}
|
||||
onClick={async () => {
|
||||
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: "" });
|
||||
setAddOpen(false);
|
||||
await tasks.reload();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</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>;
|
||||
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)} />
|
||||
<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 }}
|
||||
>
|
||||
{task.title}
|
||||
</Typography>
|
||||
{task.url && (
|
||||
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex" }}>
|
||||
<OpenInNewIcon fontSize="inherit" />
|
||||
</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`} />
|
||||
)}
|
||||
{task.due_date && task.days_overdue === 0 && (
|
||||
<Chip size="small" variant="outlined" label={task.due_date} />
|
||||
)}
|
||||
{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" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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
|
||||
* server decides what comes next.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
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 SessionTimeline from "../components/charts/SessionTimeline";
|
||||
import StatTile from "../components/StatTile";
|
||||
import TimerDial from "../components/TimerDial";
|
||||
import { api } from "../lib/api";
|
||||
import { KIND_LABEL, clock, humanMinutes } from "../lib/format";
|
||||
import { useResource, useTick } from "../lib/hooks";
|
||||
import { useSettings } from "../lib/settings";
|
||||
import { notify, sound } from "../lib/sound";
|
||||
import type { DayMetrics, Task, TaskListResponse, TimerState } from "../lib/types";
|
||||
import { kindColor, vizPalette } from "../lib/viz";
|
||||
|
||||
export default function TimerPage({
|
||||
onToast,
|
||||
dark,
|
||||
}: {
|
||||
onToast: (message: string) => void;
|
||||
dark: boolean;
|
||||
}) {
|
||||
const { settings } = 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 [abandonOpen, setAbandonOpen] = useState(false);
|
||||
const [abandonReason, setAbandonReason] = useState("");
|
||||
const [selectedTask, setSelectedTask] = useState<number | "">("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const state = timer.data;
|
||||
const current = state?.current ?? null;
|
||||
const running = current?.status === "running";
|
||||
|
||||
// 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;
|
||||
}, [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);
|
||||
}, [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);
|
||||
try {
|
||||
const next = await api.post<TimerState>(path, body);
|
||||
timer.set(next);
|
||||
if (successToast) onToast(successToast);
|
||||
void today.reload();
|
||||
return next;
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[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;
|
||||
firing.current = true;
|
||||
const wasWork = current.kind === "work";
|
||||
void (async () => {
|
||||
const next = await call("/api/timer/complete", { auto_next: true });
|
||||
const volume = settings?.pomodoro.alarm_volume ?? 70;
|
||||
if (settings?.pomodoro.alarm_sound ?? true) {
|
||||
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.",
|
||||
);
|
||||
}
|
||||
if (next?.celebration) onToast("Milestone reached — check Telegram 🎉");
|
||||
firing.current = false;
|
||||
})();
|
||||
}, [remaining, current, call, settings, onToast]);
|
||||
|
||||
const start = async (kind: "work" | "short_break" | "long_break") => {
|
||||
sound.unlock();
|
||||
await call(
|
||||
"/api/timer/start",
|
||||
{ kind, task_id: kind === "work" && selectedTask !== "" ? selectedTask : undefined },
|
||||
kind === "work" ? "Focus interval started" : "Break started",
|
||||
);
|
||||
};
|
||||
|
||||
const metrics = today.data?.metrics;
|
||||
const goal = state?.goal ?? settings?.pomodoro.daily_goal_pomodoros ?? 8;
|
||||
const doneToday = state?.completed_today ?? 0;
|
||||
const openTasks = tasks.data?.tasks.filter((task) => !task.is_done) ?? [];
|
||||
const suggestion = useMemo(() => pickSuggestion(openTasks), [openTasks]);
|
||||
|
||||
const kind = current?.kind ?? state?.next_kind ?? "work";
|
||||
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<CoffeeIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => void start(state?.next_kind === "long_break" ? "long_break" : "short_break")}
|
||||
>
|
||||
{state?.next_kind === "long_break" ? "Long break" : "Short break"}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
<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 });
|
||||
}}
|
||||
helperText={
|
||||
current?.task
|
||||
? `This interval is tracked against "${current.task.title}"`
|
||||
: "Pick a task so the report knows where the time went"
|
||||
}
|
||||
>
|
||||
<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 && 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>
|
||||
</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
|
||||
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>
|
||||
|
||||
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
|
||||
|
||||
<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."}
|
||||
</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="What happened?"
|
||||
value={abandonReason}
|
||||
onChange={(event) => setAbandonReason(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAbandonOpen(false)}>Keep going</Button>
|
||||
<Button
|
||||
color="error"
|
||||
onClick={async () => {
|
||||
const result = await call("/api/timer/abandon", { reason: abandonReason || null });
|
||||
if (result) {
|
||||
setAbandonOpen(false);
|
||||
setAbandonReason("");
|
||||
void refreshAll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Abandon
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overdue first, then due today, then whatever is pinned — the obvious next thing. */
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user