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
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:
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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user