This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useMemo, useState } from "react";
import { Alert, Box, CssBaseline, LinearProgress, Snackbar, ThemeProvider, useMediaQuery } from "@mui/material";
import { Navigate, Route, Routes } from "react-router-dom";
import AppShell from "./components/AppShell";
import { SettingsProvider, useSettings } from "./lib/settings";
import { buildTheme, type ThemeMode } from "./theme";
import BlockerPage from "./pages/BlockerPage";
import ReportPage from "./pages/ReportPage";
import SettingsPage from "./pages/SettingsPage";
import TasksPage from "./pages/TasksPage";
import TimerPage from "./pages/TimerPage";
function Shell() {
const { settings, loading, error } = useSettings();
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
const [toast, setToast] = useState<string | null>(null);
const mode: ThemeMode = useMemo(() => {
const preference = settings?.appearance.theme ?? "system";
if (preference === "system") return prefersDark ? "dark" : "light";
return preference;
}, [settings?.appearance.theme, prefersDark]);
const theme = useMemo(
() => buildTheme(mode, settings?.appearance.accent || "#6750A4", settings?.appearance.compact ?? false),
[mode, settings?.appearance.accent, settings?.appearance.compact],
);
// Keep the browser chrome in step with the app's surface.
useEffect(() => {
document.documentElement.dataset.theme = mode;
document.body.style.backgroundColor = theme.palette.background.default;
}, [mode, theme]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{loading && <LinearProgress />}
{error && (
<Box sx={{ p: 2 }}>
<Alert severity="error">
The backend is not reachable ({error}). Start it with{" "}
<code>docker compose up</code> or check the API token in Settings.
</Alert>
</Box>
)}
<AppShell onToast={setToast} dark={mode === "dark"}>
<Routes>
<Route path="/" element={<TimerPage onToast={setToast} dark={mode === "dark"} />} />
<Route path="/tasks" element={<TasksPage onToast={setToast} />} />
<Route path="/blocker" element={<BlockerPage onToast={setToast} />} />
<Route path="/report" element={<ReportPage dark={mode === "dark"} onToast={setToast} />} />
<Route path="/settings" element={<SettingsPage onToast={setToast} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppShell>
<Snackbar
open={toast !== null}
autoHideDuration={5000}
onClose={() => setToast(null)}
message={toast ?? ""}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
</ThemeProvider>
);
}
export default function App() {
return (
<SettingsProvider>
<Shell />
</SettingsProvider>
);
}
+234
View File
@@ -0,0 +1,234 @@
/**
* The frame: a navigation rail on a wide screen, a bottom bar on a phone.
*
* It also carries the two things that have to be visible from every page — what
* the timer is doing, and whether the blocker is on — because both are reasons
* you might switch pages in the first place.
*/
import { useEffect, type ReactNode } from "react";
import {
AppBar,
Avatar,
Badge,
Box,
BottomNavigation,
BottomNavigationAction,
Chip,
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Stack,
Toolbar,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import AssignmentTurnedInIcon from "@mui/icons-material/AssignmentTurnedIn";
import BarChartIcon from "@mui/icons-material/BarChart";
import ShieldIcon from "@mui/icons-material/Shield";
import SettingsIcon from "@mui/icons-material/Settings";
import TimerIcon from "@mui/icons-material/Timer";
import { useLocation, useNavigate } from "react-router-dom";
import { clock } from "../lib/format";
import { useResource } from "../lib/hooks";
import type { BlockerPolicy, TimerState } from "../lib/types";
const NAV = [
{ to: "/", label: "Timer", icon: <TimerIcon /> },
{ to: "/tasks", label: "Tasks", icon: <AssignmentTurnedInIcon /> },
{ to: "/blocker", label: "Blocker", icon: <ShieldIcon /> },
{ to: "/report", label: "Report", icon: <BarChartIcon /> },
{ to: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
const RAIL_WIDTH = 220;
export default function AppShell({
children,
dark,
}: {
children: ReactNode;
onToast: (message: string) => void;
dark: boolean;
}) {
const theme = useTheme();
const wide = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const location = useLocation();
const timer = useResource<TimerState>("/api/timer/state", 5000);
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
const current = timer.data?.current ?? null;
const remaining = current ? current.remaining_seconds : 0;
// The tab title doubles as a timer you can read from another window.
useEffect(() => {
document.title = current
? `${clock(remaining)} · ${current.kind === "work" ? "Focus" : "Break"} — Kadence`
: "Kadence";
}, [current, remaining]);
const activeIndex = Math.max(
0,
NAV.findIndex((item) => item.to === location.pathname),
);
return (
<Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}>
{wide && (
<Drawer
variant="permanent"
sx={{
width: RAIL_WIDTH,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: RAIL_WIDTH,
borderRight: "none",
bgcolor: "background.default",
px: 1.5,
py: 2,
},
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ px: 1.5, pb: 3 }}>
<Avatar sx={{ bgcolor: "primary.main", width: 36, height: 36 }}>🍅</Avatar>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, lineHeight: 1.1 }}>
Kadence
</Typography>
<Typography variant="caption" color="text.secondary">
find your rhythm
</Typography>
</Box>
</Stack>
<List sx={{ flexGrow: 1 }}>
{NAV.map((item) => {
const selected = location.pathname === item.to;
return (
<ListItemButton
key={item.to}
selected={selected}
onClick={() => navigate(item.to)}
sx={{
borderRadius: 999,
mb: 0.5,
"&.Mui-selected": {
bgcolor: "secondary.main",
color: dark ? "common.black" : "common.white",
"& .MuiListItemIcon-root": { color: "inherit" },
"&:hover": { bgcolor: "secondary.main" },
},
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 500 }} />
</ListItemButton>
);
})}
</List>
<StatusChips timer={timer.data} policy={policy.data} />
</Drawer>
)}
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{!wide && (
<AppBar position="sticky" sx={{ bgcolor: "background.default" }}>
<Toolbar sx={{ gap: 1 }}>
<Avatar sx={{ bgcolor: "primary.main", width: 32, height: 32 }}>🍅</Avatar>
<Typography variant="h6" sx={{ flexGrow: 1, color: "text.primary" }}>
{NAV[activeIndex]?.label ?? "Kadence"}
</Typography>
<StatusChips timer={timer.data} policy={policy.data} compact />
</Toolbar>
</AppBar>
)}
<Box
component="main"
sx={{
flexGrow: 1,
px: { xs: 2, md: 4 },
py: { xs: 2, md: 3 },
pb: { xs: 12, md: 4 },
maxWidth: 1280,
width: "100%",
mx: "auto",
}}
>
{children}
</Box>
{!wide && (
<BottomNavigation
value={activeIndex}
onChange={(_event, index) => navigate(NAV[index].to)}
showLabels
sx={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
zIndex: (zTheme) => zTheme.zIndex.appBar,
}}
>
{NAV.map((item) => (
<BottomNavigationAction key={item.to} label={item.label} icon={item.icon} />
))}
</BottomNavigation>
)}
</Box>
</Box>
);
}
function StatusChips({
timer,
policy,
compact = false,
}: {
timer: TimerState | null;
policy: BlockerPolicy | null;
compact?: boolean;
}) {
const current = timer?.current ?? null;
return (
<Stack direction={compact ? "row" : "column"} spacing={1} sx={{ px: compact ? 0 : 1 }}>
<Tooltip title={current ? `${current.kind} · ${current.status}` : "Timer idle"}>
<Chip
size="small"
color={current ? (current.kind === "work" ? "primary" : "success") : "default"}
variant={current ? "filled" : "outlined"}
label={current ? clock(current.remaining_seconds) : "idle"}
sx={{ fontVariantNumeric: "tabular-nums", fontWeight: 600 }}
/>
</Tooltip>
{!compact && timer && (
<Chip
size="small"
variant="outlined"
label={`${timer.completed_today}/${timer.goal} today`}
sx={{ justifyContent: "flex-start" }}
/>
)}
<Tooltip title={policy?.reason ?? "Blocker off"}>
<Badge color="error" variant="dot" invisible={!policy?.active}>
<Chip
size="small"
variant={policy?.active ? "filled" : "outlined"}
color={policy?.active ? "error" : "default"}
icon={<ShieldIcon fontSize="small" />}
label={policy?.active ? "blocking" : "open"}
/>
</Badge>
</Tooltip>
</Stack>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* A number with its label. Not a chart: one value, read in a glance.
*/
import { Card, CardContent, Stack, Typography } from "@mui/material";
import type { ReactNode } from "react";
export default function StatTile({
label,
value,
caption,
icon,
accent,
}: {
label: string;
value: ReactNode;
caption?: ReactNode;
icon?: ReactNode;
accent?: string;
}) {
return (
<Card sx={{ height: "100%" }}>
<CardContent sx={{ py: 2 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
{icon}
<Typography variant="overline" color="text.secondary" noWrap>
{label}
</Typography>
</Stack>
<Typography
variant="h4"
sx={{ fontVariantNumeric: "tabular-nums", color: accent ?? "text.primary" }}
>
{value}
</Typography>
{caption && (
<Typography variant="body2" color="text.secondary">
{caption}
</Typography>
)}
</CardContent>
</Card>
);
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The countdown ring.
*
* A hero number with a progress arc around it: the number is what you read, the
* arc is the glanceable "how much is left". The track stays recessive and the
* arc carries the state colour, so the dial says work/break without a label.
*/
import { Box, Stack, Typography, useTheme } from "@mui/material";
import { clock } from "../lib/format";
const SIZE = 280;
const STROKE = 14;
export default function TimerDial({
remaining,
total,
label,
sublabel,
color,
paused,
}: {
remaining: number;
total: number;
label: string;
sublabel?: string;
color: string;
paused?: boolean;
}) {
const theme = useTheme();
const radius = (SIZE - STROKE) / 2;
const circumference = 2 * Math.PI * radius;
const progress = total > 0 ? Math.min(1, Math.max(0, 1 - remaining / total)) : 0;
return (
<Box
sx={{
position: "relative",
width: SIZE,
height: SIZE,
maxWidth: "100%",
aspectRatio: "1 / 1",
mx: "auto",
}}
>
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
width="100%"
height="100%"
role="img"
aria-label={`${label}: ${clock(remaining)} remaining`}
>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
stroke={theme.palette.action.hover}
strokeWidth={STROKE}
/>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)}
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
opacity={paused ? 0.45 : 1}
style={{ transition: "stroke-dashoffset 1s linear, opacity .2s" }}
/>
</svg>
<Stack
spacing={0.5}
sx={{
position: "absolute",
inset: 0,
alignItems: "center",
justifyContent: "center",
textAlign: "center",
px: 4,
}}
>
<Typography variant="overline" color="text.secondary">
{label}
</Typography>
<Typography
sx={{
fontSize: { xs: 48, sm: 56 },
fontWeight: 300,
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
fontFamily: '"Roboto Mono", ui-monospace, monospace',
opacity: paused ? 0.6 : 1,
}}
>
{clock(remaining)}
</Typography>
{sublabel && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ maxWidth: "100%" }}>
{sublabel}
</Typography>
)}
</Stack>
</Box>
);
}
@@ -0,0 +1,61 @@
/** Shared chrome for the charts: title, optional legend, and a caption slot. */
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
import type { ReactNode } from "react";
export interface LegendItem {
label: string;
color: string;
}
export default function ChartFrame({
title,
caption,
legend,
children,
action,
}: {
title: string;
caption?: string;
legend?: LegendItem[];
children: ReactNode;
action?: ReactNode;
}) {
return (
<Card sx={{ height: "100%" }}>
<CardContent>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{title}
</Typography>
{action}
</Stack>
{caption && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{caption}
</Typography>
)}
{legend && legend.length > 1 && (
<Stack direction="row" spacing={2} sx={{ mb: 1.5, flexWrap: "wrap", rowGap: 1 }}>
{legend.map((item) => (
<Stack key={item.label} direction="row" spacing={0.75} alignItems="center">
<Box
sx={{
width: 10,
height: 10,
borderRadius: "3px",
bgcolor: item.color,
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary">
{item.label}
</Typography>
</Stack>
))}
</Stack>
)}
<Box>{children}</Box>
</CardContent>
</Card>
);
}
@@ -0,0 +1,109 @@
/**
* Pomodoros per day — magnitude over time, so: bars, one hue, baseline at zero.
*
* The goal line is the only reference mark; days that cleared it are the darker
* ramp step, which keeps "did I hit the goal" readable without a second colour.
*/
import { Box, Tooltip, Typography, useTheme } from "@mui/material";
import { shortDate } from "../../lib/format";
import type { VizPalette } from "../../lib/viz";
import ChartFrame from "./ChartFrame";
interface Day {
date: string;
pomodoros: number;
focus_minutes: number;
goal_met: boolean;
}
const HEIGHT = 160;
export default function FocusBars({
days,
goal,
palette,
title = "Pomodoros per day",
}: {
days: Day[];
goal: number;
palette: VizPalette;
title?: string;
}) {
const theme = useTheme();
const max = Math.max(goal, ...days.map((day) => day.pomodoros), 1);
const goalY = HEIGHT - (goal / max) * HEIGHT;
const slotWidth = 100 / Math.max(days.length, 1);
return (
<ChartFrame
title={title}
caption={`Last ${days.length} days · goal ${goal}/day`}
>
<Box sx={{ position: "relative", height: HEIGHT, display: "flex", alignItems: "flex-end", gap: "2px" }}>
{goal > 0 && (
<Box
sx={{
position: "absolute",
left: 0,
right: 0,
top: `${goalY}px`,
borderTop: `1px dashed ${palette.grid}`,
pointerEvents: "none",
}}
>
<Typography
variant="caption"
sx={{ position: "absolute", right: 0, top: -18, color: "text.secondary" }}
>
goal
</Typography>
</Box>
)}
{days.map((day) => {
const height = (day.pomodoros / max) * HEIGHT;
return (
<Tooltip
key={day.date}
title={
<Box>
<Typography variant="caption" sx={{ display: "block", fontWeight: 600 }}>
{day.date}
</Typography>
<Typography variant="caption" sx={{ display: "block" }}>
{day.pomodoros} pomodoros · {day.focus_minutes} min
</Typography>
</Box>
}
>
<Box
sx={{
flex: `0 0 calc(${slotWidth}% - 2px)`,
height: Math.max(height, day.pomodoros > 0 ? 4 : 2),
minHeight: 2,
borderRadius: "4px 4px 0 0",
bgcolor:
day.pomodoros === 0
? theme.palette.action.disabledBackground
: day.goal_met
? palette.sequential[4]
: palette.sequential[2],
transition: "height .3s",
cursor: "default",
}}
/>
</Tooltip>
);
})}
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{days.length ? shortDate(days[0].date) : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{days.length ? shortDate(days[days.length - 1].date) : ""}
</Typography>
</Box>
</ChartFrame>
);
}
@@ -0,0 +1,94 @@
/**
* The streak calendar: one cell per day, darker = more pomodoros.
*
* Sequential encoding, so one hue light→dark and an empty day is the surface
* tint rather than a colour — "nothing happened" should not look like a value.
*/
import { Box, Stack, Tooltip, Typography, useTheme } from "@mui/material";
import { weekdayOf } from "../../lib/format";
import { rampStep, type VizPalette } from "../../lib/viz";
import ChartFrame from "./ChartFrame";
const CELL = 13;
const GAP = 3;
const DAY_LABELS = ["M", "", "W", "", "F", "", "S"];
export default function Heatmap({
days,
goal,
palette,
}: {
days: { date: string; pomodoros: number; focus_minutes: number }[];
goal: number;
palette: VizPalette;
}) {
const theme = useTheme();
if (!days.length) return null;
// Pad the first week so every column is a real Monday→Sunday week.
const leading = weekdayOf(days[0].date);
const cells: ({ date: string; pomodoros: number; focus_minutes: number } | null)[] = [
...Array.from({ length: leading }, () => null),
...days,
];
const weeks: typeof cells[] = [];
for (let index = 0; index < cells.length; index += 7) {
weeks.push(cells.slice(index, index + 7));
}
return (
<ChartFrame title="Consistency" caption={`${days.filter((day) => day.pomodoros > 0).length} active days in the last ${days.length}`}>
<Stack direction="row" spacing={`${GAP}px`} sx={{ overflowX: "auto", pb: 1 }}>
<Stack spacing={`${GAP}px`} sx={{ pr: 0.5 }}>
{DAY_LABELS.map((label, index) => (
<Box key={index} sx={{ height: CELL, display: "flex", alignItems: "center" }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 10, lineHeight: 1 }}>
{label}
</Typography>
</Box>
))}
</Stack>
{weeks.map((week, weekIndex) => (
<Stack key={weekIndex} spacing={`${GAP}px`}>
{Array.from({ length: 7 }, (_unused, dayIndex) => {
const cell = week[dayIndex];
if (!cell) {
return <Box key={dayIndex} sx={{ width: CELL, height: CELL }} />;
}
const fill = rampStep(cell.pomodoros, goal, palette);
return (
<Tooltip
key={dayIndex}
title={`${cell.date}: ${cell.pomodoros} pomodoros · ${cell.focus_minutes} min`}
>
<Box
sx={{
width: CELL,
height: CELL,
borderRadius: "3px",
bgcolor: fill ?? theme.palette.action.hover,
outline: fill ? "none" : `1px solid ${palette.grid}`,
outlineOffset: "-1px",
}}
/>
</Tooltip>
);
})}
</Stack>
))}
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">
less
</Typography>
{palette.sequential.map((color) => (
<Box key={color} sx={{ width: CELL, height: CELL, borderRadius: "3px", bgcolor: color }} />
))}
<Typography variant="caption" color="text.secondary">
more
</Typography>
</Stack>
</ChartFrame>
);
}
@@ -0,0 +1,58 @@
/**
* When during the day the work actually happens. One series, one hue; the peak
* hour is the only bar that gets a direct label, which is the number you came
* for.
*/
import { Box, Tooltip, Typography, useTheme } from "@mui/material";
import type { VizPalette } from "../../lib/viz";
import ChartFrame from "./ChartFrame";
const HEIGHT = 120;
export default function HourHistogram({
byHour,
palette,
peak,
}: {
byHour: { hour: number; count: number }[];
palette: VizPalette;
peak: number | null;
}) {
const theme = useTheme();
const max = Math.max(1, ...byHour.map((entry) => entry.count));
return (
<ChartFrame
title="Focus by hour"
caption={peak === null ? "No pomodoros logged yet today" : `Peak hour: ${String(peak).padStart(2, "0")}:00`}
>
<Box sx={{ display: "flex", alignItems: "flex-end", gap: "2px", height: HEIGHT }}>
{byHour.map((entry) => (
<Tooltip key={entry.hour} title={`${String(entry.hour).padStart(2, "0")}:00 — ${entry.count}`}>
<Box
sx={{
flex: 1,
height: Math.max((entry.count / max) * HEIGHT, entry.count ? 4 : 2),
borderRadius: "4px 4px 0 0",
bgcolor:
entry.count === 0
? theme.palette.action.disabledBackground
: entry.hour === peak
? palette.sequential[4]
: palette.sequential[2],
}}
/>
</Tooltip>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 0.5 }}>
{["00", "06", "12", "18", "23"].map((label) => (
<Typography key={label} variant="caption" color="text.secondary">
{label}
</Typography>
))}
</Box>
</ChartFrame>
);
}
@@ -0,0 +1,108 @@
/**
* Today, laid out on a clock: every interval in its real place.
*
* Three categories (focus / short / long break) so a legend is mandatory, and
* each block is also labelled on hover — colour never carries identity alone.
*/
import { Box, Stack, Tooltip, Typography, useTheme } from "@mui/material";
import { KIND_LABEL } from "../../lib/format";
import { kindColor, type VizPalette } from "../../lib/viz";
import ChartFrame from "./ChartFrame";
interface Session {
id: number;
kind: string;
status: string;
started_at: string;
minutes: number;
task: string | null;
}
const LANE = 34;
export default function SessionTimeline({
sessions,
palette,
}: {
sessions: Session[];
palette: VizPalette;
}) {
const theme = useTheme();
const done = sessions.filter((item) => item.minutes > 0);
if (!done.length) {
return (
<ChartFrame title="Today's timeline" caption="Nothing recorded yet — start a pomodoro.">
<Box sx={{ height: LANE, borderRadius: 1, bgcolor: theme.palette.action.hover }} />
</ChartFrame>
);
}
const hourOf = (iso: string) => {
const date = new Date(iso);
return date.getHours() + date.getMinutes() / 60;
};
const first = Math.floor(Math.min(...done.map((item) => hourOf(item.started_at))));
const last = Math.ceil(
Math.max(...done.map((item) => hourOf(item.started_at) + item.minutes / 60)),
);
const span = Math.max(last - first, 1);
return (
<ChartFrame
title="Today's timeline"
caption={`${first}:00 → ${last}:00`}
legend={[
{ label: KIND_LABEL.work, color: palette.work },
{ label: KIND_LABEL.short_break, color: palette.shortBreak },
{ label: KIND_LABEL.long_break, color: palette.longBreak },
]}
>
<Box sx={{ position: "relative", height: LANE, borderRadius: 1, bgcolor: theme.palette.action.hover }}>
{done.map((item) => {
const start = (hourOf(item.started_at) - first) / span;
const width = item.minutes / 60 / span;
return (
<Tooltip
key={item.id}
title={
<Box>
<Typography variant="caption" sx={{ display: "block", fontWeight: 600 }}>
{KIND_LABEL[item.kind] ?? item.kind} · {item.minutes} min
</Typography>
<Typography variant="caption" sx={{ display: "block" }}>
{new Date(item.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{item.task ? ` · ${item.task}` : ""}
{item.status === "abandoned" ? " · abandoned" : ""}
</Typography>
</Box>
}
>
<Box
sx={{
position: "absolute",
left: `${start * 100}%`,
width: `calc(${Math.max(width * 100, 1)}% - 2px)`,
top: 4,
bottom: 4,
borderRadius: "4px",
bgcolor: kindColor(item.kind, palette),
opacity: item.status === "abandoned" ? 0.4 : 1,
border: item.status === "abandoned" ? `1px dashed ${palette.axis}` : "none",
}}
/>
</Tooltip>
);
})}
</Box>
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{String(first).padStart(2, "0")}:00
</Typography>
<Typography variant="caption" color="text.secondary">
{String(last).padStart(2, "0")}:00
</Typography>
</Stack>
</ChartFrame>
);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* The one place that talks to the backend.
*
* Every path is relative: in production the API and the UI share an origin, and
* in development Vite proxies /api, so neither build needs to know a host. The
* optional shared secret is kept in localStorage and sent on every call.
*/
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
const TOKEN_KEY = "pt.apiToken";
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? "";
}
export function setToken(token: string): void {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
if (init.body) headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("X-API-Token", token);
const response = await fetch(path, { ...init, headers });
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
if (typeof body?.detail === "string") detail = body.detail;
else if (Array.isArray(body?.detail)) {
detail = body.detail
.map((item: { loc?: string[]; msg?: string }) =>
`${(item.loc ?? []).slice(1).join(".")}: ${item.msg ?? ""}`.trim(),
)
.join("; ");
}
} catch {
/* a non-JSON error body is fine — the status line says enough */
}
throw new ApiError(detail, response.status);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body?: unknown) =>
request<T>(path, { method: "POST", body: body === undefined ? "{}" : JSON.stringify(body) }),
put: <T,>(path: string, body: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T,>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
del: <T,>(path: string) => request<T>(path, { method: "DELETE" }),
};
+47
View File
@@ -0,0 +1,47 @@
/** Formatting shared by every page, so numbers read the same everywhere. */
export function clock(totalSeconds: number): string {
const safe = Math.max(0, Math.round(totalSeconds));
const hours = Math.floor(safe / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const seconds = safe % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`;
}
export function humanMinutes(minutes: number): string {
const rounded = Math.round(minutes);
const hours = Math.floor(rounded / 60);
const rest = rounded % 60;
if (hours && rest) return `${hours}h ${rest}m`;
if (hours) return `${hours}h`;
return `${rest}m`;
}
export const KIND_LABEL: Record<string, string> = {
work: "Focus",
short_break: "Short break",
long_break: "Long break",
};
export const BUCKET_LABEL: Record<string, string> = {
overdue: "Overdue",
today: "Due today",
upcoming: "Coming up",
someday: "No deadline",
};
export function dueLabel(task: { due_date: string | null; days_overdue: number }): string {
if (!task.due_date) return "";
if (task.days_overdue > 0) return `${task.days_overdue}d overdue`;
return task.due_date;
}
export function shortDate(iso: string): string {
return iso.slice(5).replace("-", "/");
}
export function weekdayOf(iso: string): number {
// Monday = 0, matching the backend's schedule days.
return (new Date(`${iso}T00:00:00`).getDay() + 6) % 7;
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Small data hooks. No query library: the app has a handful of endpoints and
* one rule — the server owns the truth, the client polls it and ticks locally
* between polls so the countdown stays smooth without hammering the API.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "./api";
export interface Resource<T> {
data: T | null;
error: string | null;
loading: boolean;
reload: () => Promise<void>;
set: (value: T) => void;
}
export function useResource<T>(path: string | null, pollMs = 0): Resource<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(path !== null);
const alive = useRef(true);
const reload = useCallback(async () => {
if (!path) return;
try {
const value = await api.get<T>(path);
if (!alive.current) return;
setData(value);
setError(null);
} catch (exc) {
if (alive.current) setError(exc instanceof Error ? exc.message : String(exc));
} finally {
if (alive.current) setLoading(false);
}
}, [path]);
useEffect(() => {
alive.current = true;
void reload();
if (!pollMs) return () => void (alive.current = false);
const handle = window.setInterval(() => void reload(), pollMs);
return () => {
alive.current = false;
window.clearInterval(handle);
};
}, [reload, pollMs]);
return { data, error, loading, reload, set: setData };
}
/** A 1 Hz tick, used to count down between server polls. */
export function useTick(active: boolean): number {
const [tick, setTick] = useState(0);
useEffect(() => {
if (!active) return;
const handle = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(handle);
}, [active]);
return tick;
}
/** Remembers a value in localStorage (used for per-device UI preferences). */
export function useLocalState<T>(key: string, initial: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
if (stored === null) return initial;
try {
return JSON.parse(stored) as T;
} catch {
return initial;
}
});
const update = useCallback(
(next: T) => {
setValue(next);
localStorage.setItem(key, JSON.stringify(next));
},
[key],
);
return [value, update];
}
+64
View File
@@ -0,0 +1,64 @@
/**
* The settings context.
*
* One fetch at start-up, shared by every page, with a `save` that sends a patch
* and swaps in whatever the server echoes back — so the secret masking stays
* consistent and no page keeps a stale copy.
*/
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { api } from "./api";
import type { AppSettings, SettingsResponse } from "./types";
interface SettingsContextValue {
settings: AppSettings | null;
defaults: SettingsResponse["defaults"] | null;
error: string | null;
loading: boolean;
reload: () => Promise<void>;
save: (patch: Record<string, unknown>) => Promise<AppSettings>;
}
const Context = createContext<SettingsContextValue | null>(null);
export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [defaults, setDefaults] = useState<SettingsResponse["defaults"] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
try {
const body = await api.get<SettingsResponse>("/api/settings");
setSettings(body.settings);
setDefaults(body.defaults);
setError(null);
} catch (exc) {
setError(exc instanceof Error ? exc.message : String(exc));
} finally {
setLoading(false);
}
}, []);
const save = useCallback(async (patch: Record<string, unknown>) => {
const body = await api.put<{ settings: AppSettings }>("/api/settings", patch);
setSettings(body.settings);
return body.settings;
}, []);
useEffect(() => {
void reload();
}, [reload]);
const value = useMemo(
() => ({ settings, defaults, error, loading, reload, save }),
[settings, defaults, error, loading, reload, save],
);
return <Context.Provider value={value}>{children}</Context.Provider>;
}
export function useSettings(): SettingsContextValue {
const value = useContext(Context);
if (!value) throw new Error("useSettings outside SettingsProvider");
return value;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Alarms and ticks, synthesised with the Web Audio API.
*
* No audio files to ship or to 404, and no autoplay surprise: the context is
* created on the first user gesture (starting a timer counts) and reused.
*/
let context: AudioContext | null = null;
function ensureContext(): AudioContext | null {
if (typeof window === "undefined") return null;
if (!context) {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
context = new Ctor();
}
if (context.state === "suspended") void context.resume();
return context;
}
function beep(frequency: number, seconds: number, volume: number, at = 0): void {
const ctx = ensureContext();
if (!ctx) return;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = "sine";
oscillator.frequency.value = frequency;
const start = ctx.currentTime + at;
// A short ramp instead of a hard stop; a clipped sine clicks.
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, volume), start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + seconds);
oscillator.connect(gain).connect(ctx.destination);
oscillator.start(start);
oscillator.stop(start + seconds + 0.05);
}
export const sound = {
/** Work interval finished: three rising notes. */
workDone(volume: number): void {
const level = volume / 100;
beep(660, 0.18, 0.25 * level, 0);
beep(880, 0.18, 0.25 * level, 0.2);
beep(1046, 0.4, 0.25 * level, 0.4);
},
/** Break finished: two falling notes — back to it. */
breakDone(volume: number): void {
const level = volume / 100;
beep(880, 0.18, 0.22 * level, 0);
beep(587, 0.35, 0.22 * level, 0.2);
},
tick(volume: number): void {
beep(1200, 0.03, 0.04 * (volume / 100));
},
unlock(): void {
ensureContext();
},
};
/** A desktop notification, when the browser (or Electron) allows one. */
export function notify(title: string, body: string): void {
if (typeof Notification === "undefined") return;
if (Notification.permission === "granted") {
new Notification(title, { body, icon: undefined });
} else if (Notification.permission !== "denied") {
void Notification.requestPermission();
}
}
+231
View File
@@ -0,0 +1,231 @@
/** Shapes returned by the API. Only the fields the UI actually reads. */
export type TimerKind = "work" | "short_break" | "long_break";
export interface TimerSession {
id: number;
kind: TimerKind;
status: "running" | "paused" | "completed" | "abandoned";
started_at: string;
ended_at: string | null;
planned_seconds: number;
elapsed_seconds: number;
remaining_seconds: number;
interruptions: number;
cycle_index: number;
task: { source: string; external_id: string; title: string } | null;
note: string | null;
}
export interface TimerState {
current: TimerSession | null;
next_kind: TimerKind;
completed_today: number;
goal: number;
cycle_position: number;
cycles_before_long_break: number;
durations: Record<TimerKind, number>;
server_time: string;
finished?: TimerSession | null;
auto_started?: TimerSession | null;
celebration?: string | null;
}
export interface Task {
id: number;
source: string;
source_label: string;
external_id: string;
title: string;
description: string | null;
url: string | null;
project: string | null;
status: string | null;
priority: string | null;
due_date: string | null;
start_date: string | null;
labels: string[];
is_done: boolean;
pinned: boolean;
stale: boolean;
estimated_pomodoros: number | null;
days_overdue: number;
bucket: "overdue" | "today" | "upcoming" | "someday";
}
export interface TaskListResponse {
date: string;
tasks: Task[];
counts: Record<string, number>;
sources: string[];
}
export interface RefreshResult {
refreshed_at: string;
date: string;
total: number;
sources: { source: string; label: string; ok: boolean; error: string | null; count: number }[];
}
export interface BlockerPolicy {
active: boolean;
reason: string;
mode: string;
enabled: boolean;
strict: boolean;
redirect_ip: string;
domains: string[];
apps: string[];
kill_apps: boolean;
poll_seconds: number;
all_domains: string[];
all_apps: string[];
session_id: number | null;
}
export interface BlockEvents {
date: string;
total: number;
by_target: { target: string; count: number }[];
events: { id: number; kind: string; target: string; action: string; at: string; details: string | null }[];
}
export interface DayMetrics {
date: string;
pomodoros: number;
goal: number;
goal_pct: number;
goal_met: boolean;
focus_minutes: number;
break_minutes: number;
wasted_minutes: number;
abandoned: number;
breaks_taken: number;
interruptions: number;
completion_rate: number;
longest_stretch_minutes: number;
first_start: string | null;
last_end: string | null;
by_hour: { hour: number; count: number }[];
peak_hour: number | null;
tasks_completed: { id: number; title: string; source: string; project: string | null; url: string | null }[];
tasks_completed_count: number;
time_by_task: { title: string; minutes: number }[];
blocked_attempts: number;
blocked_top: { target: string; count: number }[];
sessions: {
id: number;
kind: TimerKind;
status: string;
started_at: string;
minutes: number;
task: string | null;
interruptions: number;
}[];
}
export interface DailyReport {
date: string;
metrics: DayMetrics;
streak: { current: number; best: number; goal_streak: number; total_days: number };
average_7d: { pomodoros: number; focus_minutes: number };
lifetime: { pomodoros: number; focus_minutes: number; focus_hours: number; tasks_completed: number; blocked_attempts: number };
comparison: { yesterday: number; previous_best: number };
achievements: { code: string; emoji: string; title: string; detail: string }[];
fun_facts: string[];
verdict: string;
open_tasks: Record<string, number>;
score: number;
}
export interface StatsSummary {
today: DayMetrics;
streak: { current: number; best: number; goal_streak: number; total_days: number };
lifetime: DailyReport["lifetime"];
average_7d: { pomodoros: number; focus_minutes: number };
range: { date: string; pomodoros: number; focus_minutes: number; goal_met: boolean }[];
heatmap: { date: string; pomodoros: number; focus_minutes: number; goal_met: boolean }[];
}
export interface AppSettings {
timezone: string;
week_starts_monday: boolean;
pomodoro: {
work_minutes: number;
short_break_minutes: number;
long_break_minutes: number;
cycles_before_long_break: number;
auto_start_breaks: boolean;
auto_start_work: boolean;
daily_goal_pomodoros: number;
strict_mode: boolean;
allow_pause: boolean;
count_interruptions: boolean;
tick_sound: boolean;
alarm_sound: boolean;
alarm_volume: number;
desktop_notifications: boolean;
keep_on_top_during_work: boolean;
};
sources: {
jira: Record<string, unknown> & { enabled: boolean; base_url: string; email: string; api_token: string; extra_jql: string; start_date_field: string; include_all_open: boolean; writeback_done: boolean; done_transition_name: string; max_issues: number };
google_tasks: { enabled: boolean; client_id: string; client_secret: string; refresh_token: string; lists: string[]; include_undated: boolean; writeback_done: boolean };
todoist: { enabled: boolean; api_token: string; projects: string[]; filter_query: string; writeback_done: boolean };
laundry: { enabled: boolean; base_url: string; session_cookie: string };
refresh_interval_minutes: number;
request_timeout_seconds: number;
};
blocker: {
enabled: boolean;
mode: "during_work" | "always" | "scheduled";
block_during_breaks: boolean;
blocked_domains: string[];
allowed_domains: string[];
blocked_apps: string[];
kill_blocked_apps: boolean;
schedule: { label: string; start: string; end: string; days: number[] }[];
strict: boolean;
redirect_ip: string;
app_poll_seconds: number;
notify_on_block: boolean;
};
notifications: {
telegram: {
enabled: boolean;
bot_token: string;
chat_id: string;
commands_enabled: boolean;
daily_report: boolean;
daily_report_time: string;
morning_briefing: boolean;
morning_briefing_time: string;
weekly_report: boolean;
weekly_report_weekday: number;
celebrate_every_n_pomodoros: number;
nudge_enabled: boolean;
nudge_after_idle_minutes: number;
nudge_window_start: string;
nudge_window_end: string;
nudge_max_per_day: number;
goal_warning: boolean;
quiet_hours: { enabled: boolean; start: string; end: string };
};
slack: { enabled: boolean; webhook_url: string };
report: {
tone: "friendly" | "coach" | "drill_sergeant" | "dry";
language: "en" | "ru";
include_funny_stats: boolean;
include_task_list: boolean;
include_blocked_sites: boolean;
include_achievements: boolean;
include_comparison: boolean;
max_tasks_listed: number;
};
};
appearance: { theme: "light" | "dark" | "system"; accent: string; compact: boolean };
}
export interface SettingsResponse {
settings: AppSettings;
defaults: { blocked_domains: string[]; blocked_apps: string[] };
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Chart colours.
*
* These are the validated data-viz slots, not the Material theme's palette: the
* UI's accent can be anything, but a chart has to stay readable for colour-blind
* readers in both themes. Work/short/long are categorical slots 1–3 (validated
* all-pairs in both modes); magnitude charts use the single-hue blue ramp.
*/
export interface VizPalette {
work: string;
shortBreak: string;
longBreak: string;
sequential: string[];
grid: string;
axis: string;
surface: string;
}
const LIGHT: VizPalette = {
work: "#2a78d6",
shortBreak: "#eb6834",
longBreak: "#1baf7a",
// blue 100 → 600, light → dark: "more pomodoros" reads as "darker".
sequential: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"],
grid: "rgba(0,0,0,0.08)",
axis: "rgba(0,0,0,0.45)",
surface: "#fcfcfb",
};
const DARK: VizPalette = {
work: "#3987e5",
shortBreak: "#d95926",
longBreak: "#199e70",
sequential: ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"],
grid: "rgba(255,255,255,0.10)",
axis: "rgba(255,255,255,0.55)",
surface: "#1a1a19",
};
export function vizPalette(dark: boolean): VizPalette {
return dark ? DARK : LIGHT;
}
export function kindColor(kind: string, palette: VizPalette): string {
if (kind === "work") return palette.work;
if (kind === "long_break") return palette.longBreak;
return palette.shortBreak;
}
/** Pick a ramp step for a count, 0 meaning "empty". */
export function rampStep(count: number, goal: number, palette: VizPalette): string | null {
if (count <= 0) return null;
const ratio = goal > 0 ? count / goal : count / 8;
const index = Math.min(palette.sequential.length - 1, Math.max(0, Math.round(ratio * (palette.sequential.length - 1))));
return palette.sequential[index];
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { HashRouter } from "react-router-dom";
import App from "./App";
// HashRouter, not BrowserRouter: the same bundle is loaded from http:// by the
// web app and from file:// by the packaged desktop app, and only the hash
// router works in both without a server rewrite.
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>,
);
+300
View File
@@ -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>
);
}
+222
View File
@@ -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>
);
}
+626
View File
@@ -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 };
+316
View File
@@ -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>
);
}
+429
View File
@@ -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;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Material theme.
*
* The seed colour from the settings drives the palette, so changing the accent
* repaints the whole app; shape, elevation and typography follow Material 3's
* defaults (rounded 16–28px surfaces, tonal containers, no hard shadows).
*/
import { alpha, createTheme, type Theme } from "@mui/material/styles";
export type ThemeMode = "light" | "dark";
export function buildTheme(mode: ThemeMode, seed: string, compact: boolean): Theme {
const dark = mode === "dark";
return createTheme({
palette: {
mode,
primary: { main: seed },
secondary: { main: dark ? "#CCC2DC" : "#625B71" },
success: { main: dark ? "#8BD5A8" : "#2E7D52" },
warning: { main: dark ? "#E6B566" : "#9A6700" },
error: { main: dark ? "#F2B8B5" : "#B3261E" },
background: {
default: dark ? "#131316" : "#FBF8FD",
paper: dark ? "#1C1B20" : "#FFFFFF",
},
},
shape: { borderRadius: 16 },
spacing: compact ? 6 : 8,
typography: {
fontFamily: '"Roboto", "Segoe UI", system-ui, sans-serif',
h4: { fontWeight: 500, letterSpacing: "-0.01em" },
h5: { fontWeight: 500 },
h6: { fontWeight: 500 },
button: { textTransform: "none", fontWeight: 500, letterSpacing: 0 },
overline: { letterSpacing: "0.08em", fontWeight: 500 },
},
components: {
MuiPaper: {
styleOverrides: {
root: ({ theme }) => ({
backgroundImage: "none",
border: `1px solid ${alpha(theme.palette.text.primary, 0.08)}`,
}),
},
},
MuiCard: {
defaultProps: { elevation: 0 },
styleOverrides: { root: { borderRadius: 20 } },
},
MuiButton: {
styleOverrides: { root: { borderRadius: 999, paddingInline: 20 } },
},
MuiChip: { styleOverrides: { root: { borderRadius: 8 } } },
MuiTextField: { defaultProps: { size: "small" } },
MuiTooltip: { defaultProps: { arrow: true } },
MuiAppBar: {
defaultProps: { elevation: 0, color: "transparent" },
styleOverrides: { root: { backdropFilter: "blur(12px)" } },
},
},
});
}