From 3cda0080127f79cf43d5423b8078a83f7380fbe8 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 19:32:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(work-time):=20redesign=20=C2=ABTime=20work?= =?UTF-8?q?ed=C2=BB=20modal=20with=20time-of-day=20timelines=20(#566)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the NewDesign/TimeWorkedModal prototype onto the existing work-time feature with zero backend changes. Each daily track now shows WHEN work happened: a sticky 00/06/12/18/24 hour axis, shaded night hours (0–6, 21–24), a per-block hover tooltip "start – end · duration", and a "now" boundary on today's row. Work vs agent windows keep their existing colour semantics. The prototype's invented props (DaySummary[], pre-made labels) are replaced by a pure, unit-tested adapter (work-time-adapter.ts) over the real IPageWorkTime: windows → time-of-day blocks (epoch kept for a DST-safe tooltip), ms → labels via the shared formatters, in-place empty-run collapsing, and the today-only now-line. The agent-only fail-safe (#395/#551) is preserved — the agent estimate fills the main slot so it never renders empty. Reuses usePageWorkTime, the endpoint, tz bucketing and format-work-time; deletes the scratch prototype. New tooltip i18n key added to en-US/ru-RU. Co-Authored-By: Claude Opus 4.8 (1M context) --- NewDesign/TimeWorkedModal.tsx | 234 ------------------ .../public/locales/en-US/translation.json | 1 + .../public/locales/ru-RU/translation.json | 1 + .../work-time/work-time-adapter.test.ts | 168 +++++++++++++ .../work-time/work-time-adapter.ts | 168 +++++++++++++ .../work-time/work-time-punch-card.tsx | 207 +++++++++------- .../page-history/work-time/work-time-stat.tsx | 2 +- .../work-time/work-time.module.css | 84 +++++-- 8 files changed, 524 insertions(+), 341 deletions(-) delete mode 100644 NewDesign/TimeWorkedModal.tsx create mode 100644 apps/client/src/features/page-history/work-time/work-time-adapter.test.ts create mode 100644 apps/client/src/features/page-history/work-time/work-time-adapter.ts diff --git a/NewDesign/TimeWorkedModal.tsx b/NewDesign/TimeWorkedModal.tsx deleted file mode 100644 index 2866e2a3..00000000 --- a/NewDesign/TimeWorkedModal.tsx +++ /dev/null @@ -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'; - * - * 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 ( - - - - ); -} - -/* ─────────────────────────── Дорожка дня ─────────────────────────── */ - -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 ( - - {d.label} - - {/* фон: полосы фаз или деления сетки */} - {axis === 'phases' - ? PHASES.map((ph) => ( - - )) - : [25, 50, 75].map((p) => ( - - ))} - {/* блоки */} - {d.blocks.map((b, i) => )} - {/* граница «сейчас» для сегодняшнего дня */} - {d.isToday && d.nowFraction != null && ( - - )} - - - {d.totalLabel} - - - ); -} - -/* ─────────────────────────── Окно ─────────────────────────── */ - -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 ( - - - {/* header */} - - Time worked on this article - - - - - {/* summary */} - - {totalLabel} - {agentLabel && agent: {agentLabel}} - - - {/* legend (grid only) */} - {axis === 'grid' && ( - - Work - Agent - - )} - - {isEmpty ? ( - - No time tracked yet - По этой статье ещё нет трудозатрат. - - ) : ( - <> - {/* axis header (sticky) */} - - - {axis === 'grid' ? ( - - {[['0%', '00', 'flex-start'], ['25%', '06', 'center'], ['50%', '12', 'center'], ['75%', '18', 'center'], ['100%', '24', 'flex-end']].map(([l, t, al]) => ( - {t} - ))} - - ) : ( - - {PHASES.map((ph) => ( - {ph.name} - ))} - - )} - - - - {/* day rows */} - - {days.map((d, i) => )} - - - )} - - Estimate · timezone {tz} · inactivity gap {inactivityGapMin} min - - - ); -} - -export default TimeWorkedModal; diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bacdbd36..0b90b2c7 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 49d6fe61..2aa13e5b 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -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}} мин", diff --git a/apps/client/src/features/page-history/work-time/work-time-adapter.test.ts b/apps/client/src/features/page-history/work-time/work-time-adapter.test.ts new file mode 100644 index 00000000..ce864444 --- /dev/null +++ b/apps/client/src/features/page-history/work-time/work-time-adapter.test.ts @@ -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) => + 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 & { 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 { + 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"); + }); +}); diff --git a/apps/client/src/features/page-history/work-time/work-time-adapter.ts b/apps/client/src/features/page-history/work-time/work-time-adapter.ts new file mode 100644 index 00000000..5ee9e5be --- /dev/null +++ b/apps/client/src/features/page-history/work-time/work-time-adapter.ts @@ -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; + +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), + }); +} diff --git a/apps/client/src/features/page-history/work-time/work-time-punch-card.tsx b/apps/client/src/features/page-history/work-time/work-time-punch-card.tsx index a1285d2d..043a8a71 100644 --- a/apps/client/src/features/page-history/work-time/work-time-punch-card.tsx +++ b/apps/client/src/features/page-history/work-time/work-time-punch-card.tsx @@ -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 ( + +
+ + ); } 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 (
- {dayHeading(day.day)} -
- {ticks.map((h) => ( -
+ {day.label} +
+ {[25, 50, 75].map((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 ( -
- ); - })} + {day.blocks.map((b, i) => ( + + ))} + {day.isToday && day.nowFraction != null && ( +
+ )}
- - {formatDayTotal(day.activeMs, t)} + + {day.totalLabel}
); @@ -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 ( - - - {formatHeadline(data.workMs, t)} + {/* summary */} + + + {total} - {data.agentOnlyMs > 0 && ( + {agent && ( - {t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })} + {t("agent: {{value}}", { value: agent })} )} + {/* legend */} -
+ {/* sticky hour axis */} +
+ +
+ {[ + ["0%", "00", "start"], + ["25%", "06", "center"], + ["50%", "12", "center"], + ["75%", "18", "center"], + ["100%", "24", "end"], + ].map(([l, label, align]) => ( + + {label} + + ))} +
+ +
+ + {/* day rows */} + {rows.map((row, i) => row.type === "day" ? ( ) : ( -
+ {t("× {{count}} days without edits", { count: row.count })} -
+ ), )} -
+ {t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", { diff --git a/apps/client/src/features/page-history/work-time/work-time-stat.tsx b/apps/client/src/features/page-history/work-time/work-time-stat.tsx index b963456f..8e370a35 100644 --- a/apps/client/src/features/page-history/work-time/work-time-stat.tsx +++ b/apps/client/src/features/page-history/work-time/work-time-stat.tsx @@ -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" > diff --git a/apps/client/src/features/page-history/work-time/work-time.module.css b/apps/client/src/features/page-history/work-time/work-time.module.css index 397d45b7..6dc848e0 100644 --- a/apps/client/src/features/page-history/work-time/work-time.module.css +++ b/apps/client/src/features/page-history/work-time/work-time.module.css @@ -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 (0–6, 21–24) 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);