init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Today, laid out on a clock: every interval in its real place.
|
||||
*
|
||||
* Three categories (focus / short / long break) so a legend is mandatory, and
|
||||
* each block is also labelled on hover — colour never carries identity alone.
|
||||
*/
|
||||
import { Box, Stack, Tooltip, Typography, useTheme } from "@mui/material";
|
||||
|
||||
import { KIND_LABEL } from "../../lib/format";
|
||||
import { kindColor, type VizPalette } from "../../lib/viz";
|
||||
import ChartFrame from "./ChartFrame";
|
||||
|
||||
interface Session {
|
||||
id: number;
|
||||
kind: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
minutes: number;
|
||||
task: string | null;
|
||||
}
|
||||
|
||||
const LANE = 34;
|
||||
|
||||
export default function SessionTimeline({
|
||||
sessions,
|
||||
palette,
|
||||
}: {
|
||||
sessions: Session[];
|
||||
palette: VizPalette;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const done = sessions.filter((item) => item.minutes > 0);
|
||||
if (!done.length) {
|
||||
return (
|
||||
<ChartFrame title="Today's timeline" caption="Nothing recorded yet — start a pomodoro.">
|
||||
<Box sx={{ height: LANE, borderRadius: 1, bgcolor: theme.palette.action.hover }} />
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const hourOf = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
return date.getHours() + date.getMinutes() / 60;
|
||||
};
|
||||
const first = Math.floor(Math.min(...done.map((item) => hourOf(item.started_at))));
|
||||
const last = Math.ceil(
|
||||
Math.max(...done.map((item) => hourOf(item.started_at) + item.minutes / 60)),
|
||||
);
|
||||
const span = Math.max(last - first, 1);
|
||||
|
||||
return (
|
||||
<ChartFrame
|
||||
title="Today's timeline"
|
||||
caption={`${first}:00 → ${last}:00`}
|
||||
legend={[
|
||||
{ label: KIND_LABEL.work, color: palette.work },
|
||||
{ label: KIND_LABEL.short_break, color: palette.shortBreak },
|
||||
{ label: KIND_LABEL.long_break, color: palette.longBreak },
|
||||
]}
|
||||
>
|
||||
<Box sx={{ position: "relative", height: LANE, borderRadius: 1, bgcolor: theme.palette.action.hover }}>
|
||||
{done.map((item) => {
|
||||
const start = (hourOf(item.started_at) - first) / span;
|
||||
const width = item.minutes / 60 / span;
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ display: "block", fontWeight: 600 }}>
|
||||
{KIND_LABEL[item.kind] ?? item.kind} · {item.minutes} min
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: "block" }}>
|
||||
{new Date(item.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
{item.task ? ` · ${item.task}` : ""}
|
||||
{item.status === "abandoned" ? " · abandoned" : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: `${start * 100}%`,
|
||||
width: `calc(${Math.max(width * 100, 1)}% - 2px)`,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
borderRadius: "4px",
|
||||
bgcolor: kindColor(item.kind, palette),
|
||||
opacity: item.status === "abandoned" ? 0.4 : 1,
|
||||
border: item.status === "abandoned" ? `1px dashed ${palette.axis}` : "none",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{String(first).padStart(2, "0")}:00
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{String(last).padStart(2, "0")}:00
|
||||
</Typography>
|
||||
</Stack>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user