fix timers
lint / ruff (push) Failing after 15s
tests / desktop (push) Successful in 21s
tests / web (push) Successful in 46s
docker / image (push) Failing after 1m31s
tests / android-core (push) Successful in 1m5s
tests / backend (push) Successful in 2m33s
android / apk (push) Successful in 4m34s

This commit is contained in:
timka123 committed 2026-10-27 18:52:54 +03:00
1 parent ff77d8f89f
commit 157a113c39
30 files changed
+3331 -419

No files matched your search

@@ -0,0 +1,387 @@
/**
* The month, as bars from start date to deadline.
*
* Built like a printed table rather than a grid of cards: hairlines between the
* cells, tabular day figures, the month set in the display serif, and exactly
* one accent — today.
*
* Bars are coloured by **state, not by source**. Five saturated fills across a
* month grid would read as Material rather than Kadence, and five categorical
* hues cannot clear the data-viz palette validator's all-pairs normal-vision
* floor anyway (the first three slots are the documented limit). Identity comes
* from the title and from the filter; colour is kept for what changes how you
* act on a task — done, overdue, or still waiting.
*
* The layout maths all live in `lib/calendar.ts`, which `:core` ports verbatim.
*/
import { useMemo, useState } from "react";
import { Box, Button, Divider, Popover, Stack, Typography, useMediaQuery, useTheme } from "@mui/material";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { Panel } from "../Layout";
import TaskRow from "../tasks/TaskRow";
import {
itemsOnDay,
layoutMonth,
monthGrid,
shiftMonth,
todayISO,
weekdayLabels,
type WeekLayout,
} from "../../lib/calendar";
import type { Task } from "../../lib/types";
const DAY_STRIP = 22;
const BAR_H = 20;
const BAR_GAP = 3;
const OVERFLOW_H = 18;
const MIN_ROW = 88;
/**
* Monday-first on both clients for now.
*
* `AppSettings.week_starts_monday` exists and the web app could read it, but the
* phone cannot — `AppSettingsDto` does not carry it and the tasks screen loads
* no settings — so honouring it here alone is how the two clients start
* disagreeing about what a week is. The backend default is Monday anyway.
*/
const WEEK_STARTS_MONDAY = true;
export default function TaskCalendar({
tasks,
longCount,
showLong,
onShowLong,
onPin,
onStart,
onDelete,
}: {
/** Already filtered: the type tree and the long-task toggle have been applied. */
tasks: Task[];
longCount: number;
showLong: boolean;
onShowLong: (next: boolean) => void;
onPin: (task: Task) => Promise<void>;
onStart: (task: Task) => Promise<void>;
onDelete: (task: Task) => Promise<void>;
}) {
const theme = useTheme();
const wide = useMediaQuery(theme.breakpoints.up("sm"));
const today = todayISO();
const [cursor, setCursor] = useState<[number, number]>(() => {
const now = new Date();
return [now.getFullYear(), now.getMonth() + 1];
});
const [day, setDay] = useState<{ date: string; anchor: HTMLElement } | null>(null);
const [year, month] = cursor;
// Showing long bars needs the room they take, or they simply push the day's
// real work into "+N" and the month gets less informative, not more.
const maxLanes = wide ? (showLong ? 5 : 4) : 2;
const weeks = useMemo(() => monthGrid(year, month, WEEK_STARTS_MONDAY), [year, month]);
const layouts = useMemo(() => layoutMonth(weeks, tasks, maxLanes), [weeks, tasks, maxLanes]);
const byId = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const label = new Date(year, month - 1, 1).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
const dayTasks = day ? itemsOnDay(tasks, day.date) : [];
return (
<>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }}
>
<Button size="small" sx={{ minWidth: 32, px: 0 }} onClick={() => setCursor(shiftMonth(year, month, -1))}>
<ChevronLeftIcon fontSize="small" />
</Button>
<Typography
sx={{
fontFamily: theme.typography.h4.fontFamily,
fontSize: 20,
minWidth: { xs: 0, sm: 190 },
letterSpacing: "-0.01em",
}}
>
{label}
</Typography>
<Button size="small" sx={{ minWidth: 32, px: 0 }} onClick={() => setCursor(shiftMonth(year, month, 1))}>
<ChevronRightIcon fontSize="small" />
</Button>
<Button
size="small"
onClick={() => {
const now = new Date();
setCursor([now.getFullYear(), now.getMonth() + 1]);
}}
>
Today
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button
size="small"
variant={showLong ? "contained" : "outlined"}
disabled={longCount === 0}
onClick={() => onShowLong(!showLong)}
>
Long tasks ({longCount})
</Button>
</Stack>
<Panel padded={false} sx={{ overflow: "hidden" }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
bgcolor: "action.hover",
borderBottom: 1,
borderColor: "divider",
}}
>
{weekdayLabels(WEEK_STARTS_MONDAY).map((name) => (
<Typography
key={name}
variant="overline"
color="text.secondary"
sx={{ textAlign: "center", py: 0.75, fontSize: 10 }}
>
{wide ? name : name.slice(0, 1)}
</Typography>
))}
</Box>
{layouts.map((week, index) => (
<WeekRow
key={week.dates[0]}
week={week}
month={month}
today={today}
byId={byId}
last={index === layouts.length - 1}
onPickDay={(date, anchor) => setDay({ date, anchor })}
/>
))}
</Panel>
<Popover
open={day !== null}
anchorEl={day?.anchor ?? null}
onClose={() => setDay(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
slotProps={{ paper: { sx: { mt: 0.5, width: 380, maxWidth: "92vw", maxHeight: 420 } } }}
>
<Box sx={{ p: 2 }}>
<Typography variant="overline" color="text.secondary">
{day && new Date(`${day.date}T00:00:00`).toLocaleDateString(undefined, {
weekday: "long",
day: "numeric",
month: "long",
})}
</Typography>
<Divider sx={{ mt: 0.5 }} />
{dayTasks.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5 }}>
Nothing on this day.
</Typography>
) : (
<Stack divider={<Divider />}>
{dayTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onPin={() => onPin(task)}
onStart={() => onStart(task)}
onDelete={() => onDelete(task)}
/>
))}
</Stack>
)}
</Box>
</Popover>
</>
);
}
function WeekRow({
week,
month,
today,
byId,
last,
onPickDay,
}: {
week: WeekLayout;
month: number;
today: string;
byId: Map<number, Task>;
last: boolean;
onPickDay: (date: string, anchor: HTMLElement) => void;
}) {
const height =
DAY_STRIP + week.lanes * (BAR_H + BAR_GAP) + (week.hasOverflow ? OVERFLOW_H : 0) + 6;
return (
<Box
sx={{
position: "relative",
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
minHeight: Math.max(MIN_ROW, height),
borderBottom: last ? 0 : 1,
borderColor: "divider",
}}
>
{week.dates.map((date, column) => {
const outside = Number(date.slice(5, 7)) !== month;
const isToday = date === today;
return (
<Box
key={date}
onClick={(event) => onPickDay(date, event.currentTarget)}
sx={{
borderRight: column === 6 ? 0 : 1,
borderColor: "divider",
px: 0.75,
pt: 0.5,
cursor: "pointer",
minWidth: 0,
"&:hover": { bgcolor: "action.hover" },
}}
>
<Typography
component="span"
variant="caption"
sx={{
fontVariantNumeric: "tabular-nums",
color: isToday ? "primary.main" : outside ? "text.disabled" : "text.secondary",
fontWeight: isToday ? 600 : 400,
borderBottom: isToday ? 2 : 0,
borderColor: "primary.main",
pb: isToday ? "1px" : 0,
}}
>
{Number(date.slice(8, 10))}
</Typography>
</Box>
);
})}
{week.bars.map((bar) => {
const task = byId.get(bar.id);
if (!task) return null;
return (
<Box
key={`${bar.id}-${bar.startCol}`}
sx={{
position: "absolute",
pointerEvents: "none",
top: DAY_STRIP + bar.lane * (BAR_H + BAR_GAP),
left: `${(bar.startCol / 7) * 100}%`,
width: `${((bar.endCol - bar.startCol + 1) / 7) * 100}%`,
px: "2px",
boxSizing: "border-box",
}}
>
<TaskBar task={task} today={today} continuesLeft={bar.continuesLeft} continuesRight={bar.continuesRight} />
</Box>
);
})}
{week.hasOverflow &&
week.overflow.map((count, column) =>
count > 0 ? (
<Typography
key={`more-${column}`}
variant="caption"
color="text.secondary"
sx={{
position: "absolute",
pointerEvents: "none",
top: DAY_STRIP + week.lanes * (BAR_H + BAR_GAP) + 1,
left: `${(column / 7) * 100}%`,
width: `${(1 / 7) * 100}%`,
px: "6px",
boxSizing: "border-box",
fontSize: 11,
}}
>
+{count} more
</Typography>
) : null,
)}
</Box>
);
}
/**
* One bar.
*
* `days_overdue` and `bucket` are computed against *today* by the backend, so a
* stale laundry slot from March claims to be two hundred days overdue forever.
* Neither is used here: lateness is decided from the task's own due date, and
* only for something still open and still offered by its source.
*/
function TaskBar({
task,
today,
continuesLeft,
continuesRight,
}: {
task: Task;
today: string;
continuesLeft: boolean;
continuesRight: boolean;
}) {
const late = !task.is_done && !task.stale && task.due_date !== null && task.due_date < today;
const muted = task.is_done || task.stale;
const radius = "4px";
return (
<Box
sx={{
height: BAR_H,
display: "flex",
alignItems: "center",
gap: "6px",
px: "6px",
bgcolor: "action.hover",
borderTopLeftRadius: continuesLeft ? 0 : radius,
borderBottomLeftRadius: continuesLeft ? 0 : radius,
borderTopRightRadius: continuesRight ? 0 : radius,
borderBottomRightRadius: continuesRight ? 0 : radius,
minWidth: 0,
}}
>
{!continuesLeft && (
<Box
sx={{
width: 2,
height: "60%",
flexShrink: 0,
borderRadius: 2,
bgcolor: late ? "error.main" : muted ? "text.disabled" : "text.secondary",
}}
/>
)}
<Typography
noWrap
sx={{
fontSize: 11.5,
lineHeight: 1,
minWidth: 0,
color: muted ? "text.disabled" : late ? "error.main" : "text.primary",
textDecoration: task.is_done ? "line-through" : "none",
}}
>
{task.title}
</Typography>
</Box>
);
}
@@ -0,0 +1,75 @@
/**
* The "and this too" you type at 9am.
*
* The draft lives here rather than in the page: it is this dialog's business and
* nothing else ever reads it.
*/
import { useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
TextField,
Typography,
} from "@mui/material";
import { api } from "../../lib/api";
export default function AddTaskDialog({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => Promise<void>;
}) {
const [draft, setDraft] = useState({ title: "", due_date: "" });
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>New task</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
autoFocus
label="What needs doing?"
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
<TextField
label="Due date"
type="date"
InputLabelProps={{ shrink: true }}
value={draft.due_date}
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
/>
<Typography variant="caption" color="text.secondary">
Local tasks live only here, so they are the one kind you can delete.
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
disabled={!draft.title.trim()}
onClick={async () => {
await api.post("/api/tasks", {
title: draft.title.trim(),
due_date: draft.due_date || null,
});
setDraft({ title: "", due_date: "" });
onClose();
await onCreated();
}}
>
Add
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,156 @@
/**
* Hide whole categories of work: a source, or one project inside it.
*
* The set it edits holds **hidden** keys, never visible ones, so a Jira project
* that first appears after a refresh shows up rather than being filtered out by
* a set written last week. See `isHidden` in `lib/calendar.ts`.
*
* Switching a source off disables its projects rather than silently unticking
* them: the parent governs, and switching it back on restores the whole subtree
* instead of leaving ghosts from whatever was unticked before.
*/
import { useMemo, useState } from "react";
import {
Box,
Button,
Checkbox,
Divider,
Popover,
Stack,
Typography,
} from "@mui/material";
import FilterListIcon from "@mui/icons-material/FilterList";
import { buildFilterTree, type CalendarItem } from "../../lib/calendar";
export default function SourceFilterTree({
items,
hidden,
onChange,
}: {
/** The *unfiltered* list — the counts have to describe what is there. */
items: CalendarItem[];
hidden: string[];
onChange: (next: string[]) => void;
}) {
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const tree = useMemo(() => buildFilterTree(items), [items]);
const hiddenSet = useMemo(() => new Set(hidden), [hidden]);
const setHidden = (key: string, isHidden: boolean, alsoClear: string[] = []) => {
const next = new Set(hiddenSet);
if (isHidden) next.add(key);
else next.delete(key);
for (const extra of alsoClear) next.delete(extra);
onChange([...next]);
};
return (
<>
<Button
size="small"
variant="outlined"
startIcon={<FilterListIcon sx={{ fontSize: 16 }} />}
onClick={(event) => setAnchor(event.currentTarget)}
>
{hidden.length ? `Filter (${hidden.length} hidden)` : "Filter"}
</Button>
<Popover
open={anchor !== null}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 260, maxHeight: 420 } } }}
>
<Box sx={{ p: 2 }}>
<Stack direction="row" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="overline" color="text.secondary">
Show
</Typography>
<Box sx={{ flexGrow: 1 }} />
<Button size="small" disabled={hidden.length === 0} onClick={() => onChange([])}>
Reset
</Button>
</Stack>
<Divider sx={{ mb: 1 }} />
{tree.length === 0 && (
<Typography variant="body2" color="text.secondary">
Nothing to filter yet.
</Typography>
)}
{tree.map((node) => {
const sourceHidden = hiddenSet.has(node.key);
return (
<Box key={node.key} sx={{ mb: 0.5 }}>
<FilterRow
label={node.label}
count={node.count}
checked={!sourceHidden}
bold
onChange={(visible) =>
setHidden(
node.key,
!visible,
// Turning a source back on restores its projects with it.
visible ? node.children.map((child) => child.key) : [],
)
}
/>
{node.children.map((child) => (
<FilterRow
key={child.key}
label={child.label}
count={child.count}
indent
disabled={sourceHidden}
checked={!sourceHidden && !hiddenSet.has(child.key)}
onChange={(visible) => setHidden(child.key, !visible)}
/>
))}
</Box>
);
})}
</Box>
</Popover>
</>
);
}
function FilterRow({
label,
count,
checked,
onChange,
indent = false,
bold = false,
disabled = false,
}: {
label: string;
count: number;
checked: boolean;
onChange: (visible: boolean) => void;
indent?: boolean;
bold?: boolean;
disabled?: boolean;
}) {
return (
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ pl: indent ? 3 : 0, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.45 : 1 }}
onClick={() => !disabled && onChange(!checked)}
>
<Checkbox size="small" checked={checked} disabled={disabled} sx={{ p: 0.5 }} />
<Typography variant="body2" noWrap sx={{ flexGrow: 1, minWidth: 0, fontWeight: bold ? 600 : 400 }}>
{label}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: "tabular-nums" }}>
{count}
</Typography>
</Stack>
);
}
@@ -0,0 +1,88 @@
/**
* Everything you owe, grouped by deadline — the original Tasks view.
*
* Moved out of `TasksPage.tsx` when the page grew a calendar beside it. The
* grouping and the empty state are unchanged; the one new thing is that the
* list filters `is_done`/`stale` itself rather than trusting the URL it was
* fetched from, because the page now asks the backend for history so the
* calendar has something to draw.
*/
import { useMemo } from "react";
import { Box, Divider, Stack, Typography } from "@mui/material";
import { Panel, SectionTitle } from "../Layout";
import { BUCKET_LABEL } from "../../lib/format";
import type { Task } from "../../lib/types";
import TaskRow from "./TaskRow";
const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"];
/** What the list has always shown: open, still offered by its source. */
export function openTasks(tasks: Task[]): Task[] {
return tasks.filter((task) => !task.is_done && !task.stale);
}
export default function TaskList({
tasks,
onPin,
onStart,
onDelete,
}: {
tasks: Task[];
onPin: (task: Task) => Promise<void>;
onStart: (task: Task) => Promise<void>;
onDelete: (task: Task) => Promise<void>;
}) {
const grouped = useMemo(() => {
const map: Record<string, Task[]> = { overdue: [], today: [], active: [], upcoming: [], someday: [] };
for (const task of tasks) {
const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday";
map[bucket].push(task);
}
return map;
}, [tasks]);
if (tasks.length === 0) {
return (
<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>
);
}
return (
<>
{BUCKETS.map((bucket) =>
grouped[bucket].length ? (
<Box key={bucket} sx={{ mb: 4 }}>
<SectionTitle
action={
<Typography variant="caption" color="text.secondary">
{grouped[bucket].length}
</Typography>
}
>
{BUCKET_LABEL[bucket]}
</SectionTitle>
<Stack divider={<Divider />}>
{grouped[bucket].map((task) => (
<TaskRow
key={task.id}
task={task}
onPin={() => onPin(task)}
onStart={() => onStart(task)}
onDelete={() => onDelete(task)}
/>
))}
</Stack>
</Box>
) : null,
)}
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
/**
* One task, as a row of hairline-separated facts.
*
* Moved out of `TasksPage.tsx` unchanged when the page grew a second view; the
* calendar's day popover renders the same row, so a task reads identically
* wherever you meet it.
*/
import { Box, IconButton, Link, Stack, Tooltip, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
import StarIcon from "@mui/icons-material/Star";
import StarBorderIcon from "@mui/icons-material/StarBorder";
import type { Task } from "../../lib/types";
export const BUCKET_COLOR: Record<string, string> = {
overdue: "error.main",
today: "warning.main",
active: "success.main",
upcoming: "text.disabled",
someday: "divider",
};
export default function TaskRow({
task,
onPin,
onStart,
onDelete,
}: {
task: Task;
onPin: () => Promise<void>;
onStart: () => Promise<void>;
onDelete: () => Promise<void>;
}) {
return (
<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">
<Typography
variant="body1"
noWrap
sx={{
fontWeight: task.pinned ? 600 : 400,
textDecoration: task.is_done ? "line-through" : "none",
color: task.is_done ? "text.disabled" : "text.primary",
}}
>
{task.title}
</Typography>
{task.url && (
<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.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.status && (
<Typography variant="caption" color="text.secondary">
{task.status}
</Typography>
)}
{task.priority && (
<Typography variant="caption" color="text.secondary">
{task.priority}
</Typography>
)}
{/* `days_overdue` is computed against today, so it is only the truth for
something still open. A stale laundry slot carries one forever. */}
{task.days_overdue > 0 && !task.is_done && !task.stale ? (
<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>
)
)}
</Stack>
</Box>
<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>
);
}
+443
View File
@@ -0,0 +1,443 @@
/**
* The month grid, and the two filters that make it readable.
*
* A calendar of *tasks* is not a calendar of events: nothing here has a time of
* day, only a `start_date` and a `due_date`, so every item is a whole-day bar and
* the only interesting layout question is which lane it sits in.
*
* Three rules exist because of what the sources actually return rather than for
* their own sake:
*
* * **Long tasks are measured on their true span, before clipping.** Jira hands
* back every open issue with whatever "Start date" the custom field holds,
* which is routinely months old. Clipping to the week first would make a
* fifteen-day bar look like seven days in each of three rows and it would
* never trip the filter — which is the entire point of the filter.
* * **The filter stores hidden keys, never visible ones.** A Jira project that
* appears for the first time after a refresh must show up, not be silently
* filtered out because it was absent from a set written last week.
* * **A due date is what makes a task plottable** — see `spanOf`.
*
* Nothing here may key off `bucket` or `days_overdue`: the backend computes both
* against *today* (`_bucket_of` in `backend/app/api/routes_tasks.py`), so a stale
* laundry slot from March reports `overdue` with a `days_overdue` that grows
* forever. In a cell you are looking at in a past month that is noise — style
* from the cell's own date against `is_done` and `stale` instead.
*
* `android/core/src/main/kotlin/tech/kadence/core/Calendar.kt` is a literal port
* of this file, and `CalendarTest` asserts the outputs this produces.
* `CalendarParityTest` reads `LONG_TASK_DAYS` straight out of this source.
*/
/** Longer than this many days and a bar is "long". Mirrored in `Calendar.kt`. */
export const LONG_TASK_DAYS = 7;
/**
* What the layout needs off a task. `Task` satisfies this structurally, so
* callers pass their tasks straight in.
*/
export interface CalendarItem {
id: number;
source: string;
source_label: string;
project: string | null;
start_date: string | null;
due_date: string | null;
}
export interface Span {
start: string;
end: string;
/** Inclusive length, so a one-day task is 1. */
days: number;
}
// --------------------------------------------------------------------------
// Dates
// --------------------------------------------------------------------------
// ISO day strings compare lexicographically in calendar order, so ordering and
// overlap tests never need a Date at all. Only arithmetic does.
/** `new Date("2026-10-19")` is parsed as UTC; this is the local-midnight form. */
function parseISO(iso: string): Date {
return new Date(`${iso}T00:00:00`);
}
function toISO(date: Date): string {
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
/** Whole days from `from` to `to`; rounded, so a DST day is still one day. */
export function diffDays(from: string, to: string): number {
return Math.round((parseISO(to).getTime() - parseISO(from).getTime()) / 86_400_000);
}
export function addDays(iso: string, days: number): string {
const date = parseISO(iso);
return toISO(new Date(date.getFullYear(), date.getMonth(), date.getDate() + days));
}
export function todayISO(): string {
return toISO(new Date());
}
// --------------------------------------------------------------------------
// Spans
// --------------------------------------------------------------------------
function span(start: string, end: string): Span {
return { start, end, days: diffDays(start, end) + 1 };
}
/**
* Where a task sits on the calendar, or null if it cannot be plotted.
*
* **A due date is what makes a task plottable.** A task with only a start date
* is deliberately *not* drawn, which is the one rule here that costs something
* and is worth the cost: the Jira JQL in `backend/app/sources/jira.py:77`
* selects issues whose start date is today-or-earlier with no lower bound, and
* Jira issues very often carry a start date and no due date. Drawing those on
* their start day puts work the user thinks of as "now" into a cell four months
* back — a confident wrong answer, which is worse than an honest absence. They
* come back as `undated` instead and get their own list.
*
* A due date *before* the start date is bad data — Jira permits it — and
* collapses to the deadline, because the deadline is the half that matters.
*/
export function spanOf(item: CalendarItem): Span | null {
const due = item.due_date;
if (!due) return null;
const start = item.start_date;
if (!start || due < start) return span(due, due);
return span(start, due);
}
export function isLong(value: Span): boolean {
return value.days > LONG_TASK_DAYS;
}
// --------------------------------------------------------------------------
// The grid
// --------------------------------------------------------------------------
/**
* The weeks a month needs — five or six, never a fixed six.
*
* October 2026 starts on a Thursday and ends on a Saturday, so Monday-first it
* runs 28 September to 1 November in five rows. Padding that to six would hang
* an empty week off the bottom of the page.
*
* `month` is 1-12.
*/
export function monthGrid(year: number, month: number, weekStartsMonday: boolean): string[][] {
const first = new Date(year, month - 1, 1);
const weekday = first.getDay();
const offset = weekStartsMonday ? (weekday + 6) % 7 : weekday;
const lastDay = new Date(year, month, 0).getDate();
const weeks: string[][] = [];
let cursor = 1 - offset;
while (cursor <= lastDay) {
const row: string[] = [];
for (let index = 0; index < 7; index += 1) {
row.push(toISO(new Date(year, month - 1, cursor + index)));
}
weeks.push(row);
cursor += 7;
}
return weeks;
}
const WEEKDAYS_FROM_MONDAY = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export function weekdayLabels(weekStartsMonday: boolean): string[] {
if (weekStartsMonday) return [...WEEKDAYS_FROM_MONDAY];
return [WEEKDAYS_FROM_MONDAY[6], ...WEEKDAYS_FROM_MONDAY.slice(0, 6)];
}
// --------------------------------------------------------------------------
// Lane packing
// --------------------------------------------------------------------------
export interface Bar {
id: number;
lane: number;
/** Inclusive column indices into the week, 0-6. */
startCol: number;
endCol: number;
/** The task began before this week / ends after it — square that edge off. */
continuesLeft: boolean;
continuesRight: boolean;
}
export interface WeekLayout {
dates: string[];
bars: Bar[];
/** Per column, how many tasks this week could not draw — the "+N" counts. */
overflow: number[];
/** Lanes actually drawn, so a row can size itself to its content. */
lanes: number;
/** Whether any day in this week needs the "+N" strip under the bars. */
hasOverflow: boolean;
}
interface Placed {
item: CalendarItem;
span: Span;
}
/**
* Lay out a whole month, keeping a multi-week bar in one lane throughout.
*
* This has to be a month-at-a-time function rather than a week-at-a-time one,
* because the lane a continuing bar occupies is information that flows from one
* week to the next. Packing each week independently — however the segments are
* sorted — lets a bar step between lanes halfway along. With A spanning
* 2 to 18 March and B spanning 9 March to 2 April, A owns lane 0 in the first
* week; in the second both clip to Monday, and *any* tie-break that prefers B
* (longest-first does) moves A down to lane 1 mid-bar.
*
* So, FullCalendar's rule, in two phases per week:
*
* 1. **Continuations keep the lane they already hold.** This can never
* collide: every bar continuing into this week covers its first day, so
* every pair of them also overlapped on the previous week's last day, and
* they were given distinct lanes there.
* 2. **Everything else competes** for the lowest free lane, longest first.
*
* Note that `continuesLeft` is geometry (the span starts before this week) while
* "is a continuation" is bookkeeping (we already gave it a lane). They are not
* the same, and conflating them breaks the first visible week: a bar running in
* from before the grid has no lane yet, so it competes as a newcomer while still
* drawing with an open left edge.
*
* A task that finds no free lane is not drawn and is counted against **every**
* day it covers, because each cell's "+2" has to describe that cell — it is what
* opens when you click it. It is deliberately given no lane, so next week it
* competes again and may well win one.
*
* `maxLanes` is a per-client layout budget, not a shared rule: it is a required
* argument with no default so that neither client can drift into its own idea of
* what "the" value is.
*/
export function layoutMonth(
weeks: string[][],
items: CalendarItem[],
maxLanes: number,
): WeekLayout[] {
const placed: Placed[] = [];
for (const item of items) {
const value = spanOf(item);
if (value) placed.push({ item, span: value });
}
const laneOf = new Map<number, number>();
const layouts: WeekLayout[] = [];
for (const week of weeks) {
const weekStart = week[0];
const weekEnd = week[6];
const inWeek = placed.filter((entry) => entry.span.start <= weekEnd && entry.span.end >= weekStart);
const occupied: boolean[][] = [];
for (let lane = 0; lane < maxLanes; lane += 1) occupied.push(new Array(7).fill(false));
const bars: Bar[] = [];
const overflow = new Array(7).fill(0);
const claim = (lane: number, from: number, to: number) => {
for (let column = from; column <= to; column += 1) occupied[lane][column] = true;
};
const free = (lane: number, from: number, to: number) => {
for (let column = from; column <= to; column += 1) {
if (occupied[lane][column]) return false;
}
return true;
};
const columns = (entry: Placed): [number, number] => [
entry.span.start < weekStart ? 0 : diffDays(weekStart, entry.span.start),
entry.span.end > weekEnd ? 6 : diffDays(weekStart, entry.span.end),
];
const barFor = (entry: Placed, lane: number, from: number, to: number): Bar => ({
id: entry.item.id,
lane,
startCol: from,
endCol: to,
continuesLeft: entry.span.start < weekStart,
continuesRight: entry.span.end > weekEnd,
});
// Phase 1 — continuations reclaim their lane, in lane order.
const continuing = inWeek
.filter((entry) => entry.span.start < weekStart && laneOf.has(entry.item.id))
.sort((a, b) => (laneOf.get(a.item.id) as number) - (laneOf.get(b.item.id) as number));
for (const entry of continuing) {
const lane = laneOf.get(entry.item.id) as number;
const [from, to] = columns(entry);
claim(lane, from, to);
bars.push(barFor(entry, lane, from, to));
}
// Phase 2 — the rest take the lowest lane that fits.
const seen = new Set(continuing.map((entry) => entry.item.id));
const starting = inWeek
.filter((entry) => !seen.has(entry.item.id))
.sort(
(a, b) =>
b.span.days - a.span.days ||
a.span.start.localeCompare(b.span.start) ||
a.item.id - b.item.id,
);
for (const entry of starting) {
const [from, to] = columns(entry);
let lane = -1;
for (let candidate = 0; candidate < maxLanes; candidate += 1) {
if (free(candidate, from, to)) {
lane = candidate;
break;
}
}
if (lane < 0) {
for (let column = from; column <= to; column += 1) overflow[column] += 1;
continue;
}
claim(lane, from, to);
laneOf.set(entry.item.id, lane);
bars.push(barFor(entry, lane, from, to));
}
const lanes = bars.reduce((most, bar) => Math.max(most, bar.lane + 1), 0);
layouts.push({
dates: week,
bars,
overflow,
lanes,
hasOverflow: overflow.some((count) => count > 0),
});
}
return layouts;
}
/** `shiftMonth(2026, 1, -1)` is December 2025. `month` is 1-12 throughout. */
export function shiftMonth(year: number, month: number, delta: number): [number, number] {
const zero = year * 12 + (month - 1) + delta;
return [Math.floor(zero / 12), (zero % 12) + 1];
}
// --------------------------------------------------------------------------
// The type filter
// --------------------------------------------------------------------------
export interface FilterNode {
/** `"jira"` for a source, `"jira/MIPT"` for one of its projects. */
key: string;
label: string;
count: number;
children: FilterNode[];
}
function projectKey(source: string, project: string): string {
return `${source}/${project}`;
}
/**
* Sources, each expanded into the projects seen under it.
*
* Only tasks that *name* a project get a child node, so a source's children can
* sum to less than its own count; those tasks are reachable by hiding the whole
* source, which is the honest thing a "(none)" bucket would only dress up.
*/
export function buildFilterTree(items: CalendarItem[]): FilterNode[] {
const sources = new Map<string, { label: string; count: number; projects: Map<string, number> }>();
for (const item of items) {
let entry = sources.get(item.source);
if (!entry) {
entry = { label: item.source_label || item.source, count: 0, projects: new Map() };
sources.set(item.source, entry);
}
entry.count += 1;
const project = (item.project ?? "").trim();
if (project) entry.projects.set(project, (entry.projects.get(project) ?? 0) + 1);
}
return [...sources.entries()]
.sort((a, b) => a[1].label.localeCompare(b[1].label))
.map(([source, entry]) => ({
key: source,
label: entry.label,
count: entry.count,
children: [...entry.projects.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([project, count]) => ({
key: projectKey(source, project),
label: project,
count,
children: [],
})),
}));
}
/** Hiding a source hides its projects with it. */
export function isHidden(item: CalendarItem, hidden: ReadonlySet<string>): boolean {
if (hidden.has(item.source)) return true;
const project = (item.project ?? "").trim();
return project.length > 0 && hidden.has(projectKey(item.source, project));
}
export interface CalendarModel<T> {
/** What the grid draws. */
visible: T[];
/** Passing the type filter but carrying no due date, so nowhere to draw them. */
undated: T[];
/** Spanning more than a week — the toggle's count, whether or not it is on. */
longCount: number;
}
/**
* One pass over the list for everything the toolbar and the grid need.
*
* `longCount` deliberately ignores `showLong`, so the toggle's own label does
* not change the moment you press it.
*/
export function buildModel<T extends CalendarItem>(
items: T[],
hidden: ReadonlySet<string>,
showLong: boolean,
): CalendarModel<T> {
const visible: T[] = [];
const undated: T[] = [];
let longCount = 0;
for (const item of items) {
if (isHidden(item, hidden)) continue;
const value = spanOf(item);
if (!value) {
undated.push(item);
continue;
}
if (isLong(value)) {
longCount += 1;
if (!showLong) continue;
}
visible.push(item);
}
return { visible, undated, longCount };
}
/** The tasks touching one day, in the order the grid stacks them. */
export function itemsOnDay<T extends CalendarItem>(items: T[], day: string): T[] {
return items
.filter((item) => {
const value = spanOf(item);
return value !== null && value.start <= day && value.end >= day;
})
.sort((a, b) => {
const left = spanOf(a) as Span;
const right = spanOf(b) as Span;
return left.start.localeCompare(right.start) || right.days - left.days || a.id - b.id;
});
}
+137 -211
View File
@@ -1,9 +1,16 @@
/**
* Everything you owe, from every source, grouped by deadline.
* Everything you owe, from every source — as a list, or as a month.
*
* 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.
*
* The page asks the backend for **done and stale rows too**, because the
* calendar's whole point is that a past month shows what actually happened. The
* list therefore filters `is_done`/`stale` itself rather than trusting the URL
* it was fetched from, and the source buttons are counted from that filtered
* list — `sources` and `counts` in the response now describe the archive, not
* the work in front of you.
*/
import { useMemo, useState } from "react";
import {
@@ -13,51 +20,43 @@ import {
Button,
Chip,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
LinearProgress,
Link,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography,
} from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
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 AddTaskDialog from "../components/tasks/AddTaskDialog";
import SourceFilterTree from "../components/tasks/SourceFilterTree";
import TaskList, { openTasks } from "../components/tasks/TaskList";
import TaskRow from "../components/tasks/TaskRow";
import TaskCalendar from "../components/calendar/TaskCalendar";
import { api } from "../lib/api";
import { BUCKET_LABEL } from "../lib/format";
import { useResource } from "../lib/hooks";
import { buildModel } from "../lib/calendar";
import { useLocalState, useResource } from "../lib/hooks";
import type { RefreshResult, Task, TaskListResponse } from "../lib/types";
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",
};
/** History included: the calendar draws past months, the list filters it back out. */
const TASKS_PATH = "/api/tasks?include_done=true&include_stale=true";
export default function TasksPage({ onToast }: { onToast: (message: string) => void }) {
const navigate = useNavigate();
const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
const tasks = useResource<TaskListResponse>(TASKS_PATH, 60000);
const [refreshing, setRefreshing] = useState(false);
const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null);
const [filter, setFilter] = useState("all");
const [addOpen, setAddOpen] = useState(false);
const [draft, setDraft] = useState({ title: "", due_date: "" });
// Per-device UI preferences. `useLocalState` JSON-stringifies, and a `Set`
// stringifies to `{}` — so the hidden keys are persisted as an array and the
// set is derived.
const [view, setView] = useLocalState<"list" | "calendar">("kadence.tasks.view", "list");
const [hiddenKeys, setHiddenKeys] = useLocalState<string[]>("kadence.tasks.hidden", []);
const [showLong, setShowLong] = useLocalState<boolean>("kadence.tasks.showLong", false);
const refresh = async () => {
setRefreshing(true);
@@ -79,19 +78,38 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
}
};
const visible = useMemo(() => {
const all = tasks.data?.tasks ?? [];
return filter === "all" ? all : all.filter((task) => task.source === filter);
}, [tasks.data, filter]);
const onPin = async (task: Task) => {
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
await tasks.reload();
};
const onStart = async (task: Task) => {
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
onToast(`Focusing on “${task.title}”`);
navigate("/");
};
const onDelete = async (task: Task) => {
await api.del(`/api/tasks/${task.id}`);
await tasks.reload();
};
const grouped = useMemo(() => {
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 all = useMemo(() => tasks.data?.tasks ?? [], [tasks.data]);
const open = useMemo(() => openTasks(all), [all]);
// The response's `sources` now lists every source that ever had a row; the
// buttons have to describe what is actually on the list.
const openSources = useMemo(() => {
const labels = new Map<string, string>();
for (const task of open) labels.set(task.source, task.source_label || task.source);
return [...labels.entries()].sort((a, b) => a[1].localeCompare(b[1]));
}, [open]);
const listed = useMemo(
() => (filter === "all" ? open : open.filter((task) => task.source === filter)),
[open, filter],
);
const hiddenSet = useMemo(() => new Set(hiddenKeys), [hiddenKeys]);
const model = useMemo(() => buildModel(all, hiddenSet, showLong), [all, hiddenSet, showLong]);
const failed = sourceStatus?.filter((item) => !item.ok) ?? [];
@@ -130,16 +148,31 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
<ToggleButtonGroup
size="small"
exclusive
value={filter}
onChange={(_event, value) => value && setFilter(value)}
value={view}
onChange={(_event, value) => value && setView(value as "list" | "calendar")}
>
<ToggleButton value="all">All</ToggleButton>
{(tasks.data?.sources ?? []).map((source) => (
<ToggleButton key={source} value={source}>
{tasks.data?.tasks.find((task) => task.source === source)?.source_label ?? source}
</ToggleButton>
))}
<ToggleButton value="list">List</ToggleButton>
<ToggleButton value="calendar">Calendar</ToggleButton>
</ToggleButtonGroup>
{view === "list" ? (
<ToggleButtonGroup
size="small"
exclusive
value={filter}
onChange={(_event, value) => value && setFilter(value)}
>
<ToggleButton value="all">All</ToggleButton>
{openSources.map(([source, label]) => (
<ToggleButton key={source} value={source}>
{label}
</ToggleButton>
))}
</ToggleButtonGroup>
) : (
<SourceFilterTree items={all} hidden={hiddenKeys} onChange={setHiddenKeys} />
)}
<Box sx={{ flexGrow: 1 }} />
{sourceStatus && failed.length === 0 && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
@@ -150,182 +183,75 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
)}
</Stack>
{tasks.data && visible.length === 0 && (
<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>
{view === "calendar" ? (
<>
<TaskCalendar
tasks={model.visible}
longCount={model.longCount}
showLong={showLong}
onShowLong={setShowLong}
onPin={onPin}
onStart={onStart}
onDelete={onDelete}
/>
<UndatedSection tasks={model.undated} onPin={onPin} onStart={onStart} onDelete={onDelete} />
</>
) : (
<TaskList tasks={listed} onPin={onPin} onStart={onStart} onDelete={onDelete} />
)}
{BUCKETS.map((bucket) =>
grouped[bucket].length ? (
<Box key={bucket} sx={{ mb: 4 }}>
<SectionTitle
action={
<Typography variant="caption" color="text.secondary">
{grouped[bucket].length}
</Typography>
}
>
{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>
</Box>
) : null,
)}
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>New task</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
autoFocus
label="What needs doing?"
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
<TextField
label="Due date"
type="date"
InputLabelProps={{ shrink: true }}
value={draft.due_date}
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
/>
<Typography variant="caption" color="text.secondary">
Local tasks live only here, so they are the one kind you can delete.
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
<Button
variant="contained"
disabled={!draft.title.trim()}
onClick={async () => {
await api.post("/api/tasks", {
title: draft.title.trim(),
due_date: draft.due_date || null,
});
setDraft({ title: "", due_date: "" });
setAddOpen(false);
await tasks.reload();
}}
>
Add
</Button>
</DialogActions>
</Dialog>
<AddTaskDialog open={addOpen} onClose={() => setAddOpen(false)} onCreated={tasks.reload} />
</>
);
}
function TaskRow({
task,
/**
* The tasks a calendar cannot place: no due date, so no column to sit in.
*
* Jira keeps the same affordance and calls it the backlog. Collapsed by default
* — it is routinely the longest list on the page, and it is not what you opened
* a calendar to look at.
*/
function UndatedSection({
tasks,
onPin,
onStart,
onDelete,
}: {
task: Task;
onPin: () => Promise<void>;
onStart: () => Promise<void>;
onDelete: () => Promise<void>;
tasks: Task[];
onPin: (task: Task) => Promise<void>;
onStart: (task: Task) => Promise<void>;
onDelete: (task: Task) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
if (tasks.length === 0) return null;
return (
<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">
<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", color: "text.disabled" }}>
<OpenInNewIcon sx={{ fontSize: 14 }} />
</Link>
)}
</Stack>
<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.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>
)
)}
</Stack>
</Box>
<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>
<Box sx={{ mt: 3 }}>
<SectionTitle
action={
<Button size="small" onClick={() => setOpen(!open)}>
{open ? "Hide" : "Show"}
</Button>
}
>
No due date ({tasks.length})
</SectionTitle>
<Collapse in={open}>
<Panel padded={false} sx={{ px: 2 }}>
<Stack divider={<Divider />}>
{tasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onPin={() => onPin(task)}
onStart={() => onStart(task)}
onDelete={() => onDelete(task)}
/>
))}
</Stack>
</Panel>
</Collapse>
</Box>
);
}
+20 -6
View File
@@ -100,13 +100,22 @@ export default function TimerPage({
[onToast, timer, today],
);
const firing = useRef(false);
// Which interval we have already told the server to finish. Keyed by id, not a
// bare "in flight" flag: a poll issued just before the countdown ran out answers
// with the finished interval still running at 00:00, and a flag cleared by that
// reply fires `complete` a second time — onto the break `auto_next` has since
// started, which is a break that ends the instant it begins. An id is never
// reused, so the second attempt is simply never made.
const completed = useRef<number | null>(null);
useEffect(() => {
if (!current || remaining > 0 || firing.current) return;
firing.current = true;
if (!current || remaining > 0 || completed.current === current.id) return;
completed.current = current.id;
const wasWork = current.kind === "work";
void (async () => {
const next = await call("/api/timer/complete", { auto_next: true });
const next = await call("/api/timer/complete", { auto_next: true, session_id: current.id });
// It never landed, so this interval is still owed a complete: let the next
// tick try again rather than leave the dial stuck at 00:00.
if (!next && completed.current === current.id) completed.current = null;
const volume = settings?.pomodoro.alarm_volume ?? 70;
if (settings?.pomodoro.alarm_sound ?? true) {
wasWork ? sound.workDone(volume) : sound.breakDone(volume);
@@ -115,7 +124,6 @@ export default function TimerPage({
notify(wasWork ? "Pomodoro complete" : "Break over", wasWork ? "Time for a break." : "Back to it.");
}
if (next?.celebration) onToast("Milestone reached — Telegram got a cheer");
firing.current = false;
})();
}, [remaining, current, call, settings, onToast]);
@@ -218,7 +226,13 @@ export default function TimerPage({
<Button
variant="contained"
disabled={busy}
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval finished")}
onClick={() =>
void call(
"/api/timer/complete",
{ auto_next: true, session_id: current.id },
"Interval finished",
)
}
>
Finish
</Button>