refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency
- Refactored TimerPage component to improve layout and user experience. - Updated theme configuration to utilize design tokens for better maintainability. - Introduced new tokens for colors and typography to align with design principles. - Simplified state management and improved timer functionality. - Enhanced accessibility and responsiveness of UI elements.
This commit is contained in:
84 files changed
+4501
-2682
No files matched your search
+4
-2
@@ -3,13 +3,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#6750A4" />
|
||||
<meta name="theme-color" content="#FAF7F2" />
|
||||
<title>Kadence</title>
|
||||
<meta name="description" content="Pomodoro timer, task list and focus guard." />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍅</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<!-- Fraunces carries the numbers and the headings; Inter does the work. -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;500&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
Generated
-42
@@ -1133,9 +1133,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1263,9 +1260,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1280,9 +1274,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1297,9 +1288,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1314,9 +1302,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1331,9 +1316,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1348,9 +1330,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1365,9 +1344,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1382,9 +1358,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1399,9 +1372,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1416,9 +1386,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1433,9 +1400,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1450,9 +1414,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1467,9 +1428,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -23,8 +23,8 @@ function Shell() {
|
||||
}, [settings?.appearance.theme, prefersDark]);
|
||||
|
||||
const theme = useMemo(
|
||||
() => buildTheme(mode, settings?.appearance.accent || "#6750A4", settings?.appearance.compact ?? false),
|
||||
[mode, settings?.appearance.accent, settings?.appearance.compact],
|
||||
() => buildTheme(mode, settings?.appearance.compact ?? false),
|
||||
[mode, settings?.appearance.compact],
|
||||
);
|
||||
|
||||
// Keep the browser chrome in step with the app's surface.
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* The frame: a navigation rail on a wide screen, a bottom bar on a phone.
|
||||
* The frame.
|
||||
*
|
||||
* 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.
|
||||
* A quiet left column on a wide screen — wordmark, navigation as a list of
|
||||
* words, and the two things worth knowing from anywhere (what the timer is
|
||||
* doing, whether the blocker is on) pinned to the bottom. On a phone it
|
||||
* collapses to a top bar and a row of tabs.
|
||||
*/
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
BottomNavigation,
|
||||
BottomNavigationAction,
|
||||
Chip,
|
||||
Drawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Stack,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
@@ -26,26 +20,27 @@ import {
|
||||
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 AssignmentOutlinedIcon from "@mui/icons-material/AssignmentOutlined";
|
||||
import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { clock } from "../lib/format";
|
||||
import { useResource } from "../lib/hooks";
|
||||
import type { BlockerPolicy, TimerState } from "../lib/types";
|
||||
import { kindColor, vizPalette } from "../lib/viz";
|
||||
|
||||
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 /> },
|
||||
{ to: "/", label: "Timer", icon: <TimerOutlinedIcon fontSize="small" /> },
|
||||
{ to: "/tasks", label: "Tasks", icon: <AssignmentOutlinedIcon fontSize="small" /> },
|
||||
{ to: "/blocker", label: "Blocker", icon: <ShieldOutlinedIcon fontSize="small" /> },
|
||||
{ to: "/report", label: "Report", icon: <BarChartOutlinedIcon fontSize="small" /> },
|
||||
{ to: "/settings", label: "Settings", icon: <SettingsOutlinedIcon fontSize="small" /> },
|
||||
];
|
||||
|
||||
const RAIL_WIDTH = 220;
|
||||
const RAIL_WIDTH = 216;
|
||||
|
||||
export default function AppShell({
|
||||
children,
|
||||
@@ -59,6 +54,7 @@ export default function AppShell({
|
||||
const wide = useMediaQuery(theme.breakpoints.up("md"));
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const palette = vizPalette(dark);
|
||||
|
||||
const timer = useResource<TimerState>("/api/timer/state", 5000);
|
||||
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
|
||||
@@ -66,84 +62,152 @@ export default function AppShell({
|
||||
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),
|
||||
const activeIndex = Math.max(0, NAV.findIndex((item) => item.to === location.pathname));
|
||||
|
||||
const status = (
|
||||
<Stack spacing={0.75} sx={{ width: "100%" }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
bgcolor: current ? kindColor(current.kind, palette) : "text.disabled",
|
||||
animation: current?.status === "running" ? "pulse 2s ease-in-out infinite" : "none",
|
||||
"@keyframes pulse": { "0%,100%": { opacity: 1 }, "50%": { opacity: 0.35 } },
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontVariantNumeric: "tabular-nums", fontWeight: 500, letterSpacing: "0.01em" }}
|
||||
>
|
||||
{timer.data
|
||||
? (timer.data.timer_enabled === false ? "View mode" : (current ? clock(remaining) : "Idle"))
|
||||
: "—"}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{timer.data ? `${timer.data.completed_today}/${timer.data.goal}` : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{policy.data?.active && (
|
||||
<Tooltip title={policy.data.reason}>
|
||||
<Typography variant="caption" sx={{ color: "error.main", letterSpacing: "0.04em" }}>
|
||||
● BLOCKING
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
{wide && (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{
|
||||
width: RAIL_WIDTH,
|
||||
flexShrink: 0,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: RAIL_WIDTH,
|
||||
borderRight: "none",
|
||||
bgcolor: "background.default",
|
||||
px: 1.5,
|
||||
py: 2,
|
||||
},
|
||||
borderRight: 1,
|
||||
borderColor: "divider",
|
||||
px: 2.5,
|
||||
py: 3,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<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 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: theme.typography.h1.fontFamily,
|
||||
fontSize: 23,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
Kadence
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
find your rhythm
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={0.25} sx={{ flexGrow: 1 }}>
|
||||
{NAV.map((item) => {
|
||||
const selected = location.pathname === item.to;
|
||||
return (
|
||||
<ListItemButton
|
||||
<Box
|
||||
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" },
|
||||
},
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.25,
|
||||
px: 1,
|
||||
py: 0.9,
|
||||
ml: -1,
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
color: selected ? "text.primary" : "text.secondary",
|
||||
position: "relative",
|
||||
"&:hover": { color: "text.primary", bgcolor: "action.hover" },
|
||||
"&::before": selected
|
||||
? {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: -10,
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
width: 2,
|
||||
bgcolor: "primary.main",
|
||||
borderRadius: 2,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 500 }} />
|
||||
</ListItemButton>
|
||||
{item.icon}
|
||||
<Typography variant="body2" sx={{ fontWeight: selected ? 600 : 400 }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<StatusChips timer={timer.data} policy={policy.data} />
|
||||
</Drawer>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
{status}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<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"}
|
||||
<AppBar
|
||||
position="sticky"
|
||||
sx={{ bgcolor: "background.default", borderBottom: 1, borderColor: "divider" }}
|
||||
>
|
||||
<Toolbar sx={{ gap: 1, minHeight: 56 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: theme.typography.h1.fontFamily,
|
||||
fontSize: 19,
|
||||
color: "text.primary",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
Kadence
|
||||
</Typography>
|
||||
<StatusChips timer={timer.data} policy={policy.data} compact />
|
||||
<Box sx={{ width: 120 }}>{status}</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)}
|
||||
@@ -152,10 +216,10 @@ export default function AppShell({
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
px: { xs: 2, md: 4 },
|
||||
py: { xs: 2, md: 3 },
|
||||
pb: { xs: 12, md: 4 },
|
||||
maxWidth: 1280,
|
||||
px: { xs: 2, md: 5 },
|
||||
py: { xs: 3, md: 4 },
|
||||
pb: { xs: 12, md: 6 },
|
||||
maxWidth: 1180,
|
||||
width: "100%",
|
||||
mx: "auto",
|
||||
}}
|
||||
@@ -176,7 +240,8 @@ export default function AppShell({
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
zIndex: (zTheme) => zTheme.zIndex.appBar,
|
||||
zIndex: (z) => z.zIndex.appBar,
|
||||
"& .MuiBottomNavigationAction-label": { fontSize: 11 },
|
||||
}}
|
||||
>
|
||||
{NAV.map((item) => (
|
||||
@@ -188,47 +253,3 @@ export default function AppShell({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* The page furniture: headers, rules, panels and statistics.
|
||||
*
|
||||
* Every page is built from these four, so spacing and hierarchy are decided
|
||||
* once. The look is editorial — an eyebrow label, a serif title, a hairline,
|
||||
* then content — rather than a grid of floating cards.
|
||||
*/
|
||||
import { Box, Divider, Stack, Typography, type SxProps, type Theme } from "@mui/material";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
actions,
|
||||
caption,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
actions?: ReactNode;
|
||||
caption?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
alignItems={{ xs: "flex-start", sm: "flex-end" }}
|
||||
spacing={2}
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
{eyebrow && (
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: "block" }}>
|
||||
{eyebrow}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h4" sx={{ fontSize: { xs: 26, sm: 30 }, lineHeight: 1.15 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{caption && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Stack direction="row" spacing={1} sx={{ flexShrink: 0, flexWrap: "wrap", rowGap: 1 }}>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionTitle({
|
||||
children,
|
||||
action,
|
||||
sx,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
sx?: SxProps<Theme>;
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5, ...sx }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
{children}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1, height: "1px", bgcolor: "divider" }} />
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
children,
|
||||
sx,
|
||||
padded = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
sx?: SxProps<Theme>;
|
||||
padded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: "14px",
|
||||
bgcolor: "background.paper",
|
||||
p: padded ? { xs: 2, sm: 2.5 } : 0,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number and its name. The figure is set in the display serif, which is what
|
||||
* makes a page of statistics read like a page rather than a readout.
|
||||
*/
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
accent,
|
||||
size = "md",
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
caption?: ReactNode;
|
||||
accent?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}) {
|
||||
const fontSize = { sm: 24, md: 32, lg: 44 }[size];
|
||||
return (
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: (theme) => theme.typography.h4.fontFamily,
|
||||
fontSize,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: "-0.02em",
|
||||
color: accent ?? "text.primary",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
{caption && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25 }}>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row of statistics separated by hairlines, not boxes. */
|
||||
export function StatRow({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Panel padded={false}>
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
divider={<Divider flexItem orientation="vertical" sx={{ display: { xs: "none", sm: "block" } }} />}
|
||||
sx={{ "& > *": { flex: 1, p: { xs: 2, sm: 2.5 } } }}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* The countdown ring.
|
||||
* The dial.
|
||||
*
|
||||
* 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.
|
||||
* A thin ring with one tick per minute of the interval: the ticks go quiet as
|
||||
* the minutes are spent, so the dial reads as "how much is left" at a glance
|
||||
* and as "how long was this?" on a second look. The time itself is the hero —
|
||||
* set in the display serif, tabular, so it never jitters.
|
||||
*/
|
||||
import { Box, Stack, Typography, useTheme } from "@mui/material";
|
||||
|
||||
import { clock } from "../lib/format";
|
||||
|
||||
const SIZE = 280;
|
||||
const STROKE = 14;
|
||||
const SIZE = 300;
|
||||
|
||||
export default function TimerDial({
|
||||
remaining,
|
||||
@@ -19,6 +19,7 @@ export default function TimerDial({
|
||||
sublabel,
|
||||
color,
|
||||
paused,
|
||||
idle,
|
||||
}: {
|
||||
remaining: number;
|
||||
total: number;
|
||||
@@ -26,23 +27,20 @@ export default function TimerDial({
|
||||
sublabel?: string;
|
||||
color: string;
|
||||
paused?: boolean;
|
||||
idle?: boolean;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const radius = (SIZE - STROKE) / 2;
|
||||
const radius = SIZE / 2 - 26;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = total > 0 ? Math.min(1, Math.max(0, 1 - remaining / total)) : 0;
|
||||
const elapsed = total > 0 ? Math.min(1, Math.max(0, 1 - remaining / total)) : 0;
|
||||
|
||||
// One tick per minute, capped so a 90-minute interval does not become a comb.
|
||||
const minutes = Math.max(1, Math.round(total / 60));
|
||||
const tickCount = minutes <= 60 ? minutes : Math.round(minutes / 5);
|
||||
const spentTicks = Math.floor(tickCount * elapsed);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
maxWidth: "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
mx: "auto",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative", width: "100%", maxWidth: SIZE, aspectRatio: "1 / 1", mx: "auto" }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
width="100%"
|
||||
@@ -50,57 +48,89 @@ export default function TimerDial({
|
||||
role="img"
|
||||
aria-label={`${label}: ${clock(remaining)} remaining`}
|
||||
>
|
||||
<g transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}>
|
||||
{Array.from({ length: tickCount }, (_unused, index) => {
|
||||
const angle = (index / tickCount) * 2 * Math.PI;
|
||||
const major = index % 5 === 0;
|
||||
const outer = radius - 7;
|
||||
const inner = outer - (major ? 9 : 5);
|
||||
const spent = index < spentTicks;
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1={SIZE / 2 + inner * Math.cos(angle)}
|
||||
y1={SIZE / 2 + inner * Math.sin(angle)}
|
||||
x2={SIZE / 2 + outer * Math.cos(angle)}
|
||||
y2={SIZE / 2 + outer * Math.sin(angle)}
|
||||
stroke={spent && !idle ? color : theme.palette.divider}
|
||||
strokeWidth={major ? 1.4 : 1}
|
||||
strokeLinecap="round"
|
||||
opacity={idle ? 0.5 : spent ? 0.9 : 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
<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" }}
|
||||
stroke={theme.palette.divider}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{!idle && (
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * elapsed}
|
||||
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
|
||||
opacity={paused ? 0.4 : 1}
|
||||
style={{ transition: "stroke-dashoffset 1s linear, opacity .25s" }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<Stack
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
px: 4,
|
||||
}}
|
||||
spacing={0.75}
|
||||
sx={{ position: "absolute", inset: 0, alignItems: "center", justifyContent: "center", px: 5 }}
|
||||
>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
<Typography variant="overline" sx={{ color: idle ? "text.secondary" : color }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: { xs: 48, sm: 56 },
|
||||
fontWeight: 300,
|
||||
fontFamily: (muiTheme) => muiTheme.typography.h1.fontFamily,
|
||||
fontSize: { xs: 52, sm: 62 },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1,
|
||||
letterSpacing: "-0.03em",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
fontFamily: '"Roboto Mono", ui-monospace, monospace',
|
||||
opacity: paused ? 0.6 : 1,
|
||||
opacity: paused ? 0.55 : 1,
|
||||
transition: "opacity .25s",
|
||||
}}
|
||||
>
|
||||
{clock(remaining)}
|
||||
</Typography>
|
||||
{sublabel && (
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{ maxWidth: "100%" }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
sx={{
|
||||
maxWidth: "100%",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{sublabel}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/** Shared chrome for the charts: title, optional legend, and a caption slot. */
|
||||
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
|
||||
/** Shared chrome for a chart: an eyebrow, a hairline, the plot, a caption. */
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Panel, SectionTitle } from "../Layout";
|
||||
|
||||
export interface LegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
@@ -21,41 +23,26 @@ export default function ChartFrame({
|
||||
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}
|
||||
<Panel sx={{ height: "100%" }}>
|
||||
<SectionTitle action={action}>{title}</SectionTitle>
|
||||
{legend && legend.length > 1 && (
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 1.5, flexWrap: "wrap", rowGap: 0.75 }}>
|
||||
{legend.map((item) => (
|
||||
<Stack key={item.label} direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: "2px", bgcolor: item.color }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
<Box>{children}</Box>
|
||||
{caption && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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 { Box, Tooltip, Typography } from "@mui/material";
|
||||
|
||||
import { shortDate } from "../../lib/format";
|
||||
import type { VizPalette } from "../../lib/viz";
|
||||
@@ -30,7 +30,6 @@ export default function FocusBars({
|
||||
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);
|
||||
@@ -84,7 +83,7 @@ export default function FocusBars({
|
||||
borderRadius: "4px 4px 0 0",
|
||||
bgcolor:
|
||||
day.pomodoros === 0
|
||||
? theme.palette.action.disabledBackground
|
||||
? palette.empty
|
||||
: day.goal_met
|
||||
? palette.sequential[4]
|
||||
: palette.sequential[2],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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 { Box, Stack, Tooltip, Typography } from "@mui/material";
|
||||
|
||||
import { weekdayOf } from "../../lib/format";
|
||||
import { rampStep, type VizPalette } from "../../lib/viz";
|
||||
@@ -23,7 +23,6 @@ export default function Heatmap({
|
||||
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.
|
||||
@@ -67,7 +66,7 @@ export default function Heatmap({
|
||||
width: CELL,
|
||||
height: CELL,
|
||||
borderRadius: "3px",
|
||||
bgcolor: fill ?? theme.palette.action.hover,
|
||||
bgcolor: fill ?? palette.empty,
|
||||
outline: fill ? "none" : `1px solid ${palette.grid}`,
|
||||
outlineOffset: "-1px",
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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 { Box, Tooltip, Typography } from "@mui/material";
|
||||
|
||||
import type { VizPalette } from "../../lib/viz";
|
||||
import ChartFrame from "./ChartFrame";
|
||||
@@ -19,7 +19,6 @@ export default function HourHistogram({
|
||||
palette: VizPalette;
|
||||
peak: number | null;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const max = Math.max(1, ...byHour.map((entry) => entry.count));
|
||||
|
||||
return (
|
||||
@@ -37,7 +36,7 @@ export default function HourHistogram({
|
||||
borderRadius: "4px 4px 0 0",
|
||||
bgcolor:
|
||||
entry.count === 0
|
||||
? theme.palette.action.disabledBackground
|
||||
? palette.empty
|
||||
: entry.hour === peak
|
||||
? palette.sequential[4]
|
||||
: palette.sequential[2],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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 { Box, Stack, Tooltip, Typography } from "@mui/material";
|
||||
|
||||
import { KIND_LABEL } from "../../lib/format";
|
||||
import { kindColor, type VizPalette } from "../../lib/viz";
|
||||
@@ -28,12 +28,11 @@ export default function SessionTimeline({
|
||||
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 }} />
|
||||
<Box sx={{ height: LANE, borderRadius: 1, border: "1px dashed", borderColor: "divider" }} />
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +57,7 @@ export default function SessionTimeline({
|
||||
{ label: KIND_LABEL.long_break, color: palette.longBreak },
|
||||
]}
|
||||
>
|
||||
<Box sx={{ position: "relative", height: LANE, borderRadius: 1, bgcolor: theme.palette.action.hover }}>
|
||||
<Box sx={{ position: "relative", height: LANE, borderRadius: 1, bgcolor: palette.empty }}>
|
||||
{done.map((item) => {
|
||||
const start = (hourOf(item.started_at) - first) / span;
|
||||
const width = item.minutes / 60 / span;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const KIND_LABEL: Record<string, string> = {
|
||||
export const BUCKET_LABEL: Record<string, string> = {
|
||||
overdue: "Overdue",
|
||||
today: "Due today",
|
||||
active: "Would be nice to start",
|
||||
upcoming: "Coming up",
|
||||
someday: "No deadline",
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface TimerState {
|
||||
finished?: TimerSession | null;
|
||||
auto_started?: TimerSession | null;
|
||||
celebration?: string | null;
|
||||
timer_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
@@ -50,7 +51,7 @@ export interface Task {
|
||||
stale: boolean;
|
||||
estimated_pomodoros: number | null;
|
||||
days_overdue: number;
|
||||
bucket: "overdue" | "today" | "upcoming" | "someday";
|
||||
bucket: "overdue" | "today" | "active" | "upcoming" | "someday";
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
@@ -64,11 +65,21 @@ export interface RefreshResult {
|
||||
refreshed_at: string;
|
||||
date: string;
|
||||
total: number;
|
||||
sources: { source: string; label: string; ok: boolean; error: string | null; count: number }[];
|
||||
closed: number;
|
||||
sources: {
|
||||
source: string;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
count: number;
|
||||
/** How many tasks this refresh found closed in that tool. */
|
||||
closed?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface BlockerPolicy {
|
||||
active: boolean;
|
||||
unexpandable?: string[];
|
||||
reason: string;
|
||||
mode: string;
|
||||
enabled: boolean;
|
||||
@@ -165,12 +176,13 @@ export interface AppSettings {
|
||||
alarm_sound: boolean;
|
||||
alarm_volume: number;
|
||||
desktop_notifications: boolean;
|
||||
timer_enabled: 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 };
|
||||
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; max_issues: number };
|
||||
google_tasks: { enabled: boolean; client_id: string; client_secret: string; refresh_token: string; lists: string[]; include_undated: boolean };
|
||||
todoist: { enabled: boolean; api_token: string; projects: string[]; filter_query: string };
|
||||
laundry: { enabled: boolean; base_url: string; session_cookie: string };
|
||||
refresh_interval_minutes: number;
|
||||
request_timeout_seconds: number;
|
||||
@@ -181,6 +193,7 @@ export interface AppSettings {
|
||||
block_during_breaks: boolean;
|
||||
blocked_domains: string[];
|
||||
allowed_domains: string[];
|
||||
subdomain_expansions: string[];
|
||||
blocked_apps: string[];
|
||||
kill_blocked_apps: boolean;
|
||||
schedule: { label: string; start: string; end: string; days: number[] }[];
|
||||
|
||||
+23
-18
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
* These are the validated data-viz slots, not the brand accent: the UI is warm
|
||||
* ember on paper, but a chart has to stay readable for colour-blind readers in
|
||||
* both themes, so identity colours come from the reference palette. Focus takes
|
||||
* the orange slot (nearest the brand's warmth), breaks take aqua, and anything
|
||||
* that encodes *magnitude* uses the single-hue blue ramp — this ordering was run
|
||||
* through the palette validator for all pairs in both modes.
|
||||
*/
|
||||
|
||||
export interface VizPalette {
|
||||
@@ -14,28 +16,28 @@ export interface VizPalette {
|
||||
sequential: string[];
|
||||
grid: string;
|
||||
axis: string;
|
||||
surface: string;
|
||||
empty: string;
|
||||
}
|
||||
|
||||
const LIGHT: VizPalette = {
|
||||
work: "#2a78d6",
|
||||
shortBreak: "#eb6834",
|
||||
work: "#eb6834",
|
||||
shortBreak: "#2a78d6",
|
||||
longBreak: "#1baf7a",
|
||||
// blue 100 → 600, light → dark: "more pomodoros" reads as "darker".
|
||||
// blue 100 → 600: "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",
|
||||
grid: "rgba(23, 21, 15, 0.12)",
|
||||
axis: "rgba(23, 21, 15, 0.45)",
|
||||
empty: "rgba(23, 21, 15, 0.07)",
|
||||
};
|
||||
|
||||
const DARK: VizPalette = {
|
||||
work: "#3987e5",
|
||||
shortBreak: "#d95926",
|
||||
work: "#d95926",
|
||||
shortBreak: "#3987e5",
|
||||
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",
|
||||
grid: "rgba(244, 241, 234, 0.14)",
|
||||
axis: "rgba(244, 241, 234, 0.55)",
|
||||
empty: "rgba(244, 241, 234, 0.08)",
|
||||
};
|
||||
|
||||
export function vizPalette(dark: boolean): VizPalette {
|
||||
@@ -48,10 +50,13 @@ export function kindColor(kind: string, palette: VizPalette): string {
|
||||
return palette.shortBreak;
|
||||
}
|
||||
|
||||
/** Pick a ramp step for a count, 0 meaning "empty". */
|
||||
/** Pick a ramp step for a count; null means "nothing happened". */
|
||||
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))));
|
||||
const index = Math.min(
|
||||
palette.sequential.length - 1,
|
||||
Math.max(0, Math.round(ratio * (palette.sequential.length - 1))),
|
||||
);
|
||||
return palette.sequential[index];
|
||||
}
|
||||
+222
-237
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* The blocker's rules and its live state.
|
||||
* The focus guard: what is blocked, when, and what it caught.
|
||||
*
|
||||
* The rules are server-side so every device agrees; the *enforcement* happens in
|
||||
* the native desktop app, which polls the same policy this page edits. The web
|
||||
* UI is honest about that: it shows whether anything is currently enforcing.
|
||||
* Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
|
||||
* expanded server-side into real hostnames — this page shows that expansion, and
|
||||
* says plainly when a pattern (`*gram*`) cannot be expressed that way.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
@@ -22,21 +20,27 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ShieldIcon from "@mui/icons-material/Shield";
|
||||
|
||||
import StatTile from "../components/StatTile";
|
||||
import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
|
||||
import { useResource } from "../lib/hooks";
|
||||
import { useSettings } from "../lib/settings";
|
||||
import type { BlockEvents, BlockerPolicy } from "../lib/types";
|
||||
|
||||
const MASK_HELP = [
|
||||
["youtube.com", "the site itself, plus www. and m."],
|
||||
["*.google.com", "every subdomain in the expansion list below"],
|
||||
["*gram*", "matches apps; a hosts file cannot express it"],
|
||||
];
|
||||
|
||||
export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) {
|
||||
const { settings, defaults, save } = useSettings();
|
||||
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 5000);
|
||||
const policy = useResource<BlockerPolicy & { unexpandable?: string[] }>("/api/blocker/policy", 5000);
|
||||
const events = useResource<BlockEvents>("/api/blocker/events", 20000);
|
||||
|
||||
const [domains, setDomains] = useState("");
|
||||
const [allowed, setAllowed] = useState("");
|
||||
const [apps, setApps] = useState("");
|
||||
const [subdomains, setSubdomains] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,257 +48,238 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
|
||||
setDomains(settings.blocker.blocked_domains.join("\n"));
|
||||
setAllowed(settings.blocker.allowed_domains.join("\n"));
|
||||
setApps(settings.blocker.blocked_apps.join("\n"));
|
||||
setSubdomains((settings.blocker.subdomain_expansions ?? []).join(", "));
|
||||
}, [settings, dirty]);
|
||||
|
||||
const lines = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const lines = (value: string) => value.split("\n").map((item) => item.trim()).filter(Boolean);
|
||||
|
||||
const patch = async (change: Record<string, unknown>) => {
|
||||
const patch = async (change: Record<string, unknown>, message = "Saved") => {
|
||||
try {
|
||||
await save({ blocker: change });
|
||||
await policy.reload();
|
||||
onToast("Blocker updated");
|
||||
onToast(message);
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
}
|
||||
};
|
||||
|
||||
const saveLists = async () => {
|
||||
await patch({
|
||||
blocked_domains: lines(domains),
|
||||
allowed_domains: lines(allowed),
|
||||
blocked_apps: lines(apps),
|
||||
});
|
||||
await patch(
|
||||
{
|
||||
blocked_domains: lines(domains),
|
||||
allowed_domains: lines(allowed),
|
||||
blocked_apps: lines(apps),
|
||||
subdomain_expansions: subdomains.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
},
|
||||
"Lists saved",
|
||||
);
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
const addDefaults = () => {
|
||||
const current = new Set(lines(domains));
|
||||
(defaults?.blocked_domains ?? []).forEach((domain) => current.add(domain));
|
||||
setDomains([...current].join("\n"));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
|
||||
const blocker = settings?.blocker;
|
||||
const unexpandable = policy.data?.unexpandable ?? [];
|
||||
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<ShieldIcon color={policy.data?.active ? "error" : "disabled"} fontSize="large" />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6">
|
||||
{policy.data?.active ? "Blocking is ON" : "Blocking is off"}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{policy.data?.reason}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={blocker?.enabled ?? false}
|
||||
onChange={(event) => void patch({ enabled: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Domains" value={policy.data?.all_domains.length ?? 0} caption="redirected" />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Apps" value={policy.data?.all_apps.length ?? 0} caption="closed on sight" />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Attempts today" value={events.data?.total ?? 0} caption="turned away" />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile
|
||||
label="Mode"
|
||||
value={
|
||||
{ during_work: "Focus", always: "Always", scheduled: "Schedule" }[
|
||||
blocker?.mode ?? "during_work"
|
||||
]
|
||||
}
|
||||
caption="when it applies"
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={policy.data?.reason ?? "—"}
|
||||
title={policy.data?.active ? "Blocking is on" : "Blocking is off"}
|
||||
caption="Rules live here; the desktop app enforces them on your machine."
|
||||
actions={
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={blocker?.enabled ?? false}
|
||||
onChange={(event) => void patch({ enabled: event.target.checked })}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<StatRow>
|
||||
<Stat label="Hostnames" value={policy.data?.all_domains.length ?? 0} caption="after expansion" />
|
||||
<Stat label="Apps" value={policy.data?.all_apps.length ?? 0} caption="closed on sight" />
|
||||
<Stat label="Attempts today" value={events.data?.total ?? 0} caption="turned away" />
|
||||
<Stat
|
||||
label="Mode"
|
||||
value={{ during_work: "Focus", always: "Always", scheduled: "Schedule" }[blocker?.mode ?? "during_work"]}
|
||||
caption="when it applies"
|
||||
size="sm"
|
||||
/>
|
||||
</StatRow>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SectionTitle>When</SectionTitle>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }}>
|
||||
<TextField
|
||||
select
|
||||
label="Mode"
|
||||
sx={{ minWidth: 240 }}
|
||||
value={blocker?.mode ?? "during_work"}
|
||||
onChange={(event) => void patch({ mode: event.target.value })}
|
||||
>
|
||||
<MenuItem value="during_work">While a focus interval runs</MenuItem>
|
||||
<MenuItem value="always">Always</MenuItem>
|
||||
<MenuItem value="scheduled">On a schedule</MenuItem>
|
||||
</TextField>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={blocker?.block_during_breaks ?? false}
|
||||
onChange={(event) => void patch({ block_during_breaks: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Keep blocking through breaks"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={blocker?.kill_blocked_apps ?? true}
|
||||
onChange={(event) => void patch({ kill_blocked_apps: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Close blocked apps (otherwise only warn)"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SectionTitle>Masks</SectionTitle>
|
||||
<Panel sx={{ mb: 3 }}>
|
||||
<Stack divider={<Divider />}>
|
||||
{MASK_HELP.map(([pattern, meaning]) => (
|
||||
<Stack key={pattern} direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ py: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: "monospace", minWidth: 150 }}>
|
||||
{pattern}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{meaning}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Panel>
|
||||
|
||||
{unexpandable.length > 0 && (
|
||||
<Alert severity="info" variant="outlined" sx={{ mb: 3 }}>
|
||||
{unexpandable.join(", ")} {unexpandable.length === 1 ? "is a pattern" : "are patterns"} a hosts file
|
||||
cannot express — {unexpandable.length === 1 ? "it is" : "they are"} still matched against application
|
||||
names.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionTitle>Blocked websites</SectionTitle>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={9}
|
||||
value={domains}
|
||||
placeholder={"youtube.com\n*.reddit.com\ntwitter.com"}
|
||||
onChange={(event) => {
|
||||
setDomains(event.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
onClick={() => {
|
||||
const current = new Set(lines(domains));
|
||||
(defaults?.blocked_domains ?? []).forEach((item) => current.add(item));
|
||||
setDomains([...current].join("\n"));
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
Add the usual suspects
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Enforcement happens in the desktop app — it rewrites <code>/etc/hosts</code> through a
|
||||
privileged helper and closes blocked applications. This page decides <em>what</em> and{" "}
|
||||
<em>when</em>.
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
When to block
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionTitle>Exceptions</SectionTitle>
|
||||
<TextField
|
||||
select
|
||||
label="Mode"
|
||||
sx={{ minWidth: 220 }}
|
||||
value={blocker?.mode ?? "during_work"}
|
||||
onChange={(event) => void patch({ mode: event.target.value })}
|
||||
>
|
||||
<MenuItem value="during_work">While a focus interval runs</MenuItem>
|
||||
<MenuItem value="always">Always</MenuItem>
|
||||
<MenuItem value="scheduled">On a schedule</MenuItem>
|
||||
</TextField>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={blocker?.block_during_breaks ?? false}
|
||||
onChange={(event) => void patch({ block_during_breaks: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Keep blocking during breaks"
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={9}
|
||||
value={allowed}
|
||||
placeholder={"music.youtube.com\n*.docs.google.com"}
|
||||
onChange={(event) => {
|
||||
setAllowed(event.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={blocker?.kill_blocked_apps ?? true}
|
||||
onChange={(event) => void patch({ kill_blocked_apps: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Close blocked apps (otherwise just warn)"
|
||||
/>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={{ height: "100%" }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Blocked websites
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
One hostname per line. <code>www.</code> and <code>m.</code> are added for you.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={8}
|
||||
value={domains}
|
||||
placeholder={"youtube.com\nreddit.com\ntwitter.com"}
|
||||
onChange={(event) => {
|
||||
setDomains(event.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }}
|
||||
/>
|
||||
<Button size="small" sx={{ mt: 1 }} onClick={addDefaults}>
|
||||
Add the usual suspects
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={{ height: "100%" }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Exceptions
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Survive a blocked parent domain — e.g. <code>music.youtube.com</code>.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={8}
|
||||
value={allowed}
|
||||
placeholder="music.youtube.com"
|
||||
onChange={(event) => {
|
||||
setAllowed(event.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={{ height: "100%" }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Blocked applications
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Process names, matched loosely: <code>steam</code> also catches{" "}
|
||||
<code>steamwebhelper</code>.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={8}
|
||||
value={apps}
|
||||
placeholder={"steam\ndiscord\ntelegram-desktop"}
|
||||
onChange={(event) => {
|
||||
setApps(event.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}>
|
||||
Save lists
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!dirty}
|
||||
onClick={() => {
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Today's temptations
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Every attempt the blocker turned away. This is what the daily report teases you about.
|
||||
</Typography>
|
||||
{top.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nothing yet today — either the blocker is off, or you are behaving.
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
|
||||
Masks allowed here too — an exception wins over a block.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider flexItem />}>
|
||||
{top.map((entry) => (
|
||||
<Stack key={entry.target} direction="row" sx={{ py: 1 }} alignItems="center">
|
||||
<Typography sx={{ flexGrow: 1 }}>{entry.target}</Typography>
|
||||
<Chip size="small" label={`× ${entry.count}`} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionTitle>Blocked applications</SectionTitle>
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={9}
|
||||
value={apps}
|
||||
placeholder={"steam*\n*discord*\ntelegram-desktop"}
|
||||
onChange={(event) => {
|
||||
setApps(event.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
|
||||
Matched against the process name and its command line.
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }} sx={{ mt: 3 }}>
|
||||
<TextField
|
||||
label="Subdomains that *.site expands to"
|
||||
fullWidth
|
||||
sx={{ maxWidth: 520 }}
|
||||
value={subdomains}
|
||||
onChange={(event) => {
|
||||
setSubdomains(event.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
helperText="Comma separated. Add the subdomains you actually visit."
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}>
|
||||
Save lists
|
||||
</Button>
|
||||
<Button disabled={!dirty} onClick={() => setDirty(false)}>
|
||||
Discard
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 5 }}>
|
||||
<SectionTitle>Today's temptations</SectionTitle>
|
||||
{top.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nothing yet — either the blocker is off, or you are behaving.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider />}>
|
||||
{top.map((entry) => (
|
||||
<Stack key={entry.target} direction="row" alignItems="center" sx={{ py: 1.25 }}>
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, fontFamily: "monospace" }}>
|
||||
{entry.target}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={entry.count} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,26 +3,13 @@
|
||||
* so the chat and the browser can never disagree.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Divider,
|
||||
Grid,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import { Box, Button, Chip, Divider, Grid, Stack, Tab, Tabs, Typography } from "@mui/material";
|
||||
|
||||
import FocusBars from "../components/charts/FocusBars";
|
||||
import Heatmap from "../components/charts/Heatmap";
|
||||
import HourHistogram from "../components/charts/HourHistogram";
|
||||
import SessionTimeline from "../components/charts/SessionTimeline";
|
||||
import StatTile from "../components/StatTile";
|
||||
import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
|
||||
import { api } from "../lib/api";
|
||||
import { humanMinutes } from "../lib/format";
|
||||
import { useResource } from "../lib/hooks";
|
||||
@@ -62,48 +49,42 @@ export default function ReportPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ flexWrap: "wrap", rowGap: 1 }}>
|
||||
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
||||
{report ? `Report for ${report.date}` : "Report"}
|
||||
</Typography>
|
||||
{daily.data?.sent_at && (
|
||||
<Chip size="small" variant="outlined" label={`sent ${new Date(daily.data.sent_at).toLocaleTimeString()}`} />
|
||||
)}
|
||||
<Button variant="contained" startIcon={<SendIcon />} disabled={sending} onClick={() => void send()}>
|
||||
Send to Telegram
|
||||
</Button>
|
||||
</Stack>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={daily.data?.sent_at ? `sent ${new Date(daily.data.sent_at).toLocaleTimeString()}` : "not sent yet"}
|
||||
title={report ? `Report · ${report.date}` : "Report"}
|
||||
caption={report?.verdict}
|
||||
actions={
|
||||
<Button variant="contained" disabled={sending} onClick={() => void send()}>
|
||||
{sending ? "Sending…" : "Send to Telegram"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile
|
||||
label="Pomodoros"
|
||||
value={metrics ? `${metrics.pomodoros}/${metrics.goal}` : "—"}
|
||||
caption={metrics?.goal_met ? "goal met 🎯" : "goal not met yet"}
|
||||
accent={palette.work}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile
|
||||
label="Focus"
|
||||
value={humanMinutes(metrics?.focus_minutes ?? 0)}
|
||||
caption={`${humanMinutes(metrics?.break_minutes ?? 0)} resting`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Score" value={report ? Math.round(report.score) : "—"} caption="out of 100" />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile
|
||||
label="Streak"
|
||||
value={report ? `${report.streak.current}d` : "—"}
|
||||
caption={report ? `best ${report.streak.best}` : ""}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<StatRow>
|
||||
<Stat
|
||||
label="Pomodoros"
|
||||
value={metrics ? `${metrics.pomodoros}/${metrics.goal}` : "—"}
|
||||
caption={metrics?.goal_met ? "goal met" : "goal not met yet"}
|
||||
accent={palette.work}
|
||||
size="lg"
|
||||
/>
|
||||
<Stat
|
||||
label="Focus"
|
||||
value={humanMinutes(metrics?.focus_minutes ?? 0)}
|
||||
caption={`${humanMinutes(metrics?.break_minutes ?? 0)} resting`}
|
||||
size="lg"
|
||||
/>
|
||||
<Stat label="Score" value={report ? Math.round(report.score) : "—"} caption="out of 100" size="lg" />
|
||||
<Stat
|
||||
label="Streak"
|
||||
value={report ? `${report.streak.current}d` : "—"}
|
||||
caption={report ? `best ${report.streak.best}` : ""}
|
||||
size="lg"
|
||||
/>
|
||||
</StatRow>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid container spacing={3} sx={{ mt: 0 }}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<FocusBars days={stats.data?.range ?? []} goal={metrics?.goal ?? 8} palette={palette} />
|
||||
</Grid>
|
||||
@@ -112,12 +93,16 @@ export default function ReportPage({
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
|
||||
</Box>
|
||||
|
||||
<Heatmap days={stats.data?.heatmap ?? []} goal={metrics?.goal ?? 8} palette={palette} />
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Heatmap days={stats.data?.heatmap ?? []} goal={metrics?.goal ?? 8} palette={palette} />
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Panel>
|
||||
<Tabs value={tab} onChange={(_event, value) => setTab(value)} sx={{ mb: 2 }}>
|
||||
<Tab label={`Achievements (${report?.achievements.length ?? 0})`} />
|
||||
<Tab label="The funny part" />
|
||||
@@ -200,23 +185,18 @@ export default function ReportPage({
|
||||
{(daily.data?.telegram_preview ?? "").replace(/<[^>]+>/g, "")}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Panel>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="All-time pomodoros" value={stats.data?.lifetime.pomodoros ?? 0} />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Hours focused" value={stats.data?.lifetime.focus_hours ?? 0} />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Tasks closed" value={stats.data?.lifetime.tasks_completed ?? 0} />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Blocked attempts" value={stats.data?.lifetime.blocked_attempts ?? 0} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SectionTitle>All time</SectionTitle>
|
||||
<StatRow>
|
||||
<Stat label="Pomodoros" value={stats.data?.lifetime.pomodoros ?? 0} />
|
||||
<Stat label="Hours focused" value={stats.data?.lifetime.focus_hours ?? 0} />
|
||||
<Stat label="Tasks closed" value={stats.data?.lifetime.tasks_completed ?? 0} />
|
||||
<Stat label="Blocked" value={stats.data?.lifetime.blocked_attempts ?? 0} />
|
||||
</StatRow>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
@@ -22,8 +19,8 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
|
||||
import { api, getToken, setToken } from "../lib/api";
|
||||
import { useSettings } from "../lib/settings";
|
||||
import type { AppSettings } from "../lib/types";
|
||||
@@ -59,7 +56,13 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Shared with the desktop app and the Telegram bot"
|
||||
title="Settings"
|
||||
caption="Secrets are masked once stored — leave the dots alone to keep the saved value."
|
||||
/>
|
||||
<Stack spacing={5}>
|
||||
<Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot.">
|
||||
<Grid container spacing={2}>
|
||||
{(
|
||||
@@ -85,6 +88,7 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
<Stack direction="row" sx={{ flexWrap: "wrap", mt: 1 }}>
|
||||
{(
|
||||
[
|
||||
["timer_enabled", "Timer enabled (off = view only)"],
|
||||
["auto_start_breaks", "Auto-start breaks"],
|
||||
["auto_start_work", "Auto-start next focus"],
|
||||
["allow_pause", "Allow pausing"],
|
||||
@@ -159,17 +163,6 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
value={settings.sources.jira.start_date_field}
|
||||
onCommit={(value) => void patch({ sources: { jira: { start_date_field: value } } })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.sources.jira.writeback_done}
|
||||
onChange={(event) =>
|
||||
void patch({ sources: { jira: { writeback_done: event.target.checked } } })
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Completing a task here transitions the Jira issue to Done"
|
||||
/>
|
||||
</SourceBlock>
|
||||
|
||||
<SourceBlock
|
||||
@@ -443,13 +436,6 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
<MenuItem value="light">Light</MenuItem>
|
||||
<MenuItem value="dark">Dark</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Accent colour"
|
||||
type="color"
|
||||
sx={{ width: 120 }}
|
||||
value={settings.appearance.accent}
|
||||
onChange={(event) => void patch({ appearance: { accent: event.target.value } })}
|
||||
/>
|
||||
<TextField
|
||||
label="API token (this browser)"
|
||||
type="password"
|
||||
@@ -462,12 +448,13 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Alert severity="info" variant="outlined" sx={{ mt: 2 }}>
|
||||
Website and application blocking is enforced by the native desktop app — install it from{" "}
|
||||
<code>desktop/</code>, then its lists live on the <strong>Blocker</strong> page.
|
||||
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
|
||||
</Alert>
|
||||
</Section>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -481,23 +468,15 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Accordion defaultExpanded disableGutters sx={{ borderRadius: 3, "&::before": { display: "none" } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={1.5}>{children}</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Box>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack spacing={2}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -518,7 +497,7 @@ function SourceBlock({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ border: 1, borderColor: "divider", borderRadius: 3, p: 2 }}>
|
||||
<Panel>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: enabled ? 1.5 : 0 }}>
|
||||
<Typography sx={{ fontWeight: 600, flexGrow: 1 }}>{label}</Typography>
|
||||
{enabled && <Chip size="small" color="success" variant="outlined" label="on" />}
|
||||
@@ -532,7 +511,7 @@ function SourceBlock({
|
||||
{children}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+168
-153
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Everything you owe, from every source, in one list.
|
||||
* Everything you owe, from every source, grouped by deadline.
|
||||
*
|
||||
* Grouped by deadline rather than by source, because "what is late" is the
|
||||
* question you actually have; the source is a chip on the row. Ticking a task
|
||||
* closes it in its own system too, and says so when that write-back fails.
|
||||
* There is no checkbox. A task is closed in Jira, Todoist or Google Tasks —
|
||||
* wherever it lives — and the next refresh notices and credits it to the day.
|
||||
* Two places to tick something off is two places to be wrong.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -11,9 +11,6 @@ import {
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Collapse,
|
||||
Dialog,
|
||||
@@ -31,30 +28,36 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
|
||||
import StarIcon from "@mui/icons-material/Star";
|
||||
import StarBorderIcon from "@mui/icons-material/StarBorder";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
|
||||
import { api } from "../lib/api";
|
||||
import { BUCKET_LABEL } from "../lib/format";
|
||||
import { useResource } from "../lib/hooks";
|
||||
import type { RefreshResult, Task, TaskListResponse } from "../lib/types";
|
||||
|
||||
const BUCKETS: Task["bucket"][] = ["overdue", "today", "upcoming", "someday"];
|
||||
const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"];
|
||||
const BUCKET_COLOR: Record<string, string> = {
|
||||
overdue: "error.main",
|
||||
today: "warning.main",
|
||||
active: "success.main",
|
||||
upcoming: "text.disabled",
|
||||
someday: "divider",
|
||||
};
|
||||
|
||||
export default function TasksPage({ onToast }: { onToast: (message: string) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [draft, setDraft] = useState({ title: "", due_date: "", estimated_pomodoros: "" });
|
||||
const [draft, setDraft] = useState({ title: "", due_date: "" });
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
@@ -63,10 +66,11 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
setSourceStatus(result.sources);
|
||||
await tasks.reload();
|
||||
const failed = result.sources.filter((item) => !item.ok);
|
||||
const closed = result.sources.reduce((sum, item) => sum + ((item as { closed?: number }).closed ?? 0), 0);
|
||||
onToast(
|
||||
failed.length
|
||||
? `${failed.length} source(s) failed — see the banner`
|
||||
: `Pulled ${result.total} tasks from ${result.sources.length} source(s)`,
|
||||
? `${failed.length} source(s) failed — see above`
|
||||
: `${result.total} open · ${closed} closed since last refresh`,
|
||||
);
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
@@ -75,43 +79,54 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDone = async (task: Task, done: boolean) => {
|
||||
try {
|
||||
const result = await api.post<{ warning: string | null }>(`/api/tasks/${task.id}/done`, { done });
|
||||
await tasks.reload();
|
||||
onToast(result.warning ?? (done ? `Closed "${task.title}"` : "Reopened"));
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
}
|
||||
};
|
||||
|
||||
const togglePin = async (task: Task) => {
|
||||
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
|
||||
await tasks.reload();
|
||||
};
|
||||
|
||||
const startOn = async (task: Task) => {
|
||||
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
|
||||
onToast(`Focusing on "${task.title}"`);
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const all = tasks.data?.tasks ?? [];
|
||||
return filter === "all" ? all : all.filter((task) => task.source === filter);
|
||||
}, [tasks.data, filter]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, Task[]> = { overdue: [], today: [], upcoming: [], someday: [] };
|
||||
for (const task of visible) map[task.bucket].push(task);
|
||||
const map: Record<string, Task[]> = { overdue: [], today: [], active: [], upcoming: [], someday: [] };
|
||||
for (const task of visible) {
|
||||
const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday";
|
||||
map[bucket].push(task);
|
||||
}
|
||||
return map;
|
||||
}, [visible]);
|
||||
|
||||
const failed = sourceStatus?.filter((item) => !item.ok) ?? [];
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="From Jira, Todoist, Google Tasks and your own list"
|
||||
title="Tasks"
|
||||
caption="Close them where they live — the next refresh credits them to today."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outlined" onClick={() => setAddOpen(true)}>
|
||||
Add
|
||||
</Button>
|
||||
<Button variant="contained" disabled={refreshing} onClick={() => void refresh()}>
|
||||
{refreshing ? "Refreshing…" : "Refresh sources"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{refreshing && <LinearProgress sx={{ mb: 2 }} />}
|
||||
|
||||
<Collapse in={failed.length > 0}>
|
||||
<Alert severity="warning" variant="outlined" sx={{ mb: 3 }} onClose={() => setSourceStatus(null)}>
|
||||
<AlertTitle sx={{ fontWeight: 600 }}>Some sources could not be read</AlertTitle>
|
||||
{failed.map((item) => (
|
||||
<Typography key={item.source} variant="body2">
|
||||
<strong>{item.label}:</strong> {item.error}
|
||||
</Typography>
|
||||
))}
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 3, flexWrap: "wrap", rowGap: 1 }}>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
@@ -126,78 +141,60 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>
|
||||
Add task
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<RefreshIcon />} disabled={refreshing} onClick={() => void refresh()}>
|
||||
Refresh sources
|
||||
</Button>
|
||||
{sourceStatus && failed.length === 0 && (
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
|
||||
{sourceStatus.map((item) => (
|
||||
<Chip key={item.source} size="small" variant="outlined" label={`${item.label} ${item.count}`} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{refreshing && <LinearProgress />}
|
||||
|
||||
<Collapse in={failed.length > 0}>
|
||||
<Alert severity="warning" onClose={() => setSourceStatus(null)}>
|
||||
<AlertTitle>Some sources could not be read</AlertTitle>
|
||||
{failed.map((item) => (
|
||||
<Typography key={item.source} variant="body2">
|
||||
<strong>{item.label}:</strong> {item.error}
|
||||
</Typography>
|
||||
))}
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
{sourceStatus && failed.length === 0 && (
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}>
|
||||
{sourceStatus.map((item) => (
|
||||
<Chip key={item.source} size="small" color="success" variant="outlined" label={`${item.label}: ${item.count}`} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tasks.data && visible.length === 0 && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Nothing on the list</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Connect Jira, Todoist or Google Tasks in Settings → Sources, or add something yourself.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Panel>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Nothing on the list
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself.
|
||||
</Typography>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{BUCKETS.map((bucket) =>
|
||||
grouped[bucket].length ? (
|
||||
<Card key={bucket}>
|
||||
<CardContent>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
<Box key={bucket} sx={{ mb: 4 }}>
|
||||
<SectionTitle
|
||||
action={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{grouped[bucket].length}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={grouped[bucket].length}
|
||||
color={bucket === "overdue" ? "error" : bucket === "today" ? "warning" : "default"}
|
||||
}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</SectionTitle>
|
||||
<Stack divider={<Divider />}>
|
||||
{grouped[bucket].map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onPin={async () => {
|
||||
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
|
||||
await tasks.reload();
|
||||
}}
|
||||
onStart={async () => {
|
||||
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
|
||||
onToast(`Focusing on “${task.title}”`);
|
||||
navigate("/");
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await api.del(`/api/tasks/${task.id}`);
|
||||
await tasks.reload();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack divider={<Divider flexItem />}>
|
||||
{grouped[bucket].map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggle={toggleDone}
|
||||
onPin={togglePin}
|
||||
onStart={startOn}
|
||||
onDelete={async () => {
|
||||
await api.del(`/api/tasks/${task.id}`);
|
||||
await tasks.reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
@@ -207,7 +204,7 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
label="Title"
|
||||
label="What needs doing?"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
@@ -218,12 +215,9 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
value={draft.due_date}
|
||||
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Estimated pomodoros"
|
||||
type="number"
|
||||
value={draft.estimated_pomodoros}
|
||||
onChange={(event) => setDraft({ ...draft, estimated_pomodoros: event.target.value })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Local tasks live only here, so they are the one kind you can delete.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -235,9 +229,8 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
await api.post("/api/tasks", {
|
||||
title: draft.title.trim(),
|
||||
due_date: draft.due_date || null,
|
||||
estimated_pomodoros: draft.estimated_pomodoros ? Number(draft.estimated_pomodoros) : null,
|
||||
});
|
||||
setDraft({ title: "", due_date: "", estimated_pomodoros: "" });
|
||||
setDraft({ title: "", due_date: "" });
|
||||
setAddOpen(false);
|
||||
await tasks.reload();
|
||||
}}
|
||||
@@ -246,71 +239,93 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
onToggle,
|
||||
onPin,
|
||||
onStart,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Task;
|
||||
onToggle: (task: Task, done: boolean) => Promise<void>;
|
||||
onPin: (task: Task) => Promise<void>;
|
||||
onStart: (task: Task) => Promise<void>;
|
||||
onPin: () => Promise<void>;
|
||||
onStart: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ py: 1 }}>
|
||||
<Checkbox checked={task.is_done} onChange={(event) => void onToggle(task, event.target.checked)} />
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
sx={{ py: 1.5, "&:hover .row-actions": { opacity: 1 } }}
|
||||
>
|
||||
<Box sx={{ width: 2, alignSelf: "stretch", bgcolor: BUCKET_COLOR[task.bucket] ?? BUCKET_COLOR.someday, borderRadius: 1 }} />
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ textDecoration: task.is_done ? "line-through" : "none", opacity: task.is_done ? 0.6 : 1 }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body1" noWrap sx={{ fontWeight: task.pinned ? 600 : 400 }}>
|
||||
{task.title}
|
||||
</Typography>
|
||||
{task.url && (
|
||||
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex" }}>
|
||||
<OpenInNewIcon fontSize="inherit" />
|
||||
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex", color: "text.disabled" }}>
|
||||
<OpenInNewIcon sx={{ fontSize: 14 }} />
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 0.5, flexWrap: "wrap", rowGap: 0.5 }}>
|
||||
<Chip size="small" variant="outlined" label={task.source_label} />
|
||||
{task.project && <Chip size="small" variant="outlined" label={task.project} />}
|
||||
{task.priority && <Chip size="small" variant="outlined" label={task.priority} />}
|
||||
{task.status && <Chip size="small" variant="outlined" label={task.status} />}
|
||||
{task.days_overdue > 0 && (
|
||||
<Chip size="small" color="error" label={`${task.days_overdue}d overdue`} />
|
||||
<Stack direction="row" spacing={1.5} sx={{ mt: 0.25, flexWrap: "wrap", rowGap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.source_label}
|
||||
</Typography>
|
||||
{task.project && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.project}
|
||||
</Typography>
|
||||
)}
|
||||
{task.due_date && task.days_overdue === 0 && (
|
||||
<Chip size="small" variant="outlined" label={task.due_date} />
|
||||
{task.status && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.status}
|
||||
</Typography>
|
||||
)}
|
||||
{task.priority && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.priority}
|
||||
</Typography>
|
||||
)}
|
||||
{task.days_overdue > 0 ? (
|
||||
<Typography variant="caption" color="error.main" sx={{ fontWeight: 600 }}>
|
||||
{task.days_overdue}d overdue
|
||||
</Typography>
|
||||
) : (
|
||||
task.due_date && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
due {task.due_date}
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
{task.stale && <Chip size="small" color="warning" variant="outlined" label="gone from source" />}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
|
||||
<IconButton size="small" onClick={() => void onPin(task)}>
|
||||
{task.pinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Start a pomodoro on this">
|
||||
<IconButton size="small" color="primary" onClick={() => void onStart(task)}>
|
||||
<PlayArrowIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{task.source === "local" && (
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => void onDelete()}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
|
||||
<Stack direction="row" spacing={0.5} className="row-actions" sx={{ opacity: { xs: 1, md: 0.25 }, transition: "opacity .15s" }}>
|
||||
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
|
||||
<IconButton size="small" onClick={() => void onPin()}>
|
||||
{task.pinned ? <StarIcon sx={{ fontSize: 17 }} /> : <StarBorderIcon sx={{ fontSize: 17 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Start a pomodoro on this">
|
||||
<IconButton size="small" onClick={() => void onStart()}>
|
||||
<PlayArrowOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{task.source === "local" && (
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => void onDelete()}>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+236
-251
@@ -2,9 +2,9 @@
|
||||
* The timer.
|
||||
*
|
||||
* The server owns the clock, so this page polls `/api/timer/state` and ticks
|
||||
* locally in between: the countdown is smooth, and a reload (or the desktop app
|
||||
* taking over) picks up exactly where the browser left off. When the local
|
||||
* countdown reaches zero it tells the server to complete the interval — the
|
||||
* locally in between: the countdown is smooth, and a reload — or the desktop
|
||||
* app taking over — picks up exactly where the browser left off. When the local
|
||||
* countdown reaches zero it tells the server to complete the interval; the
|
||||
* server decides what comes next.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
@@ -22,23 +19,18 @@ import {
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import AddAlarmIcon from "@mui/icons-material/AddAlarm";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import CoffeeIcon from "@mui/icons-material/Coffee";
|
||||
import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
|
||||
import PauseIcon from "@mui/icons-material/Pause";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import StopIcon from "@mui/icons-material/Stop";
|
||||
import AddAlarmOutlinedIcon from "@mui/icons-material/AddAlarmOutlined";
|
||||
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
|
||||
import StopOutlinedIcon from "@mui/icons-material/StopOutlined";
|
||||
|
||||
import SessionTimeline from "../components/charts/SessionTimeline";
|
||||
import StatTile from "../components/StatTile";
|
||||
import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
|
||||
import TimerDial from "../components/TimerDial";
|
||||
import { api } from "../lib/api";
|
||||
import { KIND_LABEL, clock, humanMinutes } from "../lib/format";
|
||||
@@ -55,12 +47,12 @@ export default function TimerPage({
|
||||
onToast: (message: string) => void;
|
||||
dark: boolean;
|
||||
}) {
|
||||
const { settings } = useSettings();
|
||||
const { settings, save } = useSettings();
|
||||
const palette = useMemo(() => vizPalette(dark), [dark]);
|
||||
|
||||
const timer = useResource<TimerState>("/api/timer/state", 10000);
|
||||
const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
|
||||
const today = useResource<{ sessions: unknown[]; metrics: DayMetrics }>("/api/timer/sessions", 30000);
|
||||
const today = useResource<{ metrics: DayMetrics }>("/api/timer/sessions", 30000);
|
||||
|
||||
const [abandonOpen, setAbandonOpen] = useState(false);
|
||||
const [abandonReason, setAbandonReason] = useState("");
|
||||
@@ -70,27 +62,23 @@ export default function TimerPage({
|
||||
const state = timer.data;
|
||||
const current = state?.current ?? null;
|
||||
const running = current?.status === "running";
|
||||
const timerEnabled = settings?.pomodoro.timer_enabled ?? true;
|
||||
|
||||
// Local countdown between polls.
|
||||
const tick = useTick(Boolean(current));
|
||||
const anchor = useRef<{ at: number; remaining: number; id: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (current) anchor.current = { at: Date.now(), remaining: current.remaining_seconds, id: current.id };
|
||||
else anchor.current = null;
|
||||
anchor.current = current
|
||||
? { at: Date.now(), remaining: current.remaining_seconds, id: current.id }
|
||||
: null;
|
||||
}, [current?.id, current?.remaining_seconds, current?.status]);
|
||||
|
||||
const remaining = useMemo(() => {
|
||||
if (!current) return 0;
|
||||
if (!anchor.current || anchor.current.id !== current.id) return current.remaining_seconds;
|
||||
if (current.status === "paused") return anchor.current.remaining;
|
||||
const drift = Math.floor((Date.now() - anchor.current.at) / 1000);
|
||||
return Math.max(0, anchor.current.remaining - drift);
|
||||
return Math.max(0, anchor.current.remaining - Math.floor((Date.now() - anchor.current.at) / 1000));
|
||||
}, [current, tick]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([timer.reload(), today.reload()]);
|
||||
}, [timer, today]);
|
||||
|
||||
const call = useCallback(
|
||||
async (path: string, body?: unknown, successToast?: string) => {
|
||||
setBusy(true);
|
||||
@@ -110,7 +98,6 @@ export default function TimerPage({
|
||||
[onToast, timer, today],
|
||||
);
|
||||
|
||||
// When the countdown hits zero, hand it to the server and announce it.
|
||||
const firing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!current || remaining > 0 || firing.current) return;
|
||||
@@ -123,12 +110,9 @@ export default function TimerPage({
|
||||
wasWork ? sound.workDone(volume) : sound.breakDone(volume);
|
||||
}
|
||||
if (settings?.pomodoro.desktop_notifications ?? true) {
|
||||
notify(
|
||||
wasWork ? "Pomodoro complete 🍅" : "Break over ☕",
|
||||
wasWork ? "Time for a break." : "Back to it.",
|
||||
);
|
||||
notify(wasWork ? "Pomodoro complete" : "Break over", wasWork ? "Time for a break." : "Back to it.");
|
||||
}
|
||||
if (next?.celebration) onToast("Milestone reached — check Telegram 🎉");
|
||||
if (next?.celebration) onToast("Milestone reached — Telegram got a cheer");
|
||||
firing.current = false;
|
||||
})();
|
||||
}, [remaining, current, call, settings, onToast]);
|
||||
@@ -138,7 +122,7 @@ export default function TimerPage({
|
||||
await call(
|
||||
"/api/timer/start",
|
||||
{ kind, task_id: kind === "work" && selectedTask !== "" ? selectedTask : undefined },
|
||||
kind === "work" ? "Focus interval started" : "Break started",
|
||||
kind === "work" ? "Focus started" : "Break started",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -150,243 +134,245 @@ export default function TimerPage({
|
||||
|
||||
const kind = current?.kind ?? state?.next_kind ?? "work";
|
||||
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
|
||||
const accent = kindColor(kind, palette);
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={7}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: "center", pb: 3 }}>
|
||||
<TimerDial
|
||||
remaining={current ? remaining : total}
|
||||
total={total}
|
||||
label={current ? KIND_LABEL[current.kind] : `Next: ${KIND_LABEL[kind]}`}
|
||||
sublabel={current?.task?.title ?? (current ? undefined : "Press start when you are ready")}
|
||||
color={kindColor(kind, palette)}
|
||||
paused={current?.status === "paused"}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3, flexWrap: "wrap", rowGap: 1 }}>
|
||||
{!current && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => void start("work")}
|
||||
>
|
||||
Start focus
|
||||
</Button>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={new Date().toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" })}
|
||||
title={!timerEnabled ? "View mode" : (current ? KIND_LABEL[current.kind] : "Ready when you are")}
|
||||
caption={
|
||||
!timerEnabled
|
||||
? "Timer is turned off · spectator mode"
|
||||
: (current?.task
|
||||
? `Tracking “${current.task.title}”`
|
||||
: `Cycle ${state?.cycle_position ?? 1} of ${state?.cycles_before_long_break ?? 4} before a long break`)
|
||||
}
|
||||
actions={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await save({ pomodoro: { timer_enabled: !timerEnabled } });
|
||||
onToast(!timerEnabled ? "Timer enabled" : "Switched to view-only mode");
|
||||
} catch (exc) {
|
||||
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{timerEnabled ? "Turn off timer" : "Turn on timer"}
|
||||
</Button>
|
||||
{timerEnabled && (
|
||||
current ? (
|
||||
<>
|
||||
{running ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<CoffeeIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => void start(state?.next_kind === "long_break" ? "long_break" : "short_break")}
|
||||
disabled={busy || !(settings?.pomodoro.allow_pause ?? true)}
|
||||
onClick={() => void call("/api/timer/pause")}
|
||||
>
|
||||
{state?.next_kind === "long_break" ? "Long break" : "Short break"}
|
||||
Pause
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{current && (
|
||||
<>
|
||||
{running ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<PauseIcon />}
|
||||
disabled={busy || !(settings?.pomodoro.allow_pause ?? true)}
|
||||
onClick={() => void call("/api/timer/pause")}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => void call("/api/timer/resume")}
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="large"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval completed")}
|
||||
>
|
||||
Done
|
||||
) : (
|
||||
<Button variant="outlined" disabled={busy} onClick={() => void call("/api/timer/resume")}>
|
||||
Resume
|
||||
</Button>
|
||||
<Tooltip title="Add five minutes">
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => void call("/api/timer/extend", { minutes: 5 })}
|
||||
>
|
||||
<AddAlarmIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Count an interruption without stopping">
|
||||
<IconButton disabled={busy} onClick={() => void call("/api/timer/interrupt")}>
|
||||
<NotificationsActiveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Abandon this interval">
|
||||
<IconButton color="error" disabled={busy} onClick={() => setAbandonOpen(true)}>
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={busy}
|
||||
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval finished")}
|
||||
>
|
||||
Finish
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="contained" disabled={busy} onClick={() => void start("work")}>
|
||||
Start focus
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={busy}
|
||||
onClick={() => void start(state?.next_kind === "long_break" ? "long_break" : "short_break")}
|
||||
>
|
||||
{state?.next_kind === "long_break" ? "Long break" : "Short break"}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
{current && current.interruptions > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
|
||||
{current.interruptions} interruption{current.interruptions === 1 ? "" : "s"} this interval
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={7}>
|
||||
<Panel sx={{ py: { xs: 4, md: 5 } }}>
|
||||
<TimerDial
|
||||
remaining={timerEnabled ? (current ? remaining : total) : 0}
|
||||
total={total}
|
||||
label={!timerEnabled ? "View mode" : (current ? KIND_LABEL[current.kind] : `Next · ${KIND_LABEL[kind]}`)}
|
||||
sublabel={!timerEnabled ? "Timer disabled" : (current?.task?.title ?? (current ? undefined : "Pick something below, or just begin"))}
|
||||
color={timerEnabled ? accent : palette.axis}
|
||||
paused={current?.status === "paused"}
|
||||
idle={!timerEnabled || !current}
|
||||
/>
|
||||
|
||||
{current && timerEnabled && (
|
||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
|
||||
<Tooltip title="Add five minutes">
|
||||
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/extend", { minutes: 5 })}>
|
||||
<AddAlarmOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Count an interruption without stopping the clock">
|
||||
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/interrupt")}>
|
||||
<NotificationsNoneOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Abandon this interval">
|
||||
<IconButton size="small" disabled={busy} onClick={() => setAbandonOpen(true)}>
|
||||
<StopOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{current && current.interruptions > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" align="center" sx={{ display: "block", mt: 1.5 }}>
|
||||
{current.interruptions} interruption{current.interruptions === 1 ? "" : "s"} so far
|
||||
</Typography>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={5}>
|
||||
<Stack spacing={3} sx={{ height: "100%" }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Today
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1}>
|
||||
<Typography variant="h4" sx={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{doneToday}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
/ {goal} pomodoros
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(100, goal ? (doneToday / goal) * 100 : 0)}
|
||||
sx={{ height: 8, borderRadius: 4, my: 1.5 }}
|
||||
/>
|
||||
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap", rowGap: 0.75 }}>
|
||||
{Array.from({ length: Math.max(goal, doneToday) }, (_unused, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: "50%",
|
||||
bgcolor: index < doneToday ? palette.work : "action.hover",
|
||||
outline: index < doneToday ? "none" : `1px solid ${palette.grid}`,
|
||||
outlineOffset: "-1px",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{metrics && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
{humanMinutes(metrics.focus_minutes)} focused · {humanMinutes(metrics.break_minutes)} rested
|
||||
{metrics.longest_stretch_minutes > 0 &&
|
||||
` · longest stretch ${humanMinutes(metrics.longest_stretch_minutes)}`}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Cycle {state?.cycle_position ?? 1} of {state?.cycles_before_long_break ?? 4} before a long break
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ flexGrow: 1 }}>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
What are you working on?
|
||||
</Typography>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
value={selectedTask}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value === "" ? "" : Number(event.target.value);
|
||||
setSelectedTask(value);
|
||||
if (current) void call("/api/timer/task", { task_id: value === "" ? null : value });
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<SectionTitle>Today</SectionTitle>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: (theme) => theme.typography.h1.fontFamily,
|
||||
fontSize: 44,
|
||||
lineHeight: 1,
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
helperText={
|
||||
current?.task
|
||||
? `This interval is tracked against "${current.task.title}"`
|
||||
: "Pick a task so the report knows where the time went"
|
||||
>
|
||||
{doneToday}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
of {goal} pomodoros
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap", rowGap: 0.75, mt: 2 }}>
|
||||
{Array.from({ length: Math.max(goal, doneToday) }, (_unused, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
width: 11,
|
||||
height: 11,
|
||||
borderRadius: "50%",
|
||||
bgcolor: index < doneToday ? palette.work : "transparent",
|
||||
border: index < doneToday ? "none" : "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{metrics && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
{humanMinutes(metrics.focus_minutes)} focused · {humanMinutes(metrics.break_minutes)} rested
|
||||
{metrics.longest_stretch_minutes > 0 &&
|
||||
` · longest run ${humanMinutes(metrics.longest_stretch_minutes)}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Working on</SectionTitle>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
SelectProps={{ displayEmpty: true }}
|
||||
value={selectedTask}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value === "" ? "" : Number(event.target.value);
|
||||
setSelectedTask(value);
|
||||
if (current) void call("/api/timer/task", { task_id: value === "" ? null : value });
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Nothing in particular</em>
|
||||
</MenuItem>
|
||||
{openTasks.slice(0, 60).map((task) => (
|
||||
<MenuItem key={task.id} value={task.id}>
|
||||
{task.pinned ? "★ " : ""}
|
||||
{task.title}
|
||||
{task.due_date ? ` · ${task.due_date}` : ""}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{suggestion && selectedTask === "" && (
|
||||
<Alert
|
||||
severity={suggestion.days_overdue > 0 ? "warning" : "info"}
|
||||
variant="outlined"
|
||||
sx={{ mt: 1.5 }}
|
||||
action={
|
||||
<Button size="small" onClick={() => setSelectedTask(suggestion.id)}>
|
||||
Use
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>No task</em>
|
||||
</MenuItem>
|
||||
{openTasks.slice(0, 50).map((task) => (
|
||||
<MenuItem key={task.id} value={task.id}>
|
||||
{task.pinned ? "📌 " : ""}
|
||||
{task.title}
|
||||
{task.due_date ? ` · ${task.due_date}` : ""}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{suggestion.days_overdue > 0 ? "Most overdue: " : "Next up: "}
|
||||
<strong>{suggestion.title}</strong>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{suggestion && selectedTask === "" && (
|
||||
<Alert
|
||||
severity={suggestion.days_overdue > 0 ? "warning" : "info"}
|
||||
sx={{ mt: 2 }}
|
||||
action={
|
||||
<Button size="small" onClick={() => setSelectedTask(suggestion.id)}>
|
||||
Use
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Suggested: <strong>{suggestion.title}</strong>
|
||||
{suggestion.days_overdue > 0 ? ` — ${suggestion.days_overdue} days overdue` : ""}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}>
|
||||
<Chip size="small" label={`${tasks.data?.counts.overdue ?? 0} overdue`} color="error" variant="outlined" />
|
||||
<Chip size="small" label={`${tasks.data?.counts.today ?? 0} due today`} color="warning" variant="outlined" />
|
||||
<Chip size="small" label={`${tasks.data?.counts.upcoming ?? 0} upcoming`} variant="outlined" />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 2 }} divider={<Divider flexItem orientation="vertical" />}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{tasks.data?.counts.overdue ?? 0} overdue
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{tasks.data?.counts.today ?? 0} due today
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{tasks.data?.counts.upcoming ?? 0} upcoming
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Focus today" value={humanMinutes(metrics?.focus_minutes ?? 0)} caption={`${metrics?.pomodoros ?? 0} pomodoros`} />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<StatRow>
|
||||
<Stat label="Focus today" value={humanMinutes(metrics?.focus_minutes ?? 0)} caption={`${metrics?.pomodoros ?? 0} intervals`} />
|
||||
<Stat
|
||||
label="Interruptions"
|
||||
value={metrics?.interruptions ?? 0}
|
||||
caption={metrics?.abandoned ? `${metrics.abandoned} abandoned` : "none dropped"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Tasks closed" value={metrics?.tasks_completed_count ?? 0} caption="today" />
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<StatTile label="Blocked attempts" value={metrics?.blocked_attempts ?? 0} caption="temptations turned away" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Stat label="Closed today" value={metrics?.tasks_completed_count ?? 0} caption="in your own tools" />
|
||||
<Stat label="Blocked" value={metrics?.blocked_attempts ?? 0} caption="temptations turned away" />
|
||||
</StatRow>
|
||||
</Box>
|
||||
|
||||
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
|
||||
</Box>
|
||||
|
||||
<Dialog open={abandonOpen} onClose={() => setAbandonOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>Abandon this interval?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{clock(remaining)} left. {settings?.pomodoro.strict_mode ? "Strict mode wants a reason." : "A reason is optional."}
|
||||
{clock(remaining)} left.{" "}
|
||||
{settings?.pomodoro.strict_mode ? "Strict mode wants a reason." : "A reason is optional."}
|
||||
</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
@@ -405,7 +391,6 @@ export default function TimerPage({
|
||||
if (result) {
|
||||
setAbandonOpen(false);
|
||||
setAbandonReason("");
|
||||
void refreshAll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -413,17 +398,17 @@ export default function TimerPage({
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overdue first, then due today, then whatever is pinned — the obvious next thing. */
|
||||
/** Pinned first, then the most overdue, then what is due today. */
|
||||
function pickSuggestion(tasks: Task[]): Task | null {
|
||||
const pinned = tasks.find((task) => task.pinned);
|
||||
if (pinned) return pinned;
|
||||
const overdue = tasks.filter((task) => task.bucket === "overdue");
|
||||
if (overdue.length) return overdue[0];
|
||||
const dueToday = tasks.filter((task) => task.bucket === "today");
|
||||
if (dueToday.length) return dueToday[0];
|
||||
return tasks[0] ?? null;
|
||||
return (
|
||||
tasks.find((task) => task.pinned) ??
|
||||
tasks.find((task) => task.bucket === "overdue") ??
|
||||
tasks.find((task) => task.bucket === "today") ??
|
||||
tasks[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
+100
-36
@@ -1,61 +1,125 @@
|
||||
/**
|
||||
* Material theme.
|
||||
* The MUI theme, built from the tokens.
|
||||
*
|
||||
* 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).
|
||||
* Material components are used for their behaviour, not their chrome: shadows
|
||||
* are off, radii are small, surfaces are separated by hairlines, and type is the
|
||||
* Fraunces/Inter pairing. The accent is a fixed ember — it is a brand colour,
|
||||
* not a user setting, because the warm palette is the design.
|
||||
*/
|
||||
import { alpha, createTheme, type Theme } from "@mui/material/styles";
|
||||
import { createTheme, type Theme } from "@mui/material/styles";
|
||||
|
||||
import { DISPLAY_FONT, SANS_FONT, tokensFor } from "./tokens";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export function buildTheme(mode: ThemeMode, seed: string, compact: boolean): Theme {
|
||||
const dark = mode === "dark";
|
||||
export function buildTheme(mode: ThemeMode, compact: boolean): Theme {
|
||||
const t = tokensFor(mode);
|
||||
|
||||
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",
|
||||
},
|
||||
primary: { main: t.ember, contrastText: mode === "dark" ? "#16140F" : "#FFFFFF" },
|
||||
secondary: { main: t.pine },
|
||||
success: { main: t.pine },
|
||||
warning: { main: t.amber },
|
||||
error: { main: t.clay },
|
||||
text: { primary: t.ink, secondary: t.inkMuted, disabled: t.inkFaint },
|
||||
divider: t.rule,
|
||||
background: { default: t.paper, paper: t.surface },
|
||||
},
|
||||
shape: { borderRadius: 16 },
|
||||
spacing: compact ? 6 : 8,
|
||||
shape: { borderRadius: 10 },
|
||||
spacing: compact ? 7 : 8,
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Segoe UI", system-ui, sans-serif',
|
||||
h4: { fontWeight: 500, letterSpacing: "-0.01em" },
|
||||
h5: { fontWeight: 500 },
|
||||
h6: { fontWeight: 500 },
|
||||
fontFamily: SANS_FONT,
|
||||
h1: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.02em" },
|
||||
h2: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.02em" },
|
||||
h3: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.015em" },
|
||||
h4: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.015em" },
|
||||
h5: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.01em" },
|
||||
h6: { fontFamily: DISPLAY_FONT, fontWeight: 500, fontSize: "1.1rem" },
|
||||
subtitle1: { fontWeight: 600, fontSize: "0.95rem" },
|
||||
subtitle2: { fontWeight: 600, fontSize: "0.82rem" },
|
||||
body2: { fontSize: "0.875rem", lineHeight: 1.55 },
|
||||
button: { textTransform: "none", fontWeight: 500, letterSpacing: 0 },
|
||||
overline: { letterSpacing: "0.08em", fontWeight: 500 },
|
||||
overline: {
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
caption: { fontSize: "0.76rem", lineHeight: 1.5 },
|
||||
},
|
||||
components: {
|
||||
MuiPaper: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
backgroundImage: "none",
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.08)}`,
|
||||
}),
|
||||
body: {
|
||||
backgroundColor: t.paper,
|
||||
// Tabular figures everywhere: numbers that change every second must
|
||||
// not make the layout twitch.
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
},
|
||||
"::selection": { background: t.emberSoft },
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
MuiPaper: {
|
||||
defaultProps: { elevation: 0 },
|
||||
styleOverrides: { root: { borderRadius: 20 } },
|
||||
styleOverrides: {
|
||||
root: { backgroundImage: "none", border: `1px solid ${t.rule}`, borderRadius: 12 },
|
||||
},
|
||||
},
|
||||
MuiCard: { defaultProps: { elevation: 0 }, styleOverrides: { root: { borderRadius: 12 } } },
|
||||
MuiButton: {
|
||||
styleOverrides: { root: { borderRadius: 999, paddingInline: 20 } },
|
||||
defaultProps: { disableElevation: true, disableRipple: true },
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 8, paddingInline: 14, minHeight: 36 },
|
||||
outlined: { borderColor: t.ruleStrong, "&:hover": { borderColor: t.ink, background: "transparent" } },
|
||||
contained: { "&:hover": { filter: "brightness(1.06)" } },
|
||||
text: { "&:hover": { background: t.emberSoft } },
|
||||
},
|
||||
},
|
||||
MuiChip: { styleOverrides: { root: { borderRadius: 8 } } },
|
||||
MuiIconButton: { defaultProps: { disableRipple: true }, styleOverrides: { root: { borderRadius: 8 } } },
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 6, fontWeight: 500, fontSize: "0.74rem", height: 24 },
|
||||
outlined: { borderColor: t.rule },
|
||||
},
|
||||
},
|
||||
MuiDivider: { styleOverrides: { root: { borderColor: t.rule } } },
|
||||
MuiTextField: { defaultProps: { size: "small" } },
|
||||
MuiTooltip: { defaultProps: { arrow: true } },
|
||||
MuiAppBar: {
|
||||
defaultProps: { elevation: 0, color: "transparent" },
|
||||
styleOverrides: { root: { backdropFilter: "blur(12px)" } },
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 8, background: mode === "dark" ? t.surfaceSunken : t.surface },
|
||||
notchedOutline: { borderColor: t.rule },
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
defaultProps: { arrow: false },
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
background: t.ink,
|
||||
color: t.paper,
|
||||
fontSize: "0.75rem",
|
||||
borderRadius: 6,
|
||||
padding: "6px 9px",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSwitch: { defaultProps: { size: "small" } },
|
||||
MuiTab: { styleOverrides: { root: { textTransform: "none", fontWeight: 500, minHeight: 40 } } },
|
||||
MuiTabs: { styleOverrides: { indicator: { height: 1.5, background: t.ink } } },
|
||||
MuiLinearProgress: {
|
||||
styleOverrides: {
|
||||
root: { height: 3, borderRadius: 0, background: t.rule },
|
||||
bar: { borderRadius: 0 },
|
||||
},
|
||||
},
|
||||
MuiAlert: { styleOverrides: { root: { borderRadius: 10, border: `1px solid ${t.rule}` } } },
|
||||
MuiAccordion: {
|
||||
styleOverrides: {
|
||||
root: { border: `1px solid ${t.rule}`, borderRadius: 12, "&::before": { display: "none" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The design tokens, in one place.
|
||||
*
|
||||
* Kadence is meant to read like a well-set page rather than a dashboard: warm
|
||||
* paper, ink-black type, hairline rules instead of boxes and shadows, one warm
|
||||
* accent. The desktop app's QML `Theme` singleton carries the same values, so
|
||||
* the two clients are the same product.
|
||||
*
|
||||
* Chart colours are deliberately *not* the brand accent: they come from the
|
||||
* validated data palette (see `viz.ts`), because a chart has to stay readable
|
||||
* for colour-blind readers in both themes.
|
||||
*/
|
||||
|
||||
export interface Tokens {
|
||||
paper: string;
|
||||
surface: string;
|
||||
surfaceSunken: string;
|
||||
ink: string;
|
||||
inkMuted: string;
|
||||
inkFaint: string;
|
||||
rule: string;
|
||||
ruleStrong: string;
|
||||
ember: string;
|
||||
emberSoft: string;
|
||||
pine: string;
|
||||
amber: string;
|
||||
clay: string;
|
||||
}
|
||||
|
||||
export const light: Tokens = {
|
||||
paper: "#FAF7F2",
|
||||
surface: "#FFFFFF",
|
||||
surfaceSunken: "#F3EFE8",
|
||||
ink: "#17150F",
|
||||
inkMuted: "#6E675B",
|
||||
inkFaint: "#9A9388",
|
||||
rule: "rgba(23, 21, 15, 0.12)",
|
||||
ruleStrong: "rgba(23, 21, 15, 0.26)",
|
||||
ember: "#B4441E",
|
||||
emberSoft: "rgba(180, 68, 30, 0.10)",
|
||||
pine: "#2F6B55",
|
||||
amber: "#8A6200",
|
||||
clay: "#A33A2E",
|
||||
};
|
||||
|
||||
export const dark: Tokens = {
|
||||
paper: "#121110",
|
||||
surface: "#1A1917",
|
||||
surfaceSunken: "#201E1B",
|
||||
ink: "#F4F1EA",
|
||||
inkMuted: "#A79F93",
|
||||
inkFaint: "#776F64",
|
||||
rule: "rgba(244, 241, 234, 0.14)",
|
||||
ruleStrong: "rgba(244, 241, 234, 0.30)",
|
||||
ember: "#E0724A",
|
||||
emberSoft: "rgba(224, 114, 74, 0.14)",
|
||||
pine: "#6FB394",
|
||||
amber: "#D9A441",
|
||||
clay: "#E07A6E",
|
||||
};
|
||||
|
||||
export const DISPLAY_FONT = '"Fraunces", "Iowan Old Style", Georgia, serif';
|
||||
export const SANS_FONT = '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif';
|
||||
|
||||
export function tokensFor(mode: "light" | "dark"): Tokens {
|
||||
return mode === "dark" ? dark : light;
|
||||
}
|
||||
Reference in new issue
Block a user