Merge pull request 'feat(work-time): редизайн модалки Time worked — таймлайны с временем суток (#566)' (#575) from feat/566-worktime-modal-redesign into develop

Reviewed-on: #575
This commit was merged in pull request #575.
This commit is contained in:
2026-07-12 20:21:53 +03:00
8 changed files with 524 additions and 341 deletions
-234
View File
@@ -1,234 +0,0 @@
/**
* TimeWorkedModal — редизайн окна «Time worked on this article».
* Mantine v7. Light/dark.
*
* ─────────────────────────────────────────────────────────────────────────
* ЧТО ЭТО
* ─────────────────────────────────────────────────────────────────────────
* Сводка трудозатрат по дням в виде суточных таймлайнов. Главное отличие от
* старой версии: на барах ВИДНО время суток. Реализовано двумя способами
* (проп `axis`):
* - 'grid' — ось часов 00–06–12–18–24 сверху + вертикальные деления,
* ночные часы (0–6, 21–24) слегка затемнены. По умолчанию.
* - 'phases' — цветные полосы фаз дня за блоками (Ночь/Утро/День/Вечер),
* период суток читается сразу, без счёта делений.
*
* Блоки позиционируются по времени: left = start/24, width = dur/24.
* Интенсивность (opacity) блока = длительность/плотность работы, чтобы
* очень короткие сессии не терялись (минимальная видимая ширина задана).
* Hover-тултип на блоке: начало–конец · длительность.
*
* ─────────────────────────────────────────────────────────────────────────
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
* ─────────────────────────────────────────────────────────────────────────
* import { TimeWorkedModal, DaySummary } from './TimeWorkedModal';
*
* <TimeWorkedModal
* opened={open}
* onClose={() => setOpen(false)}
* totalLabel="≈ 34h"
* agentLabel="≈ 1h 20m" // undefined → строку agent не показываем
* days={days} // DaySummary[]
* axis="grid" // 'grid' | 'phases'
* tz="Europe/Moscow"
* inactivityGapMin={15}
* />
*
* Требует MantineProvider на корне.
*
* ─────────────────────────────────────────────────────────────────────────
* ФОРМАТ ДАННЫХ
* ─────────────────────────────────────────────────────────────────────────
* interface Block {
* start: number; // час начала в сутках, 0..24 (напр. 13.7 = 13:42)
* end: number; // час конца
* kind: 'work' | 'agent';
* }
* interface DaySummary {
* label: string; // «Mon 29 Jun»
* totalLabel: string; // «1h 24m» | «—» для пустого дня
* blocks: Block[]; // [] → пустой день (полупрозрачная дорожка, итог «—»)
* isToday?: boolean; // сегодня — неполные сутки (рисуем границу «сейчас»)
* nowFraction?: number;// 0..1 позиция «сейчас» для isToday
* }
*
* Данные уже есть в бэкенде трудозатрат: сессии с началом/концом + тип
* (work/agent). Часы = локальные к tz. Изменений API не требуется.
*
* ─────────────────────────────────────────────────────────────────────────
* СОСТОЯНИЯ (нарисованы/поддержаны)
* ─────────────────────────────────────────────────────────────────────────
* - День: с активностью (work+agent) / только work / только agent / пустой (—).
* - Сегодня: граница «сейчас» на дорожке (nowFraction).
* - Короткий блок: минимальная ширина, не исчезает.
* - Пустая панель целиком: нет трудозатрат → EmptyState.
* - Длинный период: вертикальный скролл списка дней, шапка/легенда липкие.
* - Тёмная тема: токены Mantine.
*/
import { Modal, Box, Group, Stack, Text, ActionIcon, ScrollArea, Tooltip, useMantineColorScheme } from '@mantine/core';
/* ─────────────────────────── Типы ─────────────────────────── */
export interface Block { start: number; end: number; kind: 'work' | 'agent'; }
export interface DaySummary {
label: string;
totalLabel: string;
blocks: Block[];
isToday?: boolean;
nowFraction?: number;
}
export interface TimeWorkedModalProps {
opened: boolean;
onClose: () => void;
totalLabel: string;
agentLabel?: string;
days: DaySummary[];
axis?: 'grid' | 'phases';
tz?: string;
inactivityGapMin?: number;
}
const WORK = '#3b82f6';
const AGENT = '#c026d3';
const PHASES = [
{ name: 'Ночь', s: 0, e: 6, bg: 'rgba(99,102,241,.10)', lg: '#e8eaff', fg: '#5b60c9' },
{ name: 'Утро', s: 6, e: 12, bg: 'rgba(245,159,0,.10)', lg: '#fff2dc', fg: '#b5820e' },
{ name: 'День', s: 12, e: 18, bg: 'rgba(56,178,172,.10)', lg: '#dcf5f2', fg: '#1a857d' },
{ name: 'Вечер', s: 18, e: 24, bg: 'rgba(139,92,246,.11)', lg: '#efe7ff', fg: '#6d43c0' },
];
/* ─────────────────────────── Блок активности ─────────────────────────── */
function fmtHour(h: number) {
const hh = Math.floor(h);
const mm = Math.round((h - hh) * 60);
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
}
function fmtDur(h: number) {
const total = Math.round(h * 60);
const hh = Math.floor(total / 60), mm = total % 60;
return hh ? `${hh}h ${mm}m` : `${mm}m`;
}
function ActivityBlock({ b }: { b: Block }) {
const left = (b.start / 24) * 100;
const width = Math.max(((b.end - b.start) / 24) * 100, 0.6);
const dur = b.end - b.start;
const opacity = dur < 0.16 ? 0.5 : dur < 0.35 ? 0.8 : 1;
return (
<Tooltip label={`${fmtHour(b.start)}${fmtHour(b.end)} · ${fmtDur(dur)}`} withArrow openDelay={120} fz={11}>
<Box style={{
position: 'absolute', top: 5, height: 14, left: `${left}%`, width: `${width}%`,
borderRadius: 2, background: b.kind === 'agent' ? AGENT : WORK, opacity, cursor: 'default',
}} />
</Tooltip>
);
}
/* ─────────────────────────── Дорожка дня ─────────────────────────── */
function DayTrack({ d, axis, dark }: { d: DaySummary; axis: 'grid' | 'phases'; dark: boolean }) {
const trackBg = axis === 'grid'
? 'linear-gradient(90deg, rgba(90,100,130,.10) 0 25%, rgba(90,100,130,.02) 25% 87.5%, rgba(90,100,130,.10) 87.5% 100%)'
: (dark ? 'rgba(255,255,255,.04)' : '#f6f8fa');
return (
<Group gap={0} h={30} wrap="nowrap">
<Text style={{ flex: 'none', width: 92 }} fz={13} c="dimmed">{d.label}</Text>
<Box style={{ position: 'relative', flex: 1, height: 24, margin: '0 4px', borderRadius: 5, overflow: 'hidden', background: trackBg }}>
{/* фон: полосы фаз или деления сетки */}
{axis === 'phases'
? PHASES.map((ph) => (
<Box key={ph.name} style={{ position: 'absolute', top: 0, bottom: 0, left: `${(ph.s / 24) * 100}%`, width: `${((ph.e - ph.s) / 24) * 100}%`, background: ph.bg }} />
))
: [25, 50, 75].map((p) => (
<Box key={p} style={{ position: 'absolute', top: 0, bottom: 0, left: `${p}%`, width: 1, background: 'rgba(120,130,150,.16)' }} />
))}
{/* блоки */}
{d.blocks.map((b, i) => <ActivityBlock key={i} b={b} />)}
{/* граница «сейчас» для сегодняшнего дня */}
{d.isToday && d.nowFraction != null && (
<Box style={{ position: 'absolute', top: 0, bottom: 0, left: `${d.nowFraction * 100}%`, width: 2, background: '#fa5252' }} />
)}
</Box>
<Text style={{ flex: 'none', width: 64, textAlign: 'right' }} fz={12.5} fw={500} c={d.totalLabel === '—' ? 'dimmed' : undefined}>
{d.totalLabel}
</Text>
</Group>
);
}
/* ─────────────────────────── Окно ─────────────────────────── */
export function TimeWorkedModal(props: TimeWorkedModalProps) {
const { opened, onClose, totalLabel, agentLabel, days, axis = 'grid', tz = 'Europe/Moscow', inactivityGapMin = 15 } = props;
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
const isEmpty = days.length === 0 || days.every((d) => d.blocks.length === 0);
return (
<Modal opened={opened} onClose={onClose} withCloseButton={false} size="46rem" radius="lg" padding={0}
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}>
<Box p="22px 24px 20px">
{/* header */}
<Group mb={14}>
<Text fz={17} fw={600}>Time worked on this article</Text>
<Box style={{ flex: 1 }} />
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}></ActionIcon>
</Group>
{/* summary */}
<Group align="baseline" gap={16} mb={axis === 'grid' ? 8 : 16}>
<Text fz={22} fw={700}>{totalLabel}</Text>
{agentLabel && <Text fz={13} c="dimmed">agent: {agentLabel}</Text>}
</Group>
{/* legend (grid only) */}
{axis === 'grid' && (
<Group gap={16} mb={16}>
<Group gap={6}><Box w={12} h={12} style={{ borderRadius: 3, background: WORK }} /><Text fz={12} fw={500} c="dimmed">Work</Text></Group>
<Group gap={6}><Box w={12} h={12} style={{ borderRadius: 3, background: AGENT }} /><Text fz={12} fw={500} c="dimmed">Agent</Text></Group>
</Group>
)}
{isEmpty ? (
<Stack align="center" gap={6} p="48px 20px">
<Text fw={600} fz={14}>No time tracked yet</Text>
<Text fz={12.5} c="dimmed" ta="center">По этой статье ещё нет трудозатрат.</Text>
</Stack>
) : (
<>
{/* axis header (sticky) */}
<Group gap={0} mb={5} wrap="nowrap" style={{ position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
<Box style={{ flex: 'none', width: 92 }} />
{axis === 'grid' ? (
<Box style={{ flex: 1, position: 'relative', height: 14, margin: '0 4px' }}>
{[['0%', '00', 'flex-start'], ['25%', '06', 'center'], ['50%', '12', 'center'], ['75%', '18', 'center'], ['100%', '24', 'flex-end']].map(([l, t, al]) => (
<Text key={t as string} fz={10} fw={500} c="dimmed" style={{ position: 'absolute', left: l as string, transform: al === 'center' ? 'translateX(-50%)' : al === 'flex-end' ? 'translateX(-100%)' : undefined }}>{t}</Text>
))}
</Box>
) : (
<Box style={{ flex: 1, display: 'flex', height: 16, margin: '0 4px', borderRadius: 4, overflow: 'hidden' }}>
{PHASES.map((ph) => (
<Box key={ph.name} style={{ flex: ph.e - ph.s, display: 'flex', alignItems: 'center', justifyContent: 'center', background: ph.lg, color: ph.fg, font: '600 9.5px system-ui' }}>{ph.name}</Box>
))}
</Box>
)}
<Box style={{ flex: 'none', width: 64 }} />
</Group>
{/* day rows */}
<ScrollArea.Autosize mah="60vh" type="hover">
{days.map((d, i) => <DayTrack key={i} d={d} axis={axis} dark={dark} />)}
</ScrollArea.Autosize>
</>
)}
<Text mt={16} fz={11.5} c="dimmed">Estimate · timezone {tz} · inactivity gap {inactivityGapMin} min</Text>
</Box>
</Modal>
);
}
export default TimeWorkedModal;
@@ -1464,6 +1464,7 @@
"agent: {{value}}": "agent: {{value}}",
"Work": "Work",
"Agent": "Agent",
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
"≈ {{hours}}h": "≈ {{hours}}h",
"≈ {{minutes}}m": "≈ {{minutes}}m",
@@ -1481,6 +1481,7 @@
"agent: {{value}}": "агент: {{value}}",
"Work": "Работа",
"Agent": "Агент",
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
"≈ {{hours}}h": "≈ {{hours}} ч",
"≈ {{minutes}}m": "≈ {{minutes}} мин",
@@ -0,0 +1,168 @@
import { describe, it, expect } from "vitest";
import {
buildRows,
formatBlockTooltip,
summaryLabels,
toBlocks,
toTimelineDay,
EMPTY_RUN_COLLAPSE,
} from "./work-time-adapter";
import { IPageWorkTime, IPerDay } from "./work-time.types";
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
const DAY_MS = 24 * HOUR;
// Fake translator: renders the key with {{tokens}} substituted, so the tests
// assert the mapping/branching without depending on the i18n catalogue.
const t = (key: string, opts?: Record<string, unknown>) =>
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
// A day midnight anchored at a fixed UTC instant (tz handled server-side; we
// only lay out fractions of the day the server already bucketed).
const DAY0 = Date.UTC(2026, 5, 29, 0, 0, 0); // Mon 29 Jun 2026 00:00 UTC
function day(over: Partial<IPerDay> & { day: number }): IPerDay {
return {
dayISO: new Date(over.day).toISOString().slice(0, 10),
activeMs: 0,
agentMs: 0,
windows: [],
...over,
};
}
const config: IPageWorkTime["config"] = {
tGap: 15 * MIN,
agentTGap: 15 * MIN,
pIn: 0,
pOut: 0,
pSingle: 30 * 1000,
excludeGit: false,
dedupRoundMs: 1000,
};
function payload(over: Partial<IPageWorkTime>): IPageWorkTime {
return {
workMs: 0,
agentOnlyMs: 0,
perDay: [],
config,
tz: "UTC",
...over,
};
}
describe("toBlocks", () => {
it("maps work+agent windows to time-of-day segments (hour fractions)", () => {
const d = day({
day: DAY0,
activeMs: 90 * MIN,
windows: [
{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" },
{ start: DAY0 + 22 * HOUR, end: DAY0 + 23 * HOUR, class: "agent_only" },
],
});
const blocks = toBlocks(d);
expect(blocks).toHaveLength(2);
expect(blocks[0]).toMatchObject({ start: 9, end: 10.5, kind: "work" });
expect(blocks[1]).toMatchObject({ start: 22, end: 23, kind: "agent" });
// real epoch preserved for the DST-safe tooltip
expect(blocks[0].startEpoch).toBe(DAY0 + 9 * HOUR);
});
it("clamps out-of-day fractions to [0,24]", () => {
const d = day({
day: DAY0,
windows: [{ start: DAY0 - HOUR, end: DAY0 + 25 * HOUR, class: "work" }],
});
const [b] = toBlocks(d);
expect(b.start).toBe(0);
expect(b.end).toBe(24);
});
});
describe("toTimelineDay", () => {
it("draws the now-line only on today's row", () => {
const today = day({ day: DAY0, activeMs: HOUR });
const noon = DAY0 + 12 * HOUR;
const asToday = toTimelineDay(today, t, noon);
expect(asToday.isToday).toBe(true);
expect(asToday.nowFraction).toBeCloseTo(0.5, 5);
const past = day({ day: DAY0 - DAY_MS, activeMs: HOUR });
const asPast = toTimelineDay(past, t, noon);
expect(asPast.isToday).toBe(false);
expect(asPast.nowFraction).toBeUndefined();
});
it("labels an empty day and totals it as —", () => {
const empty = day({ day: DAY0 });
const d = toTimelineDay(empty, t, DAY0 + 12 * HOUR);
expect(d.isEmpty).toBe(true);
expect(d.totalLabel).toBe("—");
});
});
describe("buildRows (empty-run collapsing)", () => {
it("collapses a run of >= EMPTY_RUN_COLLAPSE empty days in place", () => {
const perDay: IPerDay[] = [
day({ day: DAY0, activeMs: HOUR }),
...Array.from({ length: EMPTY_RUN_COLLAPSE }, (_, i) =>
day({ day: DAY0 + (i + 1) * DAY_MS }),
),
day({ day: DAY0 + (EMPTY_RUN_COLLAPSE + 1) * DAY_MS, activeMs: HOUR }),
];
const rows = buildRows(perDay, t, 0);
expect(rows.map((r) => r.type)).toEqual(["day", "gap", "day"]);
const gap = rows[1];
expect(gap.type === "gap" && gap.count).toBe(EMPTY_RUN_COLLAPSE);
});
it("keeps a short empty run as individual dimmed day rows", () => {
const perDay: IPerDay[] = [
day({ day: DAY0, activeMs: HOUR }),
day({ day: DAY0 + DAY_MS }),
day({ day: DAY0 + 2 * DAY_MS, activeMs: HOUR }),
];
const rows = buildRows(perDay, t, 0);
expect(rows.map((r) => r.type)).toEqual(["day", "day", "day"]);
});
});
describe("summaryLabels", () => {
it("derives totalLabel/agentLabel from ms via the shared formatter", () => {
const { total, agent } = summaryLabels(
payload({ workMs: 4 * HOUR + 27 * MIN, agentOnlyMs: 80 * MIN }),
t,
);
expect(total).toBe("≈ 4h 25m");
expect(agent).toBe("≈ 1h 20m");
});
it("agent-only page: agent estimate fills the main slot, no secondary line", () => {
const { total, agent } = summaryLabels(
payload({ workMs: 0, agentOnlyMs: 80 * MIN }),
t,
);
expect(total).toBe("agent: ≈ 1h 20m");
expect(agent).toBeUndefined();
});
it("human-only page: no agent line", () => {
const { agent } = summaryLabels(payload({ workMs: HOUR, agentOnlyMs: 0 }), t);
expect(agent).toBeUndefined();
});
});
describe("formatBlockTooltip", () => {
it("formats start–end from the real epoch in the data tz + duration", () => {
const d = day({
day: DAY0,
windows: [{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" }],
});
const [b] = toBlocks(d);
const label = formatBlockTooltip(b, "UTC", "en-US", t);
expect(label).toBe("09:00 – 10:30 · 1h 30m");
});
});
@@ -0,0 +1,168 @@
// #566 — adapter: real IPageWorkTime → the render-ready shape the redesigned
// time-of-day timeline consumes. The visual prototype (NewDesign/TimeWorkedModal)
// was written against invented props (`DaySummary[]`, pre-made `totalLabel`); the
// real payload is IPageWorkTime. Everything below is pure so the mapping (windows
// → time-of-day blocks, ms → labels, the "now" boundary, empty-run collapsing) is
// unit-testable without React. No re-bucketing: the server already grouped the
// windows by the request timezone — we only lay out the windows it returned.
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
import { formatDayTotal, formatHeadline } from "./format-work-time";
type Translate = (key: string, opts?: Record<string, unknown>) => string;
export const DAY_MS = 24 * 60 * 60 * 1000;
// Collapse a run of this many (or more) consecutive edit-free days into a single
// "× N days" separator (§6.2 long-range) — preserved from the original punch-card.
export const EMPTY_RUN_COLLAPSE = 8;
export interface TimelineBlock {
/** Hour fraction 0..24 within the day — drives left/width positioning. */
start: number;
end: number;
kind: "work" | "agent";
/** Real epoch ms, kept for a DST-safe tooltip (formatted in the data tz). */
startEpoch: number;
endEpoch: number;
}
export interface TimelineDay {
key: string;
label: string;
totalLabel: string;
blocks: TimelineBlock[];
isEmpty: boolean;
/** Today lives only in the last active bucket; drives the "now" boundary. */
isToday: boolean;
/** 0..1 position of "now" within today's track (undefined when not today). */
nowFraction?: number;
}
export type TimelineRow =
| { type: "day"; day: TimelineDay }
| { type: "gap"; count: number };
/** Weekday-day-month heading in the browser locale (matches the server tz
* bucketing, since usePageWorkTime requests buckets in the viewer tz). */
export function dayHeading(day: number): string {
return new Date(day).toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
});
}
/** Map one day's absolute windows into time-of-day blocks. `class` work|agent_only
* → kind work|agent; positions are the in-day hour fraction (epoch kept for the
* tooltip). Fractions are clamped to [0,24] to match the original punch-card. */
export function toBlocks(day: IPerDay): TimelineBlock[] {
return day.windows.map((w: IDayWindow) => {
const start = clampHour((w.start - day.day) / (DAY_MS / 24));
const end = clampHour((w.end - day.day) / (DAY_MS / 24));
return {
start,
end,
kind: w.class === "work" ? "work" : "agent",
startEpoch: w.start,
endEpoch: w.end,
};
});
}
function clampHour(h: number): number {
return Math.max(0, Math.min(24, h));
}
/** Build a single render-ready day. `now` is injected for testability; the
* "now" boundary is drawn only when `now` falls inside this bucket's calendar
* day (so it appears on today's row and only when today has edits). */
export function toTimelineDay(
day: IPerDay,
t: Translate,
now: number,
): TimelineDay {
const isToday = now >= day.day && now < day.day + DAY_MS;
return {
key: day.dayISO,
label: dayHeading(day.day),
totalLabel: formatDayTotal(day.activeMs, t),
blocks: toBlocks(day),
isEmpty: day.activeMs === 0 && day.agentMs === 0,
isToday,
nowFraction: isToday ? (now - day.day) / DAY_MS : undefined,
};
}
/** Collapse long edit-free runs (≥ EMPTY_RUN_COLLAPSE) into an in-place "gap"
* row; short runs stay as (dimmed, "—") day rows. Preserved from the original
* punch-card so a page edited over months does not render hundreds of rows. */
export function buildRows(
perDay: IPerDay[],
t: Translate,
now: number,
): TimelineRow[] {
const rows: TimelineRow[] = [];
let emptyRun: IPerDay[] = [];
const flush = () => {
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
rows.push({ type: "gap", count: emptyRun.length });
} else {
for (const d of emptyRun) {
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
}
}
emptyRun = [];
};
for (const d of perDay) {
if (d.activeMs === 0 && d.agentMs === 0) {
emptyRun.push(d);
} else {
flush();
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
}
}
flush();
return rows;
}
/** The big summary slot. Fail-safe for an agent-only page (#395/#551): since
* formatHeadline(0) === "", never leave the 22px slot empty — put the agent
* estimate in the main slot, and only show the secondary `agent:` line when
* BOTH a human and an agent estimate exist. */
export function summaryLabels(
data: IPageWorkTime,
t: Translate,
): { total: string; agent?: string } {
const total =
data.workMs > 0
? formatHeadline(data.workMs, t)
: t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) });
const agent =
data.workMs > 0 && data.agentOnlyMs > 0
? formatHeadline(data.agentOnlyMs, t)
: undefined;
return { total, agent };
}
/** Block hover label "start – end · duration". Times come from the REAL epoch
* rendered in the data tz (NOT the 24h fraction) so a DST-transition day does
* not skew the shown clock time. Duration reuses formatDayTotal (always > 0
* here, so never "—"). */
export function formatBlockTooltip(
block: TimelineBlock,
tz: string,
locale: string,
t: Translate,
): string {
const fmt = new Intl.DateTimeFormat(locale || undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: tz,
});
return t("{{start}} – {{end}} · {{duration}}", {
start: fmt.format(block.startEpoch),
end: fmt.format(block.endEpoch),
duration: formatDayTotal(block.endEpoch - block.startEpoch, t),
});
}
@@ -1,97 +1,86 @@
import { Group, Stack, Text } from "@mantine/core";
// #566 — redesigned "Time worked" body: daily time-of-day timelines. Each day is
// a 24h track showing WHEN the work happened — a sticky 00/06/12/18/24 hour axis,
// shaded night hours, per-block hover tooltip ("start – end · duration"), and a
// "now" boundary on today's row. Presentation ported from NewDesign/TimeWorkedModal;
// all data comes through the pure adapter over the real IPageWorkTime (zero backend
// change). Positioning math, empty-run collapsing and the formatters are reused.
import { Box, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
import { IPageWorkTime } from "./work-time.types";
import { formatGapMinutes } from "./format-work-time";
import {
formatDayTotal,
formatGapMinutes,
formatHeadline,
} from "./format-work-time";
buildRows,
formatBlockTooltip,
summaryLabels,
TimelineBlock,
TimelineDay,
} from "./work-time-adapter";
import classes from "./work-time.module.css";
const DAY_MS = 24 * 60 * 60 * 1000;
// Collapse a run of this many (or more) consecutive edit-free days into a single
// "× N days" separator (§6.2 long-range) — the row is still always one day.
const EMPTY_RUN_COLLAPSE = 8;
// Minimum visible width so a very short session neither vanishes nor fakes dense
// work (§6.2); kept from the original punch-card.
const MIN_BLOCK_WIDTH_PCT = 0.6;
type Row =
| { type: "day"; day: IPerDay }
| { type: "gap"; count: number };
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
const rows: Row[] = [];
let emptyRun: IPerDay[] = [];
const flush = () => {
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
rows.push({ type: "gap", count: emptyRun.length });
} else {
for (const d of emptyRun) rows.push({ type: "day", day: d });
}
emptyRun = [];
};
for (const d of perDay) {
if (d.activeMs === 0 && d.agentMs === 0) {
emptyRun.push(d);
} else {
flush();
rows.push({ type: "day", day: d });
}
}
flush();
return rows;
}
function dayHeading(day: number): string {
return new Date(day).toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
});
function ActivityBlock({
block,
tz,
locale,
}: {
block: TimelineBlock;
tz: string;
locale: string;
}) {
const { t } = useTranslation();
const left = (block.start / 24) * 100;
const width = Math.max(((block.end - block.start) / 24) * 100, MIN_BLOCK_WIDTH_PCT);
const cls = [
classes.window,
block.kind === "work" ? classes.windowWork : classes.windowAgent,
].join(" ");
return (
<Tooltip
label={formatBlockTooltip(block, tz, locale, t)}
withArrow
openDelay={120}
fz={11}
>
<div className={cls} style={{ left: `${left}%`, width: `${width}%` }} />
</Tooltip>
);
}
function DayTrack({
day,
pSingle,
tz,
locale,
}: {
day: IPerDay;
pSingle: number;
day: TimelineDay;
tz: string;
locale: string;
}) {
const { t } = useTranslation();
const ticks = [6, 12, 18];
return (
<div className={classes.row}>
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
<div className={classes.track}>
{ticks.map((h) => (
<div
key={h}
className={classes.hourTick}
style={{ left: `${(h / 24) * 100}%` }}
/>
<span className={classes.dayLabel}>{day.label}</span>
<div className={`${classes.track} ${day.isEmpty ? classes.trackEmpty : ""}`}>
{[25, 50, 75].map((p) => (
<div key={p} className={classes.hourTick} style={{ left: `${p}%` }} />
))}
{day.windows.map((w: IDayWindow, i) => {
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
const isSingle = w.end - w.start <= pSingle;
const cls = [
classes.window,
w.class === "work" ? classes.windowWork : classes.windowAgent,
isSingle ? classes.windowSingle : "",
].join(" ");
return (
<div
key={i}
className={cls}
style={{
left: `${Math.max(0, Math.min(100, leftPct))}%`,
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}}
/>
);
})}
{day.blocks.map((b, i) => (
<ActivityBlock key={i} block={b} tz={tz} locale={locale} />
))}
{day.isToday && day.nowFraction != null && (
<div
className={classes.nowLine}
style={{ left: `${day.nowFraction * 100}%` }}
/>
)}
</div>
<span className={classes.daySum}>
{formatDayTotal(day.activeMs, t)}
<span
className={classes.daySum}
data-empty={day.totalLabel === "—" ? true : undefined}
>
{day.totalLabel}
</span>
</div>
);
@@ -102,8 +91,16 @@ interface Props {
}
export default function WorkTimePunchCard({ data }: Props) {
const { t } = useTranslation();
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
const { t, i18n } = useTranslation();
const locale = i18n.language;
const now = Date.now();
const rows = useMemo(
() => buildRows(data.perDay, t, now),
// `now` intentionally re-read on each open; excluded so the memo tracks data.
// eslint-disable-next-line react-hooks/exhaustive-deps
[data.perDay, t],
);
const { total, agent } = summaryLabels(data, t);
const gapMin = formatGapMinutes(data.config.tGap);
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
@@ -116,17 +113,19 @@ export default function WorkTimePunchCard({ data }: Props) {
return (
<Stack gap="xs">
<Group gap="lg">
<Text size="sm" fw={500}>
{formatHeadline(data.workMs, t)}
{/* summary */}
<Group align="baseline" gap="md">
<Text fz={22} fw={700}>
{total}
</Text>
{data.agentOnlyMs > 0 && (
{agent && (
<Text size="xs" c="dimmed">
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
{t("agent: {{value}}", { value: agent })}
</Text>
)}
</Group>
{/* legend */}
<Group gap="md">
<Text size="xs" c="dimmed">
<span
@@ -144,21 +143,47 @@ export default function WorkTimePunchCard({ data }: Props) {
</Text>
</Group>
<div>
{/* sticky hour axis */}
<div className={`${classes.row} ${classes.axisRow}`}>
<span />
<div className={classes.axis}>
{[
["0%", "00", "start"],
["25%", "06", "center"],
["50%", "12", "center"],
["75%", "18", "center"],
["100%", "24", "end"],
].map(([l, label, align]) => (
<span
key={label}
className={classes.axisTick}
data-align={align}
style={{ left: l }}
>
{label}
</span>
))}
</div>
<span />
</div>
{/* day rows */}
<ScrollArea.Autosize mah="60vh" type="hover">
{rows.map((row, i) =>
row.type === "day" ? (
<DayTrack
key={row.day.dayISO}
key={row.day.key}
day={row.day}
pSingle={data.config.pSingle}
tz={data.tz}
locale={locale}
/>
) : (
<div key={`gap-${i}`} className={classes.gapRow}>
<Box key={`gap-${i}`} className={classes.gapRow}>
{t("× {{count}} days without edits", { count: row.count })}
</div>
</Box>
),
)}
</div>
</ScrollArea.Autosize>
<Text size="xs" c="dimmed" mt="xs">
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
@@ -60,7 +60,7 @@ export default function WorkTimeStat({ pageId }: Props) {
opened={opened}
onClose={close}
title={t("Time worked on this article")}
size="lg"
size="46rem"
>
<WorkTimePunchCard data={data} />
</Modal>
@@ -1,5 +1,7 @@
/* #395 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
/* #566 time-of-day timeline. Custom CSS segments on a fixed 24-hour track
(position = offset-in-day / 24h, width = duration / 24h), no chart library.
Night hours (06, 2124) are shaded so the eye reads morning-vs-evening; a
sticky hour axis labels 00/06/12/18/24. Theme-aware via Mantine tokens. */
.row {
display: grid;
@@ -16,17 +18,29 @@
}
.track {
--wt-night: rgba(90, 100, 130, 0.16);
--wt-day: rgba(90, 100, 130, 0.03);
position: relative;
height: 16px;
border-radius: 4px;
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
height: 20px;
border-radius: 5px;
overflow: hidden;
/* base fill + night shading: 0–6h and 21–24h (87.5%) darker than daytime */
background:
linear-gradient(
90deg,
var(--wt-night) 0 25%,
var(--wt-day) 25% 87.5%,
var(--wt-night) 87.5% 100%
),
light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
/* Faint hour grid so the eye can read "morning vs evening". */
/* Short (< EMPTY_RUN_COLLAPSE) edit-free days: dimmed, empty track. */
.trackEmpty {
opacity: 0.5;
}
/* Quarter-day grid divisions (06/12/18) inside the track. */
.hourTick {
position: absolute;
top: 0;
@@ -40,10 +54,11 @@
.window {
position: absolute;
top: 2px;
bottom: 2px;
top: 3px;
height: 14px;
border-radius: 3px;
min-width: 3px;
cursor: default;
}
.windowWork {
@@ -54,18 +69,57 @@
background-color: var(--mantine-color-grape-5);
}
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
vanishes nor fakes dense work (§6.2). */
.windowSingle {
opacity: 0.5;
/* The "now" boundary on today's row. */
.nowLine {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--mantine-color-red-6);
}
.daySum {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
text-align: right;
white-space: nowrap;
}
.daySum[data-empty] {
color: var(--mantine-color-dimmed);
font-weight: 400;
}
/* Sticky hour axis header aligned to the track column. */
.axisRow {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--mantine-color-body);
padding-bottom: 2px;
}
.axis {
position: relative;
height: 14px;
}
.axisTick {
position: absolute;
top: 0;
font-size: 10px;
font-weight: 500;
color: var(--mantine-color-dimmed);
}
.axisTick[data-align="center"] {
transform: translateX(-50%);
}
.axisTick[data-align="end"] {
transform: translateX(-100%);
}
.gapRow {
padding: 6px 0 6px 108px;
font-size: var(--mantine-font-size-xs);