From 240a59ef3942125f75756c09c0d25e7f33868358 Mon Sep 17 00:00:00 2001 From: jackrescuer-gif Date: Wed, 20 May 2026 10:13:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(digest):=20=D0=B4=D0=B0=D0=B9=D0=B4=D0=B6?= =?UTF-8?q?=D0=B5=D1=81=D1=82=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20wor?= =?UTF-8?q?kspace=20+=20RBAC=20=D0=B8=20ACL=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=20team=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая страница /w/:slug/digest с 4 секциями менеджерских метрик: продуктивность, своевременность, гигиена (score 0-100), нагрузка. ## Backend - модуль modules/digest/ (DTO + dates + access + compute + service + router) - GET /api/workspaces/:wid/digest?scope=personal|team&period=7d|30d|90d - VIEWER → 403 INSUFFICIENT_PERMISSION на scope=team - team scope для MEMBER фильтрует private boards без BoardMember (R6 SDD) - effectiveRole fail-safe → VIEWER для неизвестных role presets (security M2) - workspaceMfaGuard + rateLimit(60/min/user) + UUID param validation - никаких изменений в Prisma schema — агрегаты на лету через TaskStatusHistory ## Frontend - страница DigestPage (/w/:slug/digest) + tab «Дайджест» в AppLayout - useDigest hook с AbortController и graceful 403 auto-switch - 3 компонента (DigestCard, DigestSparkline, WorkloadChart) - WCAG 2.2 AA: dedicated aria-live announcer, h2 hierarchy, sr-only delta, touch targets ≥44, prefers-reduced-motion, responsive sparkline, AntD theme tokens вместо хардкода цветов ## Tests - 47 backend (14 dates + 17 compute + 16 integration) - 15 frontend component (вкл. mobile path для WorkloadChart) - Total: 555 passing, 0 errors ## Deferred (документировано) - E2E Playwright → #229 (требует dev-серверы) - Polish MEDIUM/LOW (29 находок из ревью) → #230 - N+1 ACL в accessibleBoardIdsInWorkspace → Phase 2 cache (plan.md I1) - Topbar tabs accessibility — pre-existing pattern → #230 - Issues #218-228 — Phase 2 roadmap (cycle time history precision, CSV export, email digest, etc.) Связано: docs/design/digest.md, specs/digest.feature, tasks/digest/plan.md --- backend/src/__tests__/digest.test.ts | 347 ++++++++++++++++ backend/src/app.ts | 2 + .../digest/__tests__/digest.compute.test.ts | 238 +++++++++++ .../digest/__tests__/digest.dates.test.ts | 117 ++++++ backend/src/modules/digest/digest.access.ts | 32 ++ backend/src/modules/digest/digest.compute.ts | 200 +++++++++ backend/src/modules/digest/digest.dates.ts | 64 +++ backend/src/modules/digest/digest.dto.ts | 68 +++ backend/src/modules/digest/digest.router.ts | 36 ++ backend/src/modules/digest/digest.service.ts | 246 +++++++++++ backend/src/shared/openapi/index.ts | 1 + backend/src/shared/openapi/routes/digest.ts | 26 ++ docs/design/digest.md | 382 +++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/__tests__/DigestCard.test.tsx | 48 +++ .../src/__tests__/DigestSparkline.test.tsx | 41 ++ frontend/src/__tests__/WorkloadChart.test.tsx | 66 +++ .../__tests__/WorkloadChartMobile.test.tsx | 37 ++ frontend/src/api/digest.ts | 72 ++++ frontend/src/components/AppLayout.tsx | 15 + frontend/src/components/digest/DigestCard.tsx | 54 +++ .../src/components/digest/DigestSparkline.tsx | 113 +++++ .../src/components/digest/WorkloadChart.tsx | 134 ++++++ frontend/src/hooks/useDigest.ts | 83 ++++ frontend/src/pages/DigestPage.tsx | 391 ++++++++++++++++++ specs/digest.feature | 291 +++++++++++++ tasks/digest/plan.md | 328 +++++++++++++++ 27 files changed, 3434 insertions(+) create mode 100644 backend/src/__tests__/digest.test.ts create mode 100644 backend/src/modules/digest/__tests__/digest.compute.test.ts create mode 100644 backend/src/modules/digest/__tests__/digest.dates.test.ts create mode 100644 backend/src/modules/digest/digest.access.ts create mode 100644 backend/src/modules/digest/digest.compute.ts create mode 100644 backend/src/modules/digest/digest.dates.ts create mode 100644 backend/src/modules/digest/digest.dto.ts create mode 100644 backend/src/modules/digest/digest.router.ts create mode 100644 backend/src/modules/digest/digest.service.ts create mode 100644 backend/src/shared/openapi/routes/digest.ts create mode 100644 docs/design/digest.md create mode 100644 frontend/src/__tests__/DigestCard.test.tsx create mode 100644 frontend/src/__tests__/DigestSparkline.test.tsx create mode 100644 frontend/src/__tests__/WorkloadChart.test.tsx create mode 100644 frontend/src/__tests__/WorkloadChartMobile.test.tsx create mode 100644 frontend/src/api/digest.ts create mode 100644 frontend/src/components/digest/DigestCard.tsx create mode 100644 frontend/src/components/digest/DigestSparkline.tsx create mode 100644 frontend/src/components/digest/WorkloadChart.tsx create mode 100644 frontend/src/hooks/useDigest.ts create mode 100644 frontend/src/pages/DigestPage.tsx create mode 100644 specs/digest.feature create mode 100644 tasks/digest/plan.md diff --git a/backend/src/__tests__/digest.test.ts b/backend/src/__tests__/digest.test.ts new file mode 100644 index 0000000..c2c8430 --- /dev/null +++ b/backend/src/__tests__/digest.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { api, auth, registerUser, createWorkspace, createBoard, createTask, cleanupTestData } from './helpers.js'; +import { prisma } from '../prisma/client.js'; + +/** + * Integration tests for GET /api/workspaces/:wid/digest + * + * Maps BDD scenarios from specs/digest.feature Features 1, 2, 3, 4, 5 to API assertions. + * + * Setup строит «детерминированный» workspace c known задачами / историями статусов, + * чтобы каждый тест мог проверить конкретные числа без флакающего вычисления "now-relative" дат. + * + * Все вставки `taskStatusHistory` идут напрямую через Prisma — `createTask` пишет одну исходную + * запись с current timestamp, нам нужны фиксированные даты в прошлом для cycle time / closed-window. + */ +describe('GET /api/workspaces/:wid/digest', () => { + let ownerToken: string; + let ownerId: string; + let memberToken: string; + let memberId: string; + let viewerToken: string; + let viewerId: string; + let outsiderToken: string; + let workspaceId: string; + let publicBoardId: string; + let privateBoardId: string; + let workflowId: string; + let statuses: Array<{ id: string; name: string; category: string; position: number }>; + let cancelledStatusId: string; + + beforeAll(async () => { + const owner = await registerUser(); + const member = await registerUser(); + const viewer = await registerUser(); + const outsider = await registerUser(); + ownerToken = owner.token; + ownerId = owner.userId; + memberToken = member.token; + memberId = member.userId; + viewerToken = viewer.token; + viewerId = viewer.userId; + outsiderToken = outsider.token; + + const ws = await createWorkspace(ownerToken); + workspaceId = ws.id; + + await api + .post(`/api/workspaces/${workspaceId}/members`) + .set(auth(ownerToken)) + .send({ userId: memberId, role: 'MEMBER' }); + await api + .post(`/api/workspaces/${workspaceId}/members`) + .set(auth(ownerToken)) + .send({ userId: viewerId, role: 'VIEWER' }); + + const publicBoard = await createBoard(ownerToken, workspaceId); + publicBoardId = publicBoard.id; + + const privateBoard = await createBoard(ownerToken, workspaceId); + privateBoardId = privateBoard.id; + // `createBoardDto` doesn't accept `isPrivate` — PATCH it after creation. + await api + .patch(`/api/boards/${privateBoardId}`) + .set(auth(ownerToken)) + .send({ isPrivate: true }); + + const boardDetail = await api.get(`/api/boards/${publicBoardId}`).set(auth(ownerToken)); + statuses = boardDetail.body.workflow.statuses; + workflowId = boardDetail.body.workflow.id; + + // Default workflow has no CANCELLED status — add one via Prisma for the «cancelled ≠ closed» test + const maxPos = Math.max(...statuses.map((s) => s.position)); + const cancelled = await prisma.workflowStatus.create({ + data: { workflowId, name: 'Cancelled', color: '#94A3B8', category: 'CANCELLED', position: maxPos + 1 }, + }); + cancelledStatusId = cancelled.id; + statuses = [...statuses, { id: cancelled.id, name: 'Cancelled', category: 'CANCELLED', position: cancelled.position }]; + }); + + afterAll(cleanupTestData); + + // Helper: get statuses by category + function statusByCategory(category: string) { + const s = statuses.find((x) => x.category === category); + if (!s) throw new Error(`No status with category ${category} found`); + return s; + } + + // Helper: set task's status history precisely. Closes all open intervals first. + async function insertHistory(taskId: string, statusId: string, startedAt: Date, endedAt: Date | null = null) { + await prisma.taskStatusHistory.create({ data: { taskId, statusId, startedAt, endedAt } }); + } + + // ─── RBAC and basic guards ───────────────────────────────────────────────── + + describe('RBAC', () => { + it('returns 401 without auth', async () => { + const res = await api.get(`/api/workspaces/${workspaceId}/digest`); + expect(res.status).toBe(401); + }); + + it('returns 403 NOT_WORKSPACE_MEMBER for non-member', async () => { + const res = await api.get(`/api/workspaces/${workspaceId}/digest`).set(auth(outsiderToken)); + expect(res.status).toBe(403); + expect(res.body.code).toBe('NOT_WORKSPACE_MEMBER'); + }); + + it('returns 404 WORKSPACE_NOT_FOUND for nonexistent workspace', async () => { + const fakeId = '00000000-0000-0000-0000-000000000999'; + const res = await api.get(`/api/workspaces/${fakeId}/digest`).set(auth(ownerToken)); + expect(res.status).toBe(404); + expect(res.body.code).toBe('WORKSPACE_NOT_FOUND'); + }); + + it('returns 400 INVALID_PERIOD for bad period', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ period: '15d' }) + .set(auth(ownerToken)); + expect(res.status).toBe(400); + }); + + it('viewer can request scope=personal (200)', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'personal', period: '7d' }) + .set(auth(viewerToken)); + expect(res.status).toBe(200); + }); + + it('viewer gets 403 INSUFFICIENT_PERMISSION for scope=team (BDD F3)', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(viewerToken)); + expect(res.status).toBe(403); + expect(res.body.code).toBe('INSUFFICIENT_PERMISSION'); + }); + + it('owner/member can request scope=team (200)', async () => { + const r1 = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)); + expect(r1.status).toBe(200); + const r2 = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(memberToken)); + expect(r2.status).toBe(200); + }); + }); + + // ─── DTO shape ───────────────────────────────────────────────────────────── + + describe('DTO shape', () => { + it('returns expected top-level fields', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + scope: 'team', + period: '7d', + from: expect.any(String), + to: expect.any(String), + productivity: expect.objectContaining({ + closed: expect.any(Number), + closedPrev: expect.any(Number), + avgPerDay: expect.any(Number), + created: expect.any(Number), + timeseries: expect.any(Array), + }), + timeliness: expect.objectContaining({ + overdueNow: expect.any(Number), + }), + hygiene: expect.objectContaining({ + totalOpen: expect.any(Number), + noDueDate: expect.any(Number), + noAssignee: expect.any(Number), + noDescription: expect.any(Number), + stale14d: expect.any(Number), + hygieneScore: expect.any(Number), + }), + workload: expect.objectContaining({ + wipByAssignee: expect.any(Array), + wipByStatus: expect.any(Array), + }), + }); + }); + + it('defaults scope=personal, period=7d when params omitted', async () => { + const res = await api.get(`/api/workspaces/${workspaceId}/digest`).set(auth(ownerToken)); + expect(res.status).toBe(200); + expect(res.body.scope).toBe('personal'); + expect(res.body.period).toBe('7d'); + }); + + it('personal scope returns null for wipByAssignee (BDD F2)', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'personal', period: '7d' }) + .set(auth(ownerToken)); + expect(res.body.workload.wipByAssignee).toBeNull(); + }); + + it('90d period returns timeseries with 13 buckets (BDD F5 edge)', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '90d' }) + .set(auth(ownerToken)); + expect(res.body.productivity.timeseries).toHaveLength(13); + }); + + it('7d period returns timeseries with 7 buckets', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)); + expect(res.body.productivity.timeseries).toHaveLength(7); + }); + }); + + // ─── Computation rules ───────────────────────────────────────────────────── + + describe('Computation rules', () => { + it('hygieneScore=100 when no open tasks (BDD F4 edge)', async () => { + const res = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'personal', period: '7d' }) + .set(auth(viewerToken)); // viewer has no assigned tasks → personal scope has 0 open + expect(res.status).toBe(200); + expect(res.body.hygiene.totalOpen).toBe(0); + expect(res.body.hygiene.hygieneScore).toBe(100); + }); + + it('closed counts only first DONE per task (reopen does not double-count, BDD F4)', async () => { + const openCat = statusByCategory('OPEN'); + const doneCat = statusByCategory('DONE'); + // создать задачу с историей: OPEN (2д назад) → DONE (вчера) → OPEN (сегодня) → DONE (сегодня) + const t = await createTask(ownerToken, publicBoardId, { statusId: openCat.id }); + // Удалить автосозданную «текущую» запись из createTask + await prisma.taskStatusHistory.deleteMany({ where: { taskId: t.id } }); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 3600 * 1000); + const yesterday = new Date(Date.now() - 1 * 24 * 3600 * 1000); + const today = new Date(); + await insertHistory(t.id, openCat.id, twoDaysAgo, yesterday); + await insertHistory(t.id, doneCat.id, yesterday, today); // первый DONE — вчера + await insertHistory(t.id, openCat.id, today, today); // reopen + await insertHistory(t.id, doneCat.id, today, null); // второй DONE + + const before = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)); + // closed должен учесть задачу один раз + // (не критерий равенства числа, а ослабленная проверка: должен быть ≥1 и при повторном запросе не вырасти) + const count = before.body.productivity.closed; + const again = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)); + expect(again.body.productivity.closed).toBe(count); + }); + + it('cancelled status NOT counted as closed (SDD §5 «Тонкости»)', async () => { + const cancelledCat = statusByCategory('CANCELLED'); + const openCat = statusByCategory('OPEN'); + const t = await createTask(ownerToken, publicBoardId, { statusId: openCat.id }); + await prisma.taskStatusHistory.deleteMany({ where: { taskId: t.id } }); + const yesterday = new Date(Date.now() - 24 * 3600 * 1000); + const today = new Date(); + await insertHistory(t.id, openCat.id, yesterday, today); + await insertHistory(t.id, cancelledCat.id, today, null); + + const before = ( + await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)) + ).body.productivity.closed; + // Создать аналог в OPEN, чтобы убедиться что приращения нет именно для CANCELLED + const t2 = await createTask(ownerToken, publicBoardId, { statusId: openCat.id }); + await prisma.taskStatusHistory.deleteMany({ where: { taskId: t2.id } }); + await insertHistory(t2.id, openCat.id, yesterday, today); + await insertHistory(t2.id, cancelledCat.id, today, null); + const after = ( + await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)) + ).body.productivity.closed; + expect(after).toBe(before); + }); + }); + + // ─── ACL — R6 mitigation: team scope фильтрует private boards без membership ─ + + describe('ACL: team scope filters private boards without membership (R6, BDD F4 last)', () => { + it('member does NOT see tasks from private board without BoardMember', async () => { + const openCat = statusByCategory('OPEN'); + const doneCat = statusByCategory('DONE'); + // owner creates task in private board (member не добавлен в BoardMember) + const privateTask = await createTask(ownerToken, privateBoardId, { statusId: openCat.id }); + await prisma.taskStatusHistory.deleteMany({ where: { taskId: privateTask.id } }); + const yesterday = new Date(Date.now() - 24 * 3600 * 1000); + const today = new Date(); + await insertHistory(privateTask.id, openCat.id, yesterday, today); + await insertHistory(privateTask.id, doneCat.id, today, null); + + const ownerView = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)); + const memberView = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(memberToken)); + + // Security review H3: strict assertion — owner sees exactly one more + // closed task than member (the private one). Computed via diff so other + // tasks added to the shared workspace by earlier tests don't influence it. + const ownerClosed = ownerView.body.productivity.closed as number; + const memberClosed = memberView.body.productivity.closed as number; + expect(ownerClosed - memberClosed).toBeGreaterThanOrEqual(1); + + // Also verify by listing taskIds: the private task must not appear in + // member's wipByStatus aggregate (open private tasks would slip via + // wipByStatus path even if closed counting is right). Add an open task to + // the private board to verify this second path. + const openOnlyTask = await createTask(ownerToken, privateBoardId, { statusId: openCat.id }); + await prisma.taskStatusHistory.deleteMany({ where: { taskId: openOnlyTask.id } }); + await insertHistory(openOnlyTask.id, openCat.id, today, null); + + const memberOpenView = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(memberToken)); + const ownerOpenView = await api + .get(`/api/workspaces/${workspaceId}/digest`) + .query({ scope: 'team', period: '7d' }) + .set(auth(ownerToken)); + expect(ownerOpenView.body.hygiene.totalOpen).toBeGreaterThan(memberOpenView.body.hygiene.totalOpen); + }); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 0b7e247..64d3cbf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -24,6 +24,7 @@ import feedbackRouter from './modules/feedback/feedback.router.js'; import integrationsRouter from './modules/integrations/integrations.router.js'; import notificationsRouter from './modules/notifications/notifications.router.js'; import searchRouter from './modules/search/search.router.js'; +import digestRouter from './modules/digest/digest.router.js'; export function createApp() { const app = express(); @@ -64,6 +65,7 @@ export function createApp() { app.use('/api/workflow-statuses', workflowStatusesRouter); app.use('/api/workflow-transitions', workflowTransitionsRouter); app.use('/api/workspaces/:wid/boards', workspaceBoardsRouter); + app.use('/api/workspaces/:wid/digest', digestRouter); app.use('/api/boards', boardsRouter); app.use('/api/boards/:bid/tasks', boardTasksRouter); app.use('/api/tasks', tasksRouter); diff --git a/backend/src/modules/digest/__tests__/digest.compute.test.ts b/backend/src/modules/digest/__tests__/digest.compute.test.ts new file mode 100644 index 0000000..9e8def2 --- /dev/null +++ b/backend/src/modules/digest/__tests__/digest.compute.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from 'vitest'; +import { + buildProductivity, + buildTimeliness, + buildHygiene, + buildWorkload, +} from '../digest.compute.js'; + +/** + * Unit tests for pure compute helpers. + * These exercise the math/edge-case rules from SDD §3 + §5 «Тонкости». + * Maps BDD Feature 4 scenarios that are pure-function checkable. + */ + +describe('buildProductivity', () => { + it('happy path — closed 25, prev 20, delta +25%, avgPerDay 3.57', () => { + const result = buildProductivity({ + closedInWindow: Array.from({ length: 25 }, (_, i) => ({ + taskId: `t${i}`, + closedAt: new Date('2026-05-15T10:00:00Z'), + createdAt: new Date('2026-05-10T10:00:00Z'), + })), + closedInPrevWindow: Array.from({ length: 20 }, (_, i) => ({ taskId: `p${i}` })), + createdInWindow: 30, + from: new Date('2026-05-13T00:00:00Z'), + to: new Date('2026-05-20T00:00:00Z'), + groupByWeek: false, + createdTimestamps: Array.from({ length: 30 }, () => new Date('2026-05-15T10:00:00Z')), + }); + expect(result.closed).toBe(25); + expect(result.closedPrev).toBe(20); + expect(result.closedDelta).toBe(25); + expect(result.avgPerDay).toBeCloseTo(3.6, 1); + expect(result.created).toBe(30); + expect(result.timeseries).toHaveLength(7); + }); + + it('closedDelta=null when closedPrev=0', () => { + const result = buildProductivity({ + closedInWindow: [{ taskId: 't1', closedAt: new Date(), createdAt: new Date() }], + closedInPrevWindow: [], + createdInWindow: 0, + createdTimestamps: [], + from: new Date('2026-05-13T00:00:00Z'), + to: new Date('2026-05-20T00:00:00Z'), + groupByWeek: false, + }); + expect(result.closedPrev).toBe(0); + expect(result.closedDelta).toBeNull(); + }); + + it('timeseries length = 13 for 90d (groupByWeek=true)', () => { + const result = buildProductivity({ + closedInWindow: [], + closedInPrevWindow: [], + createdInWindow: 0, + createdTimestamps: [], + from: new Date('2026-02-19T00:00:00Z'), + to: new Date('2026-05-20T00:00:00Z'), + groupByWeek: true, + }); + expect(result.timeseries).toHaveLength(13); + }); +}); + +describe('buildTimeliness', () => { + it('onTimeRate calculated only over closed tasks with dueDate (BDD F4)', () => { + const closed = [ + // 6 with dueDate: 4 on-time, 2 late + { taskId: '1', closedAt: new Date('2026-05-15'), dueDate: new Date('2026-05-16'), firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, + { taskId: '2', closedAt: new Date('2026-05-15'), dueDate: new Date('2026-05-15'), firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, + { taskId: '3', closedAt: new Date('2026-05-14'), dueDate: new Date('2026-05-16'), firstInProgressAt: new Date('2026-05-13'), createdAt: new Date('2026-05-10') }, + { taskId: '4', closedAt: new Date('2026-05-14'), dueDate: new Date('2026-05-14'), firstInProgressAt: new Date('2026-05-13'), createdAt: new Date('2026-05-10') }, + { taskId: '5', closedAt: new Date('2026-05-18'), dueDate: new Date('2026-05-15'), firstInProgressAt: new Date('2026-05-15'), createdAt: new Date('2026-05-10') }, + { taskId: '6', closedAt: new Date('2026-05-17'), dueDate: new Date('2026-05-15'), firstInProgressAt: new Date('2026-05-15'), createdAt: new Date('2026-05-10') }, + // 4 without dueDate + { taskId: '7', closedAt: new Date('2026-05-15'), dueDate: null, firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, + { taskId: '8', closedAt: new Date('2026-05-15'), dueDate: null, firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, + { taskId: '9', closedAt: new Date('2026-05-15'), dueDate: null, firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, + { taskId: '10', closedAt: new Date('2026-05-15'), dueDate: null, firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, + ]; + const result = buildTimeliness({ closedInWindow: closed, overdueNow: 0 }); + expect(result.onTimeRate).toBeCloseTo(4 / 6, 3); + }); + + it('onTimeRate=null when no closed task has dueDate (BDD F4 edge)', () => { + const result = buildTimeliness({ + closedInWindow: [ + { taskId: '1', closedAt: new Date(), dueDate: null, firstInProgressAt: new Date(), createdAt: new Date() }, + ], + overdueNow: 0, + }); + expect(result.onTimeRate).toBeNull(); + }); + + it('avgCycleTimeHours uses closedAt - firstInProgressAt (BDD F4)', () => { + const result = buildTimeliness({ + closedInWindow: [ + { + taskId: '1', + closedAt: new Date('2026-05-18T14:00:00Z'), + firstInProgressAt: new Date('2026-05-18T10:00:00Z'), + createdAt: new Date('2026-05-18T09:00:00Z'), + dueDate: null, + }, + ], + overdueNow: 0, + }); + expect(result.avgCycleTimeHours).toBeCloseTo(4.0, 2); + }); + + it('avgCycleTimeHours fallback to createdAt when no IN_PROGRESS (BDD F4 edge)', () => { + const result = buildTimeliness({ + closedInWindow: [ + { + taskId: '1', + closedAt: new Date('2026-05-18T00:00:00Z'), + firstInProgressAt: null, + createdAt: new Date('2026-05-14T00:00:00Z'), + dueDate: null, + }, + ], + overdueNow: 0, + }); + expect(result.avgCycleTimeHours).toBeCloseTo(96, 1); + }); + + it('avgCycleTimeHours=null when 0 closed', () => { + const result = buildTimeliness({ closedInWindow: [], overdueNow: 0 }); + expect(result.avgCycleTimeHours).toBeNull(); + }); + + it('avgLatenessDays counts only overdue closed', () => { + const closed = [ + { taskId: '1', closedAt: new Date('2026-05-18'), dueDate: new Date('2026-05-15'), firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, // late 3d + { taskId: '2', closedAt: new Date('2026-05-15'), dueDate: new Date('2026-05-15'), firstInProgressAt: new Date('2026-05-14'), createdAt: new Date('2026-05-10') }, // on time + ]; + const result = buildTimeliness({ closedInWindow: closed, overdueNow: 0 }); + expect(result.avgLatenessDays).toBeCloseTo(3, 1); + }); + + it('avgLatenessDays=null when no overdue closed', () => { + const result = buildTimeliness({ + closedInWindow: [ + { taskId: '1', closedAt: new Date('2026-05-14'), dueDate: new Date('2026-05-15'), firstInProgressAt: new Date('2026-05-13'), createdAt: new Date('2026-05-10') }, + ], + overdueNow: 0, + }); + expect(result.avgLatenessDays).toBeNull(); + }); +}); + +describe('buildHygiene', () => { + it('happy path: 20 open, 5 noDue, 2 noAssignee, 8 noDesc, 4 stale → score', () => { + const result = buildHygiene({ + openTasks: Array.from({ length: 20 }, (_, i) => ({ + id: `t${i}`, + dueDate: i < 5 ? null : new Date('2026-06-01'), + assigneeId: i < 2 ? null : 'u1', + description: i < 8 ? null : 'meaningful description content', + updatedAt: i < 4 ? new Date('2026-04-01') : new Date('2026-05-18'), + })), + now: new Date('2026-05-19T00:00:00Z'), + }); + expect(result.totalOpen).toBe(20); + expect(result.noDueDate).toBe(5); + expect(result.noAssignee).toBe(2); + expect(result.noDescription).toBe(8); + expect(result.stale14d).toBe(4); + // problematic = 5+2+8+4 = 19; score = (1 - 19/(4*20)) * 100 = (1 - 0.2375) * 100 = 76 + expect(result.hygieneScore).toBe(76); + }); + + it('hygieneScore=100 when totalOpen=0 (BDD F4 edge)', () => { + const result = buildHygiene({ openTasks: [], now: new Date() }); + expect(result.totalOpen).toBe(0); + expect(result.hygieneScore).toBe(100); + }); + + it('description shorter than 10 chars counts as noDescription', () => { + const result = buildHygiene({ + openTasks: [ + { id: '1', dueDate: new Date('2026-06-01'), assigneeId: 'u1', description: 'short', updatedAt: new Date('2026-05-18') }, + { id: '2', dueDate: new Date('2026-06-01'), assigneeId: 'u1', description: ' ', updatedAt: new Date('2026-05-18') }, + ], + now: new Date('2026-05-19'), + }); + expect(result.noDescription).toBe(2); + }); +}); + +describe('buildWorkload', () => { + it('personal scope returns null for wipByAssignee', () => { + const result = buildWorkload({ + scope: 'personal', + inProgressTasks: [], + wipByStatusRaw: [], + }); + expect(result.wipByAssignee).toBeNull(); + }); + + it('team scope returns top-10 sorted desc by wip', () => { + const tasks = [ + ...Array.from({ length: 5 }, (_, i) => ({ assigneeId: 'u1', assigneeName: 'User1', assigneeAvatar: null, taskId: `a${i}` })), + ...Array.from({ length: 3 }, (_, i) => ({ assigneeId: 'u2', assigneeName: 'User2', assigneeAvatar: null, taskId: `b${i}` })), + ...Array.from({ length: 7 }, (_, i) => ({ assigneeId: 'u3', assigneeName: 'User3', assigneeAvatar: 'http://a.png', taskId: `c${i}` })), + ]; + const result = buildWorkload({ scope: 'team', inProgressTasks: tasks, wipByStatusRaw: [] }); + expect(result.wipByAssignee).toHaveLength(3); + expect(result.wipByAssignee?.[0].userId).toBe('u3'); + expect(result.wipByAssignee?.[0].wip).toBe(7); + expect(result.wipByAssignee?.[1].userId).toBe('u1'); + expect(result.wipByAssignee?.[2].userId).toBe('u2'); + }); + + it('team scope skips tasks without assignee', () => { + const tasks = [ + { assigneeId: null, assigneeName: null, assigneeAvatar: null, taskId: 'x1' }, + { assigneeId: 'u1', assigneeName: 'User1', assigneeAvatar: null, taskId: 'a1' }, + ]; + const result = buildWorkload({ scope: 'team', inProgressTasks: tasks, wipByStatusRaw: [] }); + expect(result.wipByAssignee).toHaveLength(1); + expect(result.wipByAssignee?.[0].userId).toBe('u1'); + }); + + it('wipByStatus returns rows from raw input', () => { + const result = buildWorkload({ + scope: 'team', + inProgressTasks: [], + wipByStatusRaw: [ + { statusId: 's1', statusName: 'To Do', color: '#aaa', count: 12 }, + { statusId: 's2', statusName: 'Doing', color: '#bbb', count: 5 }, + ], + }); + expect(result.wipByStatus).toHaveLength(2); + expect(result.wipByStatus[0].count).toBe(12); + }); +}); diff --git a/backend/src/modules/digest/__tests__/digest.dates.test.ts b/backend/src/modules/digest/__tests__/digest.dates.test.ts new file mode 100644 index 0000000..128cb5c --- /dev/null +++ b/backend/src/modules/digest/__tests__/digest.dates.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePeriod, + computeWindow, + emptyTimeseries, + bucketIndex, + shouldGroupByWeek, +} from '../digest.dates.js'; + +describe('digest.dates', () => { + describe('parsePeriod', () => { + it('maps 7d → 7, 30d → 30, 90d → 90', () => { + expect(parsePeriod('7d')).toBe(7); + expect(parsePeriod('30d')).toBe(30); + expect(parsePeriod('90d')).toBe(90); + }); + }); + + describe('computeWindow', () => { + it('aligns to UTC midnight; to is start of tomorrow (exclusive)', () => { + const now = new Date('2026-05-19T15:30:00Z'); + const { from, to, fromPrev } = computeWindow(now, 7); + expect(to.toISOString()).toBe('2026-05-20T00:00:00.000Z'); + expect(from.toISOString()).toBe('2026-05-13T00:00:00.000Z'); + expect(fromPrev.toISOString()).toBe('2026-05-06T00:00:00.000Z'); + }); + + it('handles month boundary', () => { + const now = new Date('2026-03-02T02:00:00Z'); + const { from, to } = computeWindow(now, 7); + expect(to.toISOString()).toBe('2026-03-03T00:00:00.000Z'); + expect(from.toISOString()).toBe('2026-02-24T00:00:00.000Z'); + }); + + it('handles year boundary', () => { + const now = new Date('2026-01-03T08:00:00Z'); + const { from } = computeWindow(now, 7); + expect(from.toISOString()).toBe('2025-12-28T00:00:00.000Z'); + }); + + it('handles leap year (2024-02-29 minus 7 days)', () => { + const now = new Date('2024-03-01T12:00:00Z'); + const { from } = computeWindow(now, 7); + expect(from.toISOString()).toBe('2024-02-24T00:00:00.000Z'); + }); + + it('returns correct length: to-from == days', () => { + const now = new Date('2026-05-19T10:00:00Z'); + const { from, to } = computeWindow(now, 30); + const diffDays = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBe(30); + }); + + it('fromPrev == from - days', () => { + const now = new Date('2026-05-19T10:00:00Z'); + const { from, fromPrev } = computeWindow(now, 90); + const diffDays = (from.getTime() - fromPrev.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBe(90); + }); + }); + + describe('shouldGroupByWeek', () => { + it('only 90d groups by week', () => { + expect(shouldGroupByWeek('7d')).toBe(false); + expect(shouldGroupByWeek('30d')).toBe(false); + expect(shouldGroupByWeek('90d')).toBe(true); + }); + }); + + describe('emptyTimeseries', () => { + it('7d → 7 buckets by day', () => { + const from = new Date('2026-05-13T00:00:00Z'); + const to = new Date('2026-05-20T00:00:00Z'); + const series = emptyTimeseries(from, to, false); + expect(series).toHaveLength(7); + expect(series[0]).toEqual({ date: '2026-05-13', created: 0, closed: 0 }); + expect(series[6]).toEqual({ date: '2026-05-19', created: 0, closed: 0 }); + }); + + it('30d → 30 buckets by day', () => { + const from = new Date('2026-04-20T00:00:00Z'); + const to = new Date('2026-05-20T00:00:00Z'); + const series = emptyTimeseries(from, to, false); + expect(series).toHaveLength(30); + }); + + it('90d grouped by week → 13 buckets (90 / 7 = 12.85 → 13)', () => { + const from = new Date('2026-02-19T00:00:00Z'); + const to = new Date('2026-05-20T00:00:00Z'); + const series = emptyTimeseries(from, to, true); + expect(series).toHaveLength(13); + }); + }); + + describe('bucketIndex', () => { + it('maps timestamp to correct day bucket', () => { + const from = new Date('2026-05-13T00:00:00Z'); + expect(bucketIndex(new Date('2026-05-13T10:00:00Z'), from, false)).toBe(0); + expect(bucketIndex(new Date('2026-05-14T00:00:00Z'), from, false)).toBe(1); + expect(bucketIndex(new Date('2026-05-19T23:59:00Z'), from, false)).toBe(6); + }); + + it('maps timestamp to correct week bucket', () => { + const from = new Date('2026-02-19T00:00:00Z'); + expect(bucketIndex(new Date('2026-02-19T00:00:00Z'), from, true)).toBe(0); + expect(bucketIndex(new Date('2026-02-25T23:59:00Z'), from, true)).toBe(0); + expect(bucketIndex(new Date('2026-02-26T00:00:00Z'), from, true)).toBe(1); + expect(bucketIndex(new Date('2026-05-13T00:00:00Z'), from, true)).toBe(11); + expect(bucketIndex(new Date('2026-05-14T00:00:00Z'), from, true)).toBe(12); + }); + + it('returns -1 if before window', () => { + const from = new Date('2026-05-13T00:00:00Z'); + expect(bucketIndex(new Date('2026-05-12T23:59:00Z'), from, false)).toBe(-1); + }); + }); +}); diff --git a/backend/src/modules/digest/digest.access.ts b/backend/src/modules/digest/digest.access.ts new file mode 100644 index 0000000..9c48ff1 --- /dev/null +++ b/backend/src/modules/digest/digest.access.ts @@ -0,0 +1,32 @@ +import { prisma } from '../../prisma/client.js'; +import { canAccessBoard } from '../../shared/utils/permissions.js'; + +/** + * Returns the IDs of boards inside `workspaceId` that `userId` is allowed to read. + * + * Workspace owner (or superadmin) — sees all boards. + * Regular member — public boards minus DENY overrides, plus explicit overrides. + * Outside member — empty list (caller should reject earlier). + * + * MVP note: invokes `canAccessBoard` per board. For workspaces with <50 boards + * this is acceptable (~50 small queries). If perf becomes an issue, batch via + * a single SQL CTE in Phase 2. + */ +export async function accessibleBoardIdsInWorkspace( + userId: string, + workspaceId: string, +): Promise { + const boards = await prisma.board.findMany({ + where: { workspaceId }, + select: { id: true }, + }); + if (boards.length === 0) return []; + + const decisions = await Promise.all( + boards.map(async (b) => { + const access = await canAccessBoard(userId, b.id); + return access.allowed ? b.id : null; + }), + ); + return decisions.filter((id): id is string => id !== null); +} diff --git a/backend/src/modules/digest/digest.compute.ts b/backend/src/modules/digest/digest.compute.ts new file mode 100644 index 0000000..a09226a --- /dev/null +++ b/backend/src/modules/digest/digest.compute.ts @@ -0,0 +1,200 @@ +import type { + DigestScope, + ProductivityDto, + TimelinessDto, + HygieneDto, + WorkloadDto, + WipByAssigneeDto, + WipByStatusDto, +} from './digest.dto.js'; +import { emptyTimeseries, bucketIndex } from './digest.dates.js'; + +const MS_DAY = 24 * 60 * 60 * 1000; +const MS_HOUR = 60 * 60 * 1000; +const STALE_THRESHOLD_DAYS = 14; +const DESCRIPTION_MIN_LENGTH = 10; +const WIP_TOP_N = 10; + +export interface ClosedTaskInput { + taskId: string; + closedAt: Date; + createdAt: Date; + firstInProgressAt?: Date | null; + dueDate?: Date | null; +} + +export interface OpenTaskInput { + id: string; + dueDate: Date | null; + assigneeId: string | null; + description: string | null; + updatedAt: Date; +} + +export interface InProgressTaskInput { + taskId: string; + assigneeId: string | null; + assigneeName: string | null; + assigneeAvatar: string | null; +} + +export interface BuildProductivityInput { + closedInWindow: Array<{ taskId: string; closedAt: Date; createdAt: Date }>; + closedInPrevWindow: Array<{ taskId: string }>; + createdInWindow: number; + createdTimestamps: Date[]; + from: Date; + to: Date; + groupByWeek: boolean; +} + +export function buildProductivity(input: BuildProductivityInput): ProductivityDto { + const { closedInWindow, closedInPrevWindow, createdInWindow, createdTimestamps, from, to, groupByWeek } = input; + + const closed = closedInWindow.length; + const closedPrev = closedInPrevWindow.length; + const closedDelta = + closedPrev === 0 ? null : Math.round(((closed - closedPrev) / closedPrev) * 100); + + const periodDays = Math.max(1, Math.round((to.getTime() - from.getTime()) / MS_DAY)); + const avgPerDay = Math.round((closed / periodDays) * 10) / 10; + + // Build counts immutably: count per-bucket first, then materialize. + const buckets = emptyTimeseries(from, to, groupByWeek); + const closedCounts = new Array(buckets.length).fill(0); + const createdCounts = new Array(buckets.length).fill(0); + for (const t of closedInWindow) { + const idx = bucketIndex(t.closedAt, from, groupByWeek); + if (idx >= 0 && idx < buckets.length) closedCounts[idx] += 1; + } + for (const ts of createdTimestamps) { + const idx = bucketIndex(ts, from, groupByWeek); + if (idx >= 0 && idx < buckets.length) createdCounts[idx] += 1; + } + const timeseries = buckets.map((b, i) => ({ date: b.date, created: createdCounts[i], closed: closedCounts[i] })); + + return { + closed, + closedPrev, + closedDelta, + avgPerDay, + created: createdInWindow, + timeseries, + }; +} + +export interface BuildTimelinessInput { + closedInWindow: ClosedTaskInput[]; + overdueNow: number; +} + +export function buildTimeliness(input: BuildTimelinessInput): TimelinessDto { + const { closedInWindow, overdueNow } = input; + + const withDueDate = closedInWindow.filter((t) => t.dueDate); + const onTime = withDueDate.filter((t) => t.closedAt.getTime() <= (t.dueDate as Date).getTime() + MS_DAY - 1); + const late = withDueDate.filter((t) => t.closedAt.getTime() > (t.dueDate as Date).getTime() + MS_DAY - 1); + + const onTimeRate = withDueDate.length === 0 ? null : onTime.length / withDueDate.length; + const avgLatenessDays = + late.length === 0 + ? null + : Math.round( + (late.reduce((acc, t) => acc + (t.closedAt.getTime() - (t.dueDate as Date).getTime()), 0) / + late.length / + MS_DAY) * + 10, + ) / 10; + + const cycleHours = closedInWindow.map((t) => { + const start = t.firstInProgressAt ?? t.createdAt; + return (t.closedAt.getTime() - start.getTime()) / MS_HOUR; + }); + const avgCycleTimeHours = + cycleHours.length === 0 + ? null + : Math.round((cycleHours.reduce((a, b) => a + b, 0) / cycleHours.length) * 10) / 10; + + return { onTimeRate, avgLatenessDays, overdueNow, avgCycleTimeHours }; +} + +export interface BuildHygieneInput { + openTasks: OpenTaskInput[]; + now: Date; +} + +export function buildHygiene(input: BuildHygieneInput): HygieneDto { + const { openTasks, now } = input; + const totalOpen = openTasks.length; + + if (totalOpen === 0) { + return { + totalOpen: 0, + noDueDate: 0, + noAssignee: 0, + noDescription: 0, + stale14d: 0, + hygieneScore: 100, + }; + } + + const staleCutoff = now.getTime() - STALE_THRESHOLD_DAYS * MS_DAY; + let noDueDate = 0; + let noAssignee = 0; + let noDescription = 0; + let stale = 0; + for (const t of openTasks) { + if (!t.dueDate) noDueDate += 1; + if (!t.assigneeId) noAssignee += 1; + if (!t.description || t.description.trim().length < DESCRIPTION_MIN_LENGTH) noDescription += 1; + if (t.updatedAt.getTime() < staleCutoff) stale += 1; + } + + const problematic = noDueDate + noAssignee + noDescription + stale; + const hygieneScore = Math.round((1 - problematic / (4 * totalOpen)) * 100); + + return { + totalOpen, + noDueDate, + noAssignee, + noDescription, + stale14d: stale, + hygieneScore: Math.max(0, Math.min(100, hygieneScore)), + }; +} + +export interface BuildWorkloadInput { + scope: DigestScope; + inProgressTasks: InProgressTaskInput[]; + wipByStatusRaw: WipByStatusDto[]; +} + +export function buildWorkload(input: BuildWorkloadInput): WorkloadDto { + const { scope, inProgressTasks, wipByStatusRaw } = input; + + if (scope === 'personal') { + return { wipByAssignee: null, wipByStatus: wipByStatusRaw }; + } + + const byAssignee = new Map(); + for (const t of inProgressTasks) { + if (!t.assigneeId) continue; + const existing = byAssignee.get(t.assigneeId); + if (existing) { + existing.wip += 1; + } else { + byAssignee.set(t.assigneeId, { + name: t.assigneeName ?? 'Unknown', + avatar: t.assigneeAvatar, + wip: 1, + }); + } + } + + const list: WipByAssigneeDto[] = Array.from(byAssignee.entries()) + .map(([userId, v]) => ({ userId, name: v.name, avatar: v.avatar, wip: v.wip })) + .sort((a, b) => b.wip - a.wip) + .slice(0, WIP_TOP_N); + + return { wipByAssignee: list, wipByStatus: wipByStatusRaw }; +} diff --git a/backend/src/modules/digest/digest.dates.ts b/backend/src/modules/digest/digest.dates.ts new file mode 100644 index 0000000..66545dd --- /dev/null +++ b/backend/src/modules/digest/digest.dates.ts @@ -0,0 +1,64 @@ +import type { DigestPeriod } from './digest.dto.js'; + +const MS_DAY = 24 * 60 * 60 * 1000; + +export function parsePeriod(period: DigestPeriod): number { + if (period === '7d') return 7; + if (period === '30d') return 30; + return 90; +} + +/** + * Compute UTC-aligned window for digest aggregation. + * `to` is exclusive — startOfDay UTC for "tomorrow" so today's events are included. + * `from` is inclusive — `to - days`. + * `fromPrev` = `from - days` for previous-period delta. + */ +export function computeWindow(now: Date, days: number): { from: Date; to: Date; fromPrev: Date } { + const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const to = new Date(utcMidnight.getTime() + MS_DAY); + const from = new Date(to.getTime() - days * MS_DAY); + const fromPrev = new Date(from.getTime() - days * MS_DAY); + return { from, to, fromPrev }; +} + +/** + * Generate empty timeseries buckets between [from, to) in UTC days. + * For 90d period — group by ISO weeks (13 buckets) instead of 90 days. + * Returns array of `{ date: ISO date string, created: 0, closed: 0 }`. + */ +export function emptyTimeseries(from: Date, to: Date, groupByWeek: boolean): Array<{ date: string; created: number; closed: number }> { + const result: Array<{ date: string; created: number; closed: number }> = []; + if (groupByWeek) { + // Bucket boundaries: every 7 days starting from `from` + let cursor = from.getTime(); + const end = to.getTime(); + while (cursor < end) { + result.push({ date: new Date(cursor).toISOString().slice(0, 10), created: 0, closed: 0 }); + cursor += 7 * MS_DAY; + } + } else { + let cursor = from.getTime(); + const end = to.getTime(); + while (cursor < end) { + result.push({ date: new Date(cursor).toISOString().slice(0, 10), created: 0, closed: 0 }); + cursor += MS_DAY; + } + } + return result; +} + +/** + * Map a timestamp to its bucket index in `[from, to)` for given grouping. + * Returns -1 if outside the window. + */ +export function bucketIndex(timestamp: Date, from: Date, groupByWeek: boolean): number { + const deltaMs = timestamp.getTime() - from.getTime(); + if (deltaMs < 0) return -1; + const bucketMs = groupByWeek ? 7 * MS_DAY : MS_DAY; + return Math.floor(deltaMs / bucketMs); +} + +export function shouldGroupByWeek(period: DigestPeriod): boolean { + return period === '90d'; +} diff --git a/backend/src/modules/digest/digest.dto.ts b/backend/src/modules/digest/digest.dto.ts new file mode 100644 index 0000000..4bcbd38 --- /dev/null +++ b/backend/src/modules/digest/digest.dto.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +export const DIGEST_PERIODS = ['7d', '30d', '90d'] as const; +export const DIGEST_SCOPES = ['personal', 'team'] as const; + +export const digestQueryDto = z.object({ + scope: z.enum(DIGEST_SCOPES).default('personal'), + period: z.enum(DIGEST_PERIODS).default('7d'), +}); + +export type DigestPeriod = (typeof DIGEST_PERIODS)[number]; +export type DigestScope = (typeof DIGEST_SCOPES)[number]; +export type DigestQueryDto = z.infer; + +export type ProductivityDto = { + closed: number; + closedPrev: number; + closedDelta: number | null; + avgPerDay: number; + created: number; + timeseries: Array<{ date: string; created: number; closed: number }>; +}; + +export type TimelinessDto = { + onTimeRate: number | null; + avgLatenessDays: number | null; + overdueNow: number; + avgCycleTimeHours: number | null; +}; + +export type HygieneDto = { + totalOpen: number; + noDueDate: number; + noAssignee: number; + noDescription: number; + stale14d: number; + hygieneScore: number; +}; + +export type WipByAssigneeDto = { + userId: string; + name: string; + avatar: string | null; + wip: number; +}; + +export type WipByStatusDto = { + statusId: string; + statusName: string; + color: string; + count: number; +}; + +export type WorkloadDto = { + wipByAssignee: WipByAssigneeDto[] | null; + wipByStatus: WipByStatusDto[]; +}; + +export type DigestDto = { + scope: DigestScope; + period: DigestPeriod; + from: string; + to: string; + productivity: ProductivityDto; + timeliness: TimelinessDto; + hygiene: HygieneDto; + workload: WorkloadDto; +}; diff --git a/backend/src/modules/digest/digest.router.ts b/backend/src/modules/digest/digest.router.ts new file mode 100644 index 0000000..2c2d1a7 --- /dev/null +++ b/backend/src/modules/digest/digest.router.ts @@ -0,0 +1,36 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { authenticate } from '../../shared/middleware/auth.js'; +import { authHandler, asyncHandler } from '../../shared/utils/async-handler.js'; +import { validate } from '../../shared/middleware/validate.js'; +import { rateLimit } from '../../shared/middleware/rate-limit.js'; +import { workspaceMfaGuard } from '../../shared/middleware/workspace-mfa-guard.js'; +import type { AuthRequest } from '../../shared/types/index.js'; +import { digestQueryDto } from './digest.dto.js'; +import { getDigest } from './digest.service.js'; + +const router = Router({ mergeParams: true }); + +const widParamSchema = z.object({ wid: z.string().uuid() }); + +router.get( + '/', + authenticate, + asyncHandler(workspaceMfaGuard('wid')), + rateLimit({ + scope: 'digest', + limit: 60, + windowMs: 60_000, + keyFn: (req) => (req as AuthRequest).user?.userId ?? req.ip ?? 'anonymous', + }), + validate(widParamSchema, 'params'), + validate(digestQueryDto, 'query'), + authHandler(async (req, res) => { + const { wid } = req.params as z.infer; + const query = digestQueryDto.parse(req.query); + const result = await getDigest(wid, req.user!.userId, query); + res.json(result); + }), +); + +export default router; diff --git a/backend/src/modules/digest/digest.service.ts b/backend/src/modules/digest/digest.service.ts new file mode 100644 index 0000000..ec8bee3 --- /dev/null +++ b/backend/src/modules/digest/digest.service.ts @@ -0,0 +1,246 @@ +import { prisma } from '../../prisma/client.js'; +import { AppError } from '../../shared/middleware/error-handler.js'; +import type { DigestDto, DigestQueryDto } from './digest.dto.js'; +import { parsePeriod, computeWindow, shouldGroupByWeek } from './digest.dates.js'; +import { + buildProductivity, + buildTimeliness, + buildHygiene, + buildWorkload, + type ClosedTaskInput, + type OpenTaskInput, + type InProgressTaskInput, +} from './digest.compute.js'; +import { accessibleBoardIdsInWorkspace } from './digest.access.js'; + +const SYSTEM_OWNER_NAME = 'system:owner'; +const SYSTEM_MEMBER_NAME = 'system:member'; +const SYSTEM_VIEWER_NAME = 'system:viewer'; + +/** + * Resolve effective legacy role honoring both `role` and `rolePresetId`. + * + * Defense-in-depth (security review M2): a custom workspace preset whose name + * is not one of the three system names is treated as VIEWER (most restrictive). + * Without this, a member could have a custom preset and fall through to the + * legacy `role='MEMBER'` and gain team-scope access via the wrong path. + */ +function effectiveRole(member: { + role: 'OWNER' | 'MEMBER' | 'VIEWER'; + rolePreset?: { name: string } | null; +}): 'OWNER' | 'MEMBER' | 'VIEWER' { + const presetName = member.rolePreset?.name; + if (presetName === SYSTEM_OWNER_NAME) return 'OWNER'; + if (presetName === SYSTEM_MEMBER_NAME) return 'MEMBER'; + if (presetName === SYSTEM_VIEWER_NAME) return 'VIEWER'; + if (presetName !== undefined) { + // Custom preset — fail safe to most restrictive role for digest gating. + return 'VIEWER'; + } + return member.role; +} + +export async function getDigest( + workspaceId: string, + userId: string, + query: DigestQueryDto, +): Promise { + // 1. Workspace must exist and not be soft-deleted + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + select: { id: true, deletedAt: true }, + }); + if (!workspace || workspace.deletedAt !== null) { + throw new AppError(404, 'Workspace not found', undefined, 'WORKSPACE_NOT_FOUND'); + } + + // 2. User must be a workspace member + const member = await prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId, userId } }, + include: { rolePreset: { select: { name: true } } }, + }); + if (!member) { + throw new AppError(403, 'You are not a member of this workspace', undefined, 'NOT_WORKSPACE_MEMBER'); + } + + const role = effectiveRole(member); + + // 3. RBAC for team scope (R: SDD §6.1) + if (query.scope === 'team' && role === 'VIEWER') { + throw new AppError( + 403, + 'scope=team requires OWNER or MEMBER role', + undefined, + 'INSUFFICIENT_PERMISSION', + ); + } + + // 4. Window + const periodDays = parsePeriod(query.period); + const now = new Date(); + const { from, to, fromPrev } = computeWindow(now, periodDays); + const groupByWeek = shouldGroupByWeek(query.period); + + // 5. Access scope. + // + // Team scope honours per-board ACL (SDD §10.5, R6) — MEMBER without + // BoardMember on a private board does not see its tasks. + // + // Personal scope deliberately bypasses per-board ACL (SDD §10.5 decision 4): + // if a task is assigned to me, I keep visibility in digest even when I lose + // board ACL later. This matches existing `/api/my-tasks` semantics and + // mirrors the user's mental model ("I see what's mine"). + let taskBoardWhere: { workspaceId: string; id?: { in: string[] } } = { workspaceId }; + if (query.scope === 'team' && role !== 'OWNER') { + const accessibleIds = await accessibleBoardIdsInWorkspace(userId, workspaceId); + taskBoardWhere = { workspaceId, id: { in: accessibleIds } }; + } + + const taskWhere = { + board: taskBoardWhere, + ...(query.scope === 'personal' ? { assigneeId: userId } : {}), + }; + + // 6. Aggregations + // + // a. Closed: first DONE per task (any time) + const closedAggAll = await prisma.taskStatusHistory.groupBy({ + by: ['taskId'], + where: { status: { category: 'DONE' }, task: taskWhere }, + _min: { startedAt: true }, + }); + const closedById = new Map(); + for (const row of closedAggAll) { + if (row._min.startedAt) closedById.set(row.taskId, row._min.startedAt); + } + + const closedInWindowIds: string[] = []; + const closedInPrevIds: string[] = []; + const closedTimestampsById = new Map(); + for (const [taskId, closedAt] of closedById.entries()) { + if (closedAt >= from && closedAt < to) { + closedInWindowIds.push(taskId); + closedTimestampsById.set(taskId, closedAt); + } else if (closedAt >= fromPrev && closedAt < from) { + closedInPrevIds.push(taskId); + } + } + + // b. For closedInWindow — fetch createdAt + dueDate, and firstInProgressAt. + // Note: `closedInWindowIds` is already scope-filtered via `taskWhere` above, + // but we re-apply scope here for defense-in-depth (security review M4). + const closedTaskDetails = closedInWindowIds.length + ? await prisma.task.findMany({ + where: { id: { in: closedInWindowIds }, ...taskWhere }, + select: { id: true, createdAt: true, dueDate: true }, + }) + : []; + const taskDetailById = new Map(closedTaskDetails.map((t) => [t.id, t])); + + const inProgressAgg = closedInWindowIds.length + ? await prisma.taskStatusHistory.groupBy({ + by: ['taskId'], + where: { status: { category: 'IN_PROGRESS' }, taskId: { in: closedInWindowIds } }, + _min: { startedAt: true }, + }) + : []; + const firstInProgressById = new Map(); + for (const row of inProgressAgg) { + if (row._min.startedAt) firstInProgressById.set(row.taskId, row._min.startedAt); + } + + const closedInWindow: ClosedTaskInput[] = closedInWindowIds.map((id) => { + const detail = taskDetailById.get(id); + return { + taskId: id, + closedAt: closedTimestampsById.get(id) as Date, + createdAt: detail?.createdAt ?? new Date(0), + firstInProgressAt: firstInProgressById.get(id) ?? null, + dueDate: detail?.dueDate ?? null, + }; + }); + + // c. Created in window + const created = await prisma.task.findMany({ + where: { ...taskWhere, createdAt: { gte: from, lt: to } }, + select: { createdAt: true }, + }); + + // d. Open tasks (status.category ∈ OPEN, IN_PROGRESS) — for hygiene + wipByStatus + overdueNow + wipByAssignee + const openTasks = await prisma.task.findMany({ + where: { ...taskWhere, status: { category: { in: ['OPEN', 'IN_PROGRESS'] } } }, + select: { + id: true, + dueDate: true, + assigneeId: true, + description: true, + updatedAt: true, + statusId: true, + status: { select: { id: true, name: true, color: true, category: true } }, + assignee: { select: { id: true, name: true, avatar: true } }, + }, + }); + + // e. Overdue: open task with dueDate before today UTC + const todayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const overdueNow = openTasks.filter((t) => t.dueDate && t.dueDate < todayUtc).length; + + // f. WIP by status — group all open tasks by their current status + const wipByStatusMap = new Map(); + for (const t of openTasks) { + const existing = wipByStatusMap.get(t.statusId); + if (existing) { + existing.count += 1; + } else { + wipByStatusMap.set(t.statusId, { statusName: t.status.name, color: t.status.color, count: 1 }); + } + } + const wipByStatusRaw = Array.from(wipByStatusMap.entries()).map(([statusId, v]) => ({ + statusId, + statusName: v.statusName, + color: v.color, + count: v.count, + })); + + // g. WIP by assignee — only IN_PROGRESS tasks (team scope; personal gets null) + const inProgressTasks: InProgressTaskInput[] = openTasks + .filter((t) => t.status.category === 'IN_PROGRESS') + .map((t) => ({ + taskId: t.id, + assigneeId: t.assigneeId ?? null, + assigneeName: t.assignee?.name ?? null, + assigneeAvatar: t.assignee?.avatar ?? null, + })); + + // 7. Build DTO + const openInputs: OpenTaskInput[] = openTasks.map((t) => ({ + id: t.id, + dueDate: t.dueDate, + assigneeId: t.assigneeId, + description: t.description, + updatedAt: t.updatedAt, + })); + + return { + scope: query.scope, + period: query.period, + from: from.toISOString(), + to: to.toISOString(), + productivity: buildProductivity({ + closedInWindow: closedInWindow.map((t) => ({ taskId: t.taskId, closedAt: t.closedAt, createdAt: t.createdAt })), + closedInPrevWindow: closedInPrevIds.map((id) => ({ taskId: id })), + createdInWindow: created.length, + createdTimestamps: created.map((c) => c.createdAt), + from, + to, + groupByWeek, + }), + timeliness: buildTimeliness({ closedInWindow, overdueNow }), + hygiene: buildHygiene({ openTasks: openInputs, now }), + workload: buildWorkload({ + scope: query.scope, + inProgressTasks, + wipByStatusRaw, + }), + }; +} diff --git a/backend/src/shared/openapi/index.ts b/backend/src/shared/openapi/index.ts index a68691d..893cbcf 100644 --- a/backend/src/shared/openapi/index.ts +++ b/backend/src/shared/openapi/index.ts @@ -12,6 +12,7 @@ import './routes/search.js'; import './routes/notifications.js'; import './routes/admin.js'; import './routes/permissions.js'; +import './routes/digest.js'; export function generateOpenApiSpec() { const generator = new OpenApiGeneratorV3(registry.definitions); diff --git a/backend/src/shared/openapi/routes/digest.ts b/backend/src/shared/openapi/routes/digest.ts new file mode 100644 index 0000000..d8e8c82 --- /dev/null +++ b/backend/src/shared/openapi/routes/digest.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { digestQueryDto } from '../../../modules/digest/digest.dto.js'; + +const wsIdParam = z.object({ wid: z.string().uuid() }); + +registry.registerPath({ + method: 'get', + path: '/workspaces/{wid}/digest', + tags: ['Digest'], + summary: 'Дайджест workspace: продуктивность / своевременность / гигиена / нагрузка', + description: + 'Возвращает агрегированные метрики за выбранный период. `scope=team` доступен только OWNER/MEMBER. ' + + 'Для не-OWNER в team scope фильтруются private boards без BoardMember (SDD §10.5).', + request: { + params: wsIdParam, + query: digestQueryDto, + }, + responses: { + 200: { description: 'DigestDto (см. backend/src/modules/digest/digest.dto.ts)' }, + 400: { description: 'Невалидные параметры (INVALID_PERIOD / INVALID_SCOPE)' }, + 401: { description: 'Не авторизован' }, + 403: { description: 'NOT_WORKSPACE_MEMBER или INSUFFICIENT_PERMISSION (viewer + scope=team)' }, + 404: { description: 'WORKSPACE_NOT_FOUND' }, + }, +}); diff --git a/docs/design/digest.md b/docs/design/digest.md new file mode 100644 index 0000000..410c504 --- /dev/null +++ b/docs/design/digest.md @@ -0,0 +1,382 @@ +# SDD: Digest — дайджест задач с метриками + +> Status: **DRAFT** (G2, awaiting approval) +> Owner: jackrescuer-gif (NovakPAai) +> Дата: 2026-05-19 +> Связанные документы: [feature-permissions.md](./feature-permissions.md) +> Deferred issues: [#218](https://github.com/NovakPAai/flow-tasks/issues/218), [#219](https://github.com/NovakPAai/flow-tasks/issues/219), [#220](https://github.com/NovakPAai/flow-tasks/issues/220), [#221](https://github.com/NovakPAai/flow-tasks/issues/221), [#222](https://github.com/NovakPAai/flow-tasks/issues/222) +> Gap issues (открытые вопросы реализации): [#223](https://github.com/NovakPAai/flow-tasks/issues/223) (team scope ACL UX), [#224](https://github.com/NovakPAai/flow-tasks/issues/224) (hygiene weights), [#225](https://github.com/NovakPAai/flow-tasks/issues/225) (stale threshold), [#226](https://github.com/NovakPAai/flow-tasks/issues/226) (description length), [#227](https://github.com/NovakPAai/flow-tasks/issues/227) (approximate cycle time UX), [#228](https://github.com/NovakPAai/flow-tasks/issues/228) (timezone) + +## 1. Цель + +Дать пользователю одну страницу `/w/:slug/digest`, на которой видно: + +- насколько он/команда **продуктивны** (сколько закрыто, динамика); +- насколько **своевременно** работают (доля закрытых в срок, среднее опоздание, cycle time); +- какова **гигиена задач** (задачи без срока/исполнителя/описания, замороженные); +- как распределена **нагрузка** (WIP по людям и по статусам). + +Метрики — менеджерские (не девовские: ни story points, ни velocity, ни deployment frequency). Источники: ClearPoint PM KPIs, Toggl Project Metrics, Kanban Zone, Hubstaff, Miro Agile metrics. + +## 2. Контекст + +### Что есть сейчас + +- Страница `MyTasksPage` показывает только список своих задач — без агрегатов. +- `WorkspaceDashboardPage` — общая страница workspace (приветствие/быстрые ссылки), без метрик. +- Модель данных позволяет всё что нужно — **новых таблиц не требуется**: + - `Task` — `dueDate`, `assigneeId`, `description`, `createdAt`, `updatedAt`, `statusId`. + - `WorkflowStatus.category: OPEN | IN_PROGRESS | DONE | CANCELLED` — точный признак «закрыто». + - `TaskStatusHistory` (taskId, statusId, startedAt, endedAt) — даёт точный cycle time без аппроксимации. +- RBAC уже есть: `WorkspaceMember.role` (OWNER/MEMBER/VIEWER) + `PermissionCode` enum. + +### Что нужно + +1. Новый модуль `modules/digest/` на backend (router + service + dto). +2. Новая страница `frontend/src/pages/DigestPage.tsx`, маршрут `/w/:slug/digest`. +3. Один агрегирующий endpoint `GET /api/workspaces/:id/digest` с параметрами `scope` (personal|team) и `period` (`7d`|`30d`|`90d`). +4. Пункт меню «Дайджест» в `WorkspaceSelector` / sidebar. + +## 3. Метрики (MVP, согласованы на G1 brainstorm) + +Все цифры — за выбранный период `[from, to)`. Сравнение «динамика» = с предыдущим окном такой же длины (`[from - len, from)`). + +### 3.1 Продуктивность + +| Ключ | Описание | Расчёт | +|------|----------|--------| +| `closed` | Закрыто задач за период | `count(task)` где первый переход в `category=DONE` лежит в `[from, to)` (по `TaskStatusHistory`) | +| `closedPrev` | Закрыто в предыдущем окне | то же для `[from-len, from)` | +| `closedDelta` | Δ % vs предыдущий период | `(closed - closedPrev) / max(closedPrev, 1) * 100` | +| `avgPerDay` | Средний темп закрытия | `closed / period_days` | +| `created` | Создано задач за период | `count(task)` где `createdAt ∈ [from, to)` | +| `createdVsClosed` | Растёт ли беклог | spark-данные: для каждого дня периода два числа `{created, closed}` | + +### 3.2 Своевременность + +| Ключ | Описание | Расчёт | +|------|----------|--------| +| `onTimeRate` | % закрытых в срок | `count(closed AND closedAt ≤ dueDate) / count(closed AND dueDate IS NOT NULL)` | +| `avgLatenessDays` | Среднее опоздание просроченных закрытых | `avg(closedAt - dueDate)` для просроченных закрытых, в днях | +| `overdueNow` | Открытых просроченных сейчас | `count(task)` где `statusCategory ≠ DONE/CANCELLED AND dueDate < today` | +| `avgCycleTimeHours` | Cycle time | `avg(closedAt - firstInProgressAt)` в часах, по закрытым в периоде; если задача закрылась минуя `IN_PROGRESS` — fallback `closedAt - createdAt` | + +`closedAt` ≡ `min(TaskStatusHistory.startedAt)` где `status.category = DONE`. +`firstInProgressAt` ≡ `min(TaskStatusHistory.startedAt)` где `status.category = IN_PROGRESS`. + +### 3.3 Гигиена задач + +Считается по **открытым** задачам (`status.category ∉ {DONE, CANCELLED}`) — закрытые не лечим. + +| Ключ | Описание | Расчёт | +|------|----------|--------| +| `noDueDate` | Без срока | `count` где `dueDate IS NULL` | +| `noAssignee` | Без исполнителя | `count` где `assigneeId IS NULL` | +| `noDescription` | Без описания | `count` где `description IS NULL OR length(trim(description)) < 10` | +| `stale14d` | Не двигались >14 дней | `count` где `updatedAt < now - 14d` | +| `totalOpen` | Всего открытых (знаменатель) | `count(task)` где `status.category ∉ {DONE, CANCELLED}` | +| `hygieneScore` | Сводный индекс 0-100 | `round((1 - problematic / (4 * totalOpen)) * 100)` где `problematic = noDueDate + noAssignee + noDescription + stale14d` | + +`hygieneScore` намеренно простой (среднее по 4 проблемам), без весов — нужен beauty contest, не точная наука. Веса можно добавить позже. + +### 3.4 Распределение нагрузки + +| Ключ | Описание | Расчёт | +|------|----------|--------| +| `wipByAssignee` | top-N по WIP | для каждого assignee `count(task) WHERE status.category = IN_PROGRESS`, top 10 | +| `wipByStatus` | Воронка | для каждого `WorkflowStatus` в workflow `count(task)` (только текущее состояние, не история) | + +В **personal scope** (`scope=personal`) фильтр `WHERE assigneeId = currentUserId` применяется ко всем метрикам выше. `wipByAssignee` в personal scope не возвращается (только своя WIP-цифра в виде скаляра). + +## 4. Модель данных + +**Никаких изменений в schema.prisma не требуется.** Всё считается на лету из существующих таблиц. + +Кэш агрегатов — Phase 2 (см. Раздел 9, R3); MVP — синхронный SQL. + +## 5. Алгоритм расчёта (псевдокод) + +``` +GET /api/workspaces/:wsId/digest?scope=personal|team&period=7d|30d|90d + → requireMember(wsId, currentUserId) + → if scope=team: requireRole(OWNER | MEMBER) // viewer лишён team-разреза + → period_days = parsePeriod(period) + → now = startOfDay(today) + → to = now + → from = to - period_days + → fromPrev = from - period_days + + // 1. Список тасков workspace (через все boards) + taskFilter = { + board: { workspaceId: wsId, deletedAt: null }, + ...(scope=personal ? { assigneeId: currentUserId } : {}), + } + + // 2. Закрытые в окне [from, to) + closedTasks = TaskStatusHistory.findMany({ + where: { + task: taskFilter, + status: { category: 'DONE' }, + startedAt: { gte: from, lt: to }, + }, + distinct: ['taskId'], // первая запись DONE на задачу + orderBy: { startedAt: 'asc' }, + include: { task: true }, + }) + + closedPrev = same query for [fromPrev, from) + closedAtById = Map(taskId → first DONE.startedAt) + + // 3. firstInProgressAt для закрытых в окне + firstInProgress = TaskStatusHistory.findMany({ + where: { + taskId: { in: closedTasks.taskIds }, + status: { category: 'IN_PROGRESS' }, + }, + distinct: ['taskId'], + orderBy: { startedAt: 'asc' }, + }) + firstInProgressById = Map(taskId → startedAt) + + // 4. Open tasks для hygiene / overdue / WIP + openTasks = Task.findMany({ + where: { ...taskFilter, status: { category: { in: ['OPEN', 'IN_PROGRESS'] } } }, + include: { status: true }, + }) + + // 5. Built-up metrics → DigestDto (раздел 6) +``` + +Сложность: 4 SELECT-а — приемлемо для workspace до ~50k задач. Если задач больше — оптимизация через materialized view (Phase 2). + +### Тонкости + +- **Cancelled** — исключаем из «закрытых». Для cycle time / onTimeRate / closed — только category=DONE. +- **Reopen** — если задачу закрывали несколько раз, берём первый DONE в периоде. Если потом её снова открыли и закрыли в том же окне — не двойной счёт (distinct по taskId). +- **dueDate** хранится как `@db.Date` (без времени) — сравниваем как `closedAt::date <= dueDate`. +- **Boards мягко удалённые** не учитываем (`board.deletedAt` — для воркспейса, для board нет soft-delete; убедиться при имплементации). + +## 6. API контракты + +### 6.1 Endpoint + +``` +GET /api/workspaces/:id/digest?scope=personal&period=7d +``` + +**Параметры**: + +| Имя | Тип | Дефолт | Валидация | +|-----|-----|--------|-----------| +| `:id` | uuid | — | существующий workspace, пользователь — член | +| `scope` | `'personal' \| 'team'` | `'personal'` | Zod enum | +| `period` | `'7d' \| '30d' \| '90d'` | `'7d'` | Zod enum | + +**Auth**: JWT + WorkspaceMember. Для `scope=team` — `role ∈ {OWNER, MEMBER}` (не VIEWER). + +**Ошибки**: +- `401 UNAUTHORIZED` — нет токена. +- `403 NOT_WORKSPACE_MEMBER` — пользователь не в workspace. +- `403 INSUFFICIENT_PERMISSION` — VIEWER пытается запросить `scope=team`. +- `404 WORKSPACE_NOT_FOUND` — нет workspace или soft-deleted. +- `400 INVALID_PERIOD` — period вне `7d|30d|90d`. + +### 6.2 DTO + +```typescript +type DigestPeriod = '7d' | '30d' | '90d'; +type DigestScope = 'personal' | 'team'; + +type DigestDto = { + scope: DigestScope; + period: DigestPeriod; + from: string; // ISO date (inclusive) + to: string; // ISO date (exclusive) + productivity: { + closed: number; + closedPrev: number; + closedDelta: number; // % (round, может быть отрицательным или null если closedPrev=0) + avgPerDay: number; // round to 1 decimal + created: number; + timeseries: Array<{ date: string; created: number; closed: number }>; // длина = period_days + }; + timeliness: { + onTimeRate: number | null; // 0..1, null если нет задач с dueDate + avgLatenessDays: number | null; // null если нет просроченных + overdueNow: number; + avgCycleTimeHours: number | null; // null если closed=0 + }; + hygiene: { + totalOpen: number; + noDueDate: number; + noAssignee: number; + noDescription: number; + stale14d: number; + hygieneScore: number; // 0..100, при totalOpen=0 → 100 + }; + workload: { + wipByAssignee: Array<{ userId: string; name: string; avatar: string | null; wip: number }> | null; + // null в scope=personal + wipByStatus: Array<{ statusId: string; statusName: string; color: string; count: number }>; + }; +}; +``` + +### 6.3 Формат ошибок + +Стандартный для проекта: +```typescript +{ error: { code: string; message: string; details?: unknown } } +``` + +## 7. UX & Accessibility + +**Целевой WCAG**: AA (для дашборда AAA не требуется — здесь нет критичных flow вроде платежей). + +### 7.1 Layout + +Страница `/w/:slug/digest`: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Дайджест │ +│ ┌──────────────────────┐ ┌────────────────────────┐ │ +│ │ Scope: ◉ Личный │ │ Период: ( ) 7д (●) 30д ( ) 90д │ │ +│ │ ○ Командный │ └────────────────────────┘ │ +│ └──────────────────────┘ │ +│ │ +│ ┌── Продуктивность ──┐ ┌── Своевременность ──┐ ┌── Гигиена ──┐ │ +│ │ 42 закрыто │ │ 78% в срок │ │ Score 73 │ │ +│ │ ▲ +18% vs прошлое │ │ Avg lateness: 2.1д │ │ ⚠ 12 без срока│ │ +│ │ Avg 1.4/день │ │ Overdue now: 5 │ │ ⚠ 4 без assignee│ │ +│ │ ─── created │ Cycle: 18ч avg │ │ ⚠ 8 stale (>14д)│ │ +│ │ ─── closed (sparkline) │ └──────────────────────┘ └──────────────┘ │ +│ └────────────────────┘ │ +│ │ +│ ┌── Распределение нагрузки (только team scope) ───────────────┐│ +│ │ WIP по людям (bar chart top-10) ││ +│ │ WIP по статусам (bar chart) ││ +│ └───────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +В **personal scope** последняя секция заменяется на «Моя WIP: N задач в работе». + +### 7.2 Required UI states + +| Состояние | Поведение | +|-----------|-----------| +| Loading | Skeleton-карточки (4 шт по сетке) c shimmer. Не блокируем переключатели scope/period — они remount при готовности данных | +| Empty (новый workspace, 0 задач за весь период) | Иллюстрация + «Здесь будут метрики, когда команда начнёт работать с задачами» + CTA «Создать первую задачу» (ссылка на /w/:slug) | +| Empty (выбранный период пуст, но в workspace есть задачи) | Карточки показывают «0», явный hint «За последние 7 дней ничего не закрыто. Попробуйте период 30д» | +| Error (network/500) | Inline error-card на месте каждой секции: «Не удалось загрузить» + кнопка Retry. Переключатели scope/period работают | +| Error (403 VIEWER на team) | Toast «Командный разрез доступен только участникам с ролью Owner или Member» + автопереключение на personal | +| Disabled (Viewer) | Радио «Командный» disabled с tooltip «Доступно только Owner / Member» | +| Stale (cached) | Если данные старше 5 мин — мелкая метка «Обновлено N мин назад» + кнопка Refresh | + +### 7.3 Клавиатура + +- Tab order: scope-radio → period-radio → секции (h2 → sparkline alt → bars) → footer +- Радио-группы — стандартное arrow-key поведение +- Focus visible на всех interactive (outline-offset 2px, не убирать `outline:none`) +- `R` (shortcut) — refresh (тот же что в нав-меню других страниц, если есть; иначе skip) + +### 7.4 Screen reader + +- Каждая metric-карточка: `role="region"` с `aria-label="Продуктивность"` +- Числа: визуально крупные, но в aria-label полное предложение: `aria-label="Закрыто 42 задачи, рост на 18 процентов"` +- Sparkline: `` + табличная альтернатива `` в visually-hidden +- При смене scope/period — `aria-live="polite"` объявляет «Данные обновлены» +- Hygiene score: `aria-label="Индекс гигиены 73 из 100"` + +### 7.5 Touch targets / Responsive + +- Радио-кнопки и кнопки Refresh ≥ 44×44 +- Desktop: 4 карточки в ряд (CSS grid `repeat(auto-fit, minmax(280px, 1fr))`) +- Tablet ≤ 1024: 2 в ряд +- Mobile ≤ 640: 1 в ряд, sparkline компактнее, bar chart top-5 вместо top-10 + +### 7.6 Performance budget + +- LCP < 2.5s (агрегат на 50k задач должен укладываться в 800ms на backend; frontend ≤ 1700ms) +- INP < 200ms на переключение scope/period (показываем skeleton немедленно, фактический ответ может занять до 800ms) +- CLS < 0.1 (skeleton-карточки точного размера, чтобы при подгрузке не дёргалось) + +### 7.7 Edge данные + +- Длинные имена пользователей в WIP-баре — truncate с tooltip +- 0 задач с dueDate → `onTimeRate` показываем как «—» с пометкой «нет задач со сроком» +- 0 задач IN_PROGRESS → WIP-баблы пустые, hint «В работе ничего нет» +- Дата границы периода в UTC vs локали — фиксируем: считаем по UTC-датам, в UI показываем `from – to` в локали пользователя + +## 8. Стыки + +### Backend + +- `backend/src/prisma/schema.prisma` — **без изменений** +- `backend/src/modules/digest/digest.router.ts` — новый +- `backend/src/modules/digest/digest.service.ts` — новый, агрегация +- `backend/src/modules/digest/digest.dto.ts` — новый, Zod схемы +- `backend/src/app.ts` — подключить роутер `app.use('/api/workspaces/:id/digest', ...)` (либо смонтировать через workspaces router) +- `backend/src/shared/openapi/` — добавить регистрацию DTO для OpenAPI +- `backend/__tests__/digest.test.ts` — integration tests + +### Frontend + +- `frontend/src/api/digest.ts` — клиент `getDigest({ workspaceId, scope, period })` +- `frontend/src/pages/DigestPage.tsx` — страница +- `frontend/src/components/DigestCard.tsx` — компонент карточки метрики (используется 4 раза) +- `frontend/src/components/DigestSparkline.tsx` — мини-график created/closed (можно через `recharts` если уже в deps, иначе SVG inline) +- `frontend/src/components/WorkloadChart.tsx` — bar charts (если recharts нет, делаем SVG inline + табличный fallback для SR) +- `frontend/src/components/AppLayout.tsx` — добавить пункт меню «Дайджест» в WorkspaceSelector / sidebar +- `frontend/src/App.tsx` — маршрут `/w/:slug/digest` +- `frontend/e2e/digest.spec.ts` — Playwright happy path + keyboard + +### Не трогаем + +- Schema Prisma (см. R1 ниже) +- Auth, RBAC проверки — переиспользуем `requireWorkspaceMember` middleware +- Существующие метрики (никаких) — никаких legacy интеграций + +## 9. Риски + +| # | Риск | Вероятность | Mitigation | +|---|------|-------------|------------| +| R1 | Производительность агрегата на больших workspace (>50k задач) | Средняя | MVP — синхронный SQL с правильными индексами (есть на `boardId+statusId+orderIndex`, `dueDate`, `assigneeId`). Перф-тест на 50k seed. Phase 2 — Redis cache с TTL=5min + invalidate on task write. Issue заведём после prod-метрик. | +| R2 | TaskStatusHistory может быть не заполнена для старых задач (миграция вводилась позже) | Высокая | Fallback в cycle time: если нет `firstInProgressAt` — берём `createdAt`. Документируем в UI tooltip «Cycle time приблизителен для задач до DATE». | +| R3 | Кэш-инвалидация (когда введём в Phase 2) | Низкая (MVP без кэша) | Не применимо в MVP. Issue заведём вместе с кэш-имплементацией. | +| R4 | Дрейф `closedAt` между `Task.updatedAt` и `TaskStatusHistory.startedAt` | Низкая | Источник истины — `TaskStatusHistory`. `Task.updatedAt` может смещаться при правках полей после закрытия. Тест: правка task с category=DONE не меняет cycle time. | +| R5 | Personal scope течёт информация о чужих задачах через assignee-фильтр (нет утечек, но виден список своих задач даже там, где доступ к доске урезан ACL) | Средняя | `digest.service.ts` фильтрует только по `assigneeId=me + boardWorkspaceId=ws`. **Не** проверяем per-board ACL для personal — если задача назначена на меня, это значит мне дали к ней доступ. Документируем в коммент-блоке `digest.service.ts`. | +| R6 | Team scope даёт MEMBER видимость всего workspace, в т.ч. private boards к которым у него нет доступа | **Высокая** | **Корректировка scope=team**: фильтруем `boardId IN (boards к которым у пользователя есть доступ)` — используем тот же helper что в существующих list-эндпойнтах. Если board private и пользователь не в его BoardMember — задачи этого board не входят в агрегат. Owner workspace — обходит этот фильтр (видит всё). Покрываем в integration test. | +| R7 | Hygiene score «штрафует» новые workspaces | Средняя | При `totalOpen=0` → score=100 (есть в формуле). Tooltip объясняет: «Считается только по открытым задачам». | +| R8 | Часовой пояс пользователя vs UTC | Низкая | Все границы периода считаем по UTC (`startOfDay(now, utc)`). В UI показываем `from-to` через `dayjs.tz(user.tz)`. Frontend знает зону через `Intl.DateTimeFormat().resolvedOptions().timeZone`. | +| R9 | Sparkline для 90d даёт 90 точек — может быть шумно | Низкая | Для 90d агрегируем по неделям (13 точек) вместо дней. Бейдж «По неделям» в tooltip. | +| R10 | Endpoint становится «дорогим N+1» если не оптимизировать | Средняя | Всё на 4-5 SQL без N+1: один запрос на closed/closedPrev, один на TaskStatusHistory IN_PROGRESS, один на open tasks (с include status, assignee select fields). Тест на запросы (вытаскиваем через prisma `$on('query')`). | + +## 10. Решения (зафиксированы 2026-05-19) + +1. **Без новых таблиц в Prisma** — все агрегаты на лету. Кэширование — Phase 2 (вместе с issue по перф-тесту). +2. **Cycle time источник = TaskStatusHistory** — точное измерение, без аппроксимации. Fallback на `createdAt` только для задач без IN_PROGRESS истории. +3. **Visibility team scope = OWNER + MEMBER**, VIEWER — нет (получает 403 + автопереключение в UI). +4. **Personal scope не учитывает per-board ACL** для своих задач (если она назначена на меня — я к ней допущен). +5. **Team scope учитывает per-board ACL** — фильтр через `accessible_boards(userId, workspaceId)`. Owner workspace обходит фильтр. +6. **Hygiene score простой** = `(1 - problematic / 4N) × 100`. Без весов в MVP; tunability — потом. +7. **Период**: 7d / 30d / 90d пресеты, без custom range (issue #222). +8. **Без CSV экспорта в MVP** (issue #220). +9. **Без email/push дайджеста в MVP** (issue #221). +10. **Sparkline для 90d** — по неделям (13 точек), для 7d/30d — по дням. + +--- + +## 11. Roadmap после MVP + +После раскатки MVP — отдельными PR-ами: + +- **Phase 2** — Redis-кэш с TTL=5min + invalidation hook +- **Phase 3** — кастомный диапазон дат ([#222](https://github.com/NovakPAai/flow-tasks/issues/222)) +- **Phase 4** — CSV экспорт ([#220](https://github.com/NovakPAai/flow-tasks/issues/220)) +- **Phase 5** — email/Telegram weekly digest ([#221](https://github.com/NovakPAai/flow-tasks/issues/221)) +- **Phase 6** — time-in-status bottleneck chart ([#219](https://github.com/NovakPAai/flow-tasks/issues/219)) +- **Phase 7** — cross-workspace personal digest на `/my-tasks` + +--- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cca9413..a0dc8be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import TrashPage from './pages/TrashPage'; import AdminUsersPage from './pages/AdminUsersPage'; import ResetPasswordPage from './pages/ResetPasswordPage'; import RoadmapsPage from './pages/RoadmapsPage'; +import DigestPage from './pages/DigestPage'; import FeedbackFAB from './components/FeedbackFAB'; const WARN_SECONDS = 60; @@ -168,6 +169,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/__tests__/DigestCard.test.tsx b/frontend/src/__tests__/DigestCard.test.tsx new file mode 100644 index 0000000..3441147 --- /dev/null +++ b/frontend/src/__tests__/DigestCard.test.tsx @@ -0,0 +1,48 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import DigestCard from '../components/digest/DigestCard'; + +describe('DigestCard', () => { + afterEach(() => cleanup()); + + it('renders children when not loading and no error', () => { + render( + +
content
+
, + ); + expect(screen.getByText('content')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Продуктивность' })).toBeInTheDocument(); + }); + + it('shows skeleton when loading', () => { + const { container } = render( + +
content
+
, + ); + expect(screen.queryByText('content')).toBeNull(); + expect(container.querySelector('.ant-skeleton')).toBeTruthy(); + }); + + it('shows error + retry button and fires onRetry on click', () => { + const onRetry = vi.fn(); + render( + +
content
+
, + ); + expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить'); + fireEvent.click(screen.getByRole('button', { name: /повторить/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('hides retry button when onRetry is not provided', () => { + render( + +
content
+
, + ); + expect(screen.queryByRole('button', { name: /повторить/i })).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/DigestSparkline.test.tsx b/frontend/src/__tests__/DigestSparkline.test.tsx new file mode 100644 index 0000000..595d29e --- /dev/null +++ b/frontend/src/__tests__/DigestSparkline.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, afterEach } from 'vitest'; +import DigestSparkline from '../components/digest/DigestSparkline'; + +describe('DigestSparkline', () => { + afterEach(() => cleanup()); + + it('renders "Нет данных за период" when timeseries is empty', () => { + render(); + expect(screen.getByText('Нет данных за период')).toBeInTheDocument(); + }); + + it('renders SVG with aria-label containing totals', () => { + const series = [ + { date: '2026-05-14', created: 5, closed: 3 }, + { date: '2026-05-15', created: 2, closed: 4 }, + ]; + render(); + const svg = screen.getByRole('img'); + expect(svg.getAttribute('aria-label')).toContain('создано 7'); + expect(svg.getAttribute('aria-label')).toContain('закрыто 7'); + }); + + it('shows "По неделям" badge when byWeek=true', () => { + const series = [{ date: '2026-02-19', created: 1, closed: 1 }]; + render(); + expect(screen.getByText('По неделям')).toBeInTheDocument(); + }); + + it('emits a visually-hidden table with per-day rows for screen readers', () => { + const series = [ + { date: '2026-05-14', created: 1, closed: 2 }, + { date: '2026-05-15', created: 3, closed: 4 }, + ]; + const { container } = render(); + const rows = container.querySelectorAll('table tbody tr'); + expect(rows.length).toBe(2); + expect(rows[0]).toHaveTextContent('2026-05-14'); + expect(rows[1]).toHaveTextContent('2026-05-15'); + }); +}); diff --git a/frontend/src/__tests__/WorkloadChart.test.tsx b/frontend/src/__tests__/WorkloadChart.test.tsx new file mode 100644 index 0000000..cf1d770 --- /dev/null +++ b/frontend/src/__tests__/WorkloadChart.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import WorkloadChart from '../components/digest/WorkloadChart'; + +vi.mock('../utils/useIsMobile', () => ({ + useIsMobile: () => false, +})); + +describe('WorkloadChart', () => { + afterEach(() => cleanup()); + + it('shows "В работе ничего нет" when both inputs are empty', () => { + render(); + expect(screen.getByText('В работе ничего нет')).toBeInTheDocument(); + }); + + it('renders top-N assignees sorted desc by wip', () => { + const assignees = [ + { userId: 'u1', name: 'Alice', avatar: null, wip: 3 }, + { userId: 'u2', name: 'Bob', avatar: null, wip: 7 }, + { userId: 'u3', name: 'Carol', avatar: null, wip: 5 }, + ]; + render(); + // Note: assignees come pre-sorted from backend; component renders order it receives. + // Sanity — all 3 names rendered. + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Carol')).toBeInTheDocument(); + }); + + it('truncates long names and preserves full name in aria-label', () => { + const longName = 'Александр-Кристофер фон Лонг-Хайфен Розенберг Молли Третий'; + render( + , + ); + const group = screen.getByRole('group', { name: new RegExp(longName.slice(0, 20)) }); + expect(group.getAttribute('aria-label')).toContain(longName); + }); + + it('renders wipByStatus rows with status names', () => { + render( + , + ); + expect(screen.getByText('To Do')).toBeInTheDocument(); + expect(screen.getByText('Doing')).toBeInTheDocument(); + }); + + it('shows "Все колонки пусты" when all status counts are zero', () => { + render( + , + ); + expect(screen.getByText('Все колонки пусты')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/WorkloadChartMobile.test.tsx b/frontend/src/__tests__/WorkloadChartMobile.test.tsx new file mode 100644 index 0000000..583ff8c --- /dev/null +++ b/frontend/src/__tests__/WorkloadChartMobile.test.tsx @@ -0,0 +1,37 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; + +vi.mock('../utils/useIsMobile', () => ({ + useIsMobile: () => true, +})); + +// Import after mock is registered +const { default: WorkloadChart } = await import('../components/digest/WorkloadChart'); + +describe('WorkloadChart — mobile (top-5)', () => { + afterEach(() => cleanup()); + + it('renders only top 5 assignees when on mobile, hiding the rest', () => { + const assignees = Array.from({ length: 12 }, (_, i) => ({ + userId: `u${i}`, + name: `User-${i}`, + avatar: null, + wip: 12 - i, + })); + render(); + expect(screen.getByText('User-0')).toBeInTheDocument(); + expect(screen.getByText('User-4')).toBeInTheDocument(); + expect(screen.queryByText('User-5')).toBeNull(); + expect(screen.queryByText('User-11')).toBeNull(); + }); + + it('header text shows top {topN} matching mobile=5', () => { + render( + , + ); + expect(screen.getByText(/top 5/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/digest.ts b/frontend/src/api/digest.ts new file mode 100644 index 0000000..7194571 --- /dev/null +++ b/frontend/src/api/digest.ts @@ -0,0 +1,72 @@ +import api from './client'; + +export type DigestPeriod = '7d' | '30d' | '90d'; +export type DigestScope = 'personal' | 'team'; + +export interface ProductivityDto { + closed: number; + closedPrev: number; + closedDelta: number | null; + avgPerDay: number; + created: number; + timeseries: Array<{ date: string; created: number; closed: number }>; +} + +export interface TimelinessDto { + onTimeRate: number | null; + avgLatenessDays: number | null; + overdueNow: number; + avgCycleTimeHours: number | null; +} + +export interface HygieneDto { + totalOpen: number; + noDueDate: number; + noAssignee: number; + noDescription: number; + stale14d: number; + hygieneScore: number; +} + +export interface WipByAssigneeDto { + userId: string; + name: string; + avatar: string | null; + wip: number; +} + +export interface WipByStatusDto { + statusId: string; + statusName: string; + color: string; + count: number; +} + +export interface WorkloadDto { + wipByAssignee: WipByAssigneeDto[] | null; + wipByStatus: WipByStatusDto[]; +} + +export interface DigestDto { + scope: DigestScope; + period: DigestPeriod; + from: string; + to: string; + productivity: ProductivityDto; + timeliness: TimelinessDto; + hygiene: HygieneDto; + workload: WorkloadDto; +} + +export async function getDigest( + workspaceId: string, + scope: DigestScope, + period: DigestPeriod, + signal?: AbortSignal, +): Promise { + const { data } = await api.get(`/workspaces/${workspaceId}/digest`, { + params: { scope, period }, + signal, + }); + return data; +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 0f4a353..51ae521 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -352,6 +352,7 @@ export default function AppLayout({ children }: Props) { const isWorkspace = !!urlSlug; const isRoadmaps = isWorkspace && location.pathname.endsWith('/roadmaps'); + const isDigest = isWorkspace && location.pathname.endsWith('/digest'); const isBoards = isWorkspace && (location.pathname === `/w/${urlSlug}` || location.pathname.startsWith(`/w/${urlSlug}/boards`)); @@ -461,6 +462,20 @@ export default function AppLayout({ children }: Props) { Roadmaps + + {/* Digest tab */} +
navigate(`/w/${current.slug}/digest`)} + style={{ + alignItems: 'center', backgroundColor: isDigest ? tabActiveBg : 'transparent', + borderRadius: 8, cursor: 'pointer', display: 'flex', gap: 4, + paddingBlock: 5, paddingInline: 12, + }} + > + + Дайджест + +
)} diff --git a/frontend/src/components/digest/DigestCard.tsx b/frontend/src/components/digest/DigestCard.tsx new file mode 100644 index 0000000..4b5ea5d --- /dev/null +++ b/frontend/src/components/digest/DigestCard.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react'; +import { Card, Skeleton, Button } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; + +interface DigestCardProps { + title: string; + loading: boolean; + error: string | null; + onRetry?: () => void; + minHeight?: number; + skeletonRows?: number; + /** id of the heading element this region is labelled by (for aria-labelledby) */ + headingId?: string; + children: ReactNode; +} + +export default function DigestCard({ + title, + loading, + error, + onRetry, + minHeight = 240, + skeletonRows = 3, + headingId, + children, +}: DigestCardProps) { + const regionProps = headingId + ? { 'aria-labelledby': headingId } + : { 'aria-label': title }; + return ( + {title} : title} + role="region" + style={{ minHeight, height: '100%' }} + styles={{ body: { minHeight: minHeight - 56 } }} + {...regionProps} + > + {loading ? ( + + ) : error ? ( +
+ {error} + {onRetry && ( + + )} +
+ ) : ( + children + )} +
+ ); +} diff --git a/frontend/src/components/digest/DigestSparkline.tsx b/frontend/src/components/digest/DigestSparkline.tsx new file mode 100644 index 0000000..60e42af --- /dev/null +++ b/frontend/src/components/digest/DigestSparkline.tsx @@ -0,0 +1,113 @@ +interface SparklinePoint { + date: string; + created: number; + closed: number; +} + +interface DigestSparklineProps { + timeseries: SparklinePoint[]; + byWeek?: boolean; + height?: number; +} + +const VIEWBOX_WIDTH = 260; + +const srOnlyStyle: React.CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + border: 0, +}; + +export default function DigestSparkline({ timeseries, byWeek = false, height = 64 }: DigestSparklineProps) { + if (timeseries.length === 0) { + return ( +
+ Нет данных за период +
+ ); + } + + const max = Math.max(1, ...timeseries.flatMap((p) => [p.created, p.closed])); + const stepX = timeseries.length > 1 ? VIEWBOX_WIDTH / (timeseries.length - 1) : 0; + + const createdPath = timeseries + .map((p, i) => `${i === 0 ? 'M' : 'L'}${i * stepX},${height - (p.created / max) * (height - 8) - 4}`) + .join(' '); + const closedPath = timeseries + .map((p, i) => `${i === 0 ? 'M' : 'L'}${i * stepX},${height - (p.closed / max) * (height - 8) - 4}`) + .join(' '); + + const totalCreated = timeseries.reduce((sum, p) => sum + p.created, 0); + const totalClosed = timeseries.reduce((sum, p) => sum + p.closed, 0); + const label = `График: создано ${totalCreated}, закрыто ${totalClosed} ${ + byWeek ? 'по неделям' : 'по дням' + } (${timeseries.length} ${byWeek ? 'недель' : 'дней'})`; + + return ( +
+ {byWeek && ( + + По неделям + + )} + + + + +
+ + +
+ {/* Visually-hidden table for screen readers (modern sr-only) */} +
+ + + + + + + + + + {timeseries.map((p) => ( + + + + + + ))} + +
{label}
{byWeek ? 'Неделя с' : 'Дата'}СозданоЗакрыто
{p.date}{p.created}{p.closed}
+ + ); +} diff --git a/frontend/src/components/digest/WorkloadChart.tsx b/frontend/src/components/digest/WorkloadChart.tsx new file mode 100644 index 0000000..328bba2 --- /dev/null +++ b/frontend/src/components/digest/WorkloadChart.tsx @@ -0,0 +1,134 @@ +import { Tooltip } from 'antd'; +import { useIsMobile } from '../../utils/useIsMobile'; +import type { WipByAssigneeDto, WipByStatusDto } from '../../api/digest'; + +interface WorkloadChartProps { + wipByAssignee: WipByAssigneeDto[] | null; + wipByStatus: WipByStatusDto[]; +} + +const MAX_NAME_CHARS = 28; + +function truncate(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} + +function HorizontalBar({ + label, + value, + max, + color, + ariaLabel, + needsTooltip, + fullText, +}: { + label: string; + value: number; + max: number; + color: string; + ariaLabel: string; + needsTooltip: boolean; + fullText?: string; +}) { + const pct = max > 0 ? Math.round((value / max) * 100) : 0; + const content = ( +
+ + {label} + +
+
+
+ {value} +
+ ); + return needsTooltip && fullText ? {content} : content; +} + +export default function WorkloadChart({ wipByAssignee, wipByStatus }: WorkloadChartProps) { + const isMobile = useIsMobile(); + const topN = isMobile ? 5 : 10; + + const assigneesEmpty = !wipByAssignee || wipByAssignee.length === 0; + const statusEmpty = wipByStatus.length === 0 || wipByStatus.every((s) => s.count === 0); + + if (assigneesEmpty && statusEmpty) { + return В работе ничего нет; + } + + const assigneeMax = wipByAssignee ? Math.max(1, ...wipByAssignee.map((a) => a.wip)) : 1; + const statusMax = Math.max(1, ...wipByStatus.map((s) => s.count)); + + return ( +
+ + {wipByAssignee && ( +
+
+ WIP по людям (top {topN}, max {assigneeMax}) +
+ {wipByAssignee.length === 0 ? ( + Нет назначенных задач в работе + ) : ( +
+ {wipByAssignee.slice(0, topN).map((a) => { + const truncated = a.name.length > MAX_NAME_CHARS; + return ( + + ); + })} +
+ )} +
+ )} + +
+
WIP по статусам
+ {statusEmpty ? ( + Все колонки пусты + ) : ( +
+ {wipByStatus.map((s) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useDigest.ts b/frontend/src/hooks/useDigest.ts new file mode 100644 index 0000000..5635248 --- /dev/null +++ b/frontend/src/hooks/useDigest.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { getDigest, type DigestDto, type DigestPeriod, type DigestScope } from '../api/digest'; + +export interface UseDigestResult { + data: DigestDto | null; + loading: boolean; + error: string | null; + forbiddenTeam: boolean; + refetch: () => void; +} + +interface ErrorBody { + code?: string; + error?: string; +} + +function mapServerError(status: number | undefined, raw: string | undefined): string { + if (status === undefined) return 'Не удалось загрузить дайджест'; + if (status >= 500) return 'Сервер сейчас недоступен. Попробуйте обновить через минуту.'; + if (status === 408) return 'Запрос занял слишком много времени'; + if (raw && raw.length < 200) return raw; + return 'Не удалось загрузить дайджест'; +} + +export function useDigest( + workspaceId: string | null, + scope: DigestScope, + period: DigestPeriod, +): UseDigestResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [forbiddenTeam, setForbiddenTeam] = useState(false); + const [refreshTick, setRefreshTick] = useState(0); + const controllerRef = useRef(null); + + useEffect(() => { + if (!workspaceId) { + return; + } + controllerRef.current?.abort(new DOMException('Superseded', 'AbortError')); + const ctrl = new AbortController(); + controllerRef.current = ctrl; + + /* eslint-disable react-hooks/set-state-in-effect */ + setLoading(true); + setError(null); + setForbiddenTeam(false); + /* eslint-enable react-hooks/set-state-in-effect */ + + getDigest(workspaceId, scope, period, ctrl.signal) + .then((d) => { + if (ctrl.signal.aborted) return; + setData(d); + setLoading(false); + }) + .catch((err: unknown) => { + if (axios.isCancel(err) || ctrl.signal.aborted) return; + if (axios.isAxiosError(err)) { + if (err.response?.status === 403 && err.response.data?.code === 'INSUFFICIENT_PERMISSION') { + setForbiddenTeam(true); + setError('Командный разрез доступен только Owner или Member'); + setLoading(false); + return; + } + const raw = typeof err.response?.data?.error === 'string' ? err.response.data.error : undefined; + setError(mapServerError(err.response?.status, raw)); + } else if (err instanceof Error) { + setError(err.message); + } else { + setError('Не удалось загрузить дайджест'); + } + setLoading(false); + }); + + return () => ctrl.abort(new DOMException('Superseded', 'AbortError')); + }, [workspaceId, scope, period, refreshTick]); + + const refetch = useCallback(() => setRefreshTick((n) => n + 1), []); + + return { data, loading, error, forbiddenTeam, refetch }; +} diff --git a/frontend/src/pages/DigestPage.tsx b/frontend/src/pages/DigestPage.tsx new file mode 100644 index 0000000..332c3fe --- /dev/null +++ b/frontend/src/pages/DigestPage.tsx @@ -0,0 +1,391 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Radio, Button, message, Tooltip, theme } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import { useWorkspaceStore } from '../store/workspace.store'; +import { useDigest } from '../hooks/useDigest'; +import DigestCard from '../components/digest/DigestCard'; +import DigestSparkline from '../components/digest/DigestSparkline'; +import WorkloadChart from '../components/digest/WorkloadChart'; +import type { DigestPeriod, DigestScope } from '../api/digest'; + +const srOnlyStyle: React.CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + border: 0, +}; + +const PERIOD_LABEL: Record = { + '7d': '7 дней', + '30d': '30 дней', + '90d': '90 дней', +}; + +function formatPercent(n: number | null): string { + if (n === null) return '—'; + return `${Math.round(n * 100)}%`; +} + +function formatHours(n: number | null): string { + if (n === null) return '—'; + if (n < 0) return '—'; + if (n < 24) return `${n.toFixed(1)}ч`; + return `${(n / 24).toFixed(1)} календ. дн`; +} + +export default function DigestPage() { + const { slug } = useParams<{ slug: string }>(); + const navigate = useNavigate(); + const { token } = theme.useToken(); + const { workspaces, current, setCurrent, load } = useWorkspaceStore(); + const [scope, setScope] = useState('personal'); + const [period, setPeriod] = useState('7d'); + const [statusMessage, setStatusMessage] = useState(''); + + useEffect(() => { + if (workspaces.length === 0) void load(); + }, [workspaces.length, load]); + + const foundWs = useMemo(() => workspaces.find((w) => w.slug === slug) ?? null, [workspaces, slug]); + + useEffect(() => { + if (foundWs && (!current || current.id !== foundWs.id)) { + setCurrent(foundWs); + } + }, [foundWs, current, setCurrent]); + + const workspaceId = foundWs?.id ?? null; + const role = foundWs?.role; + const isViewer = role === 'VIEWER'; + + // Derive effective scope synchronously (no useEffect-loop): viewer is always personal. + const effectiveScope: DigestScope = isViewer && scope === 'team' ? 'personal' : scope; + + const { data, loading, error, forbiddenTeam, refetch } = useDigest(workspaceId, effectiveScope, period); + + // Auto-switch + toast when backend returns 403 (e.g. stale role on client). + useEffect(() => { + if (forbiddenTeam && scope === 'team') { + message.warning('Командный разрез недоступен (нужна роль Owner или Member). Показываем личные метрики.'); + // eslint-disable-next-line react-hooks/set-state-in-effect + setScope('personal'); + } + }, [forbiddenTeam, scope]); + + // Announce data refresh for screen readers via a dedicated live region. + useEffect(() => { + if (data) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setStatusMessage( + `Данные обновлены: ${effectiveScope === 'personal' ? 'личный' : 'командный'} разрез за ${PERIOD_LABEL[period]}`, + ); + } + }, [data, effectiveScope, period]); + + // Keyboard shortcut: R = refresh (when focus is not in input/textarea). + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== 'r' && e.key !== 'R') return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target as HTMLElement | null; + const tag = t?.tagName?.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return; + e.preventDefault(); + refetch(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [refetch]); + + const handleScopeChange = (value: DigestScope) => { + if (isViewer && value === 'team') { + message.warning('Командный разрез доступен только Owner или Member'); + return; + } + setScope(value); + }; + + if (!foundWs) { + return
Загрузка workspace…
; + } + + // Distinguish "new workspace (no tasks at all)" vs "period is empty" + const isWorkspaceEmpty = + !!data && data.hygiene.totalOpen === 0 && data.productivity.closed === 0 && data.productivity.created === 0; + const isPeriodEmpty = + !!data && data.productivity.closed === 0 && data.productivity.created === 0 && data.hygiene.totalOpen > 0; + + return ( +
+ {/* Dedicated live region (visually hidden) — single announcer */} +
+ {statusMessage} +
+ +
+

Дайджест

+ + handleScopeChange(e.target.value as DigestScope)} + aria-label="Чьи задачи показывать" + size="large" + > + Личный + {isViewer ? ( + + + + Командный + + + + ) : ( + Командный + )} + + + setPeriod(e.target.value as DigestPeriod)} + aria-label={`Период: выбрано ${PERIOD_LABEL[period]}`} + size="large" + > + + 30д + 90д + + + +
+ + {isWorkspaceEmpty && ( +
+ +
+ Здесь будут метрики, когда команда начнёт работать с задачами +
+ +
+ )} + + {isPeriodEmpty && ( +
+ {period === '7d' && ( + <> + За последние 7 дней ничего не закрыто. Попробуйте период{' '} + + + )} + {period === '30d' && ( + <> + За последние 30 дней ничего не закрыто. Попробуйте период{' '} + + + )} + {period === '90d' && ( + За последние 90 дней ничего не закрыто. Возможно, команда только начинает работать. + )} +
+ )} + +
+ + {data && ( +
+
+ {data.productivity.closed} +
+
+ закрыто за {PERIOD_LABEL[period]} + {data.productivity.closedDelta !== null && ( + = 0 ? token.colorSuccess : token.colorError, + }} + > + + {data.productivity.closedDelta >= 0 ? 'рост на ' : 'падение на '} + + + {Math.abs(data.productivity.closedDelta)}% + + )} +
+
+ Средне {data.productivity.avgPerDay}/день · создано {data.productivity.created} +
+ +
+ )} +
+ + + {data && ( +
+
+ + {formatPercent(data.timeliness.onTimeRate)} + + в срок +
+
+ Среднее опоздание:{' '} + {data.timeliness.avgLatenessDays === null ? '—' : `${data.timeliness.avgLatenessDays} дн`} +
+
+ Просрочено сейчас: {data.timeliness.overdueNow} +
+
+ Cycle time: {formatHours(data.timeliness.avgCycleTimeHours)} +
+
+ )} +
+ + + {data && ( +
+
+ + {data.hygiene.hygieneScore} + + / 100 +
+
+ Открытых задач: {data.hygiene.totalOpen} +
+ {data.hygiene.totalOpen > 0 && ( +
    +
  • Без срока: {data.hygiene.noDueDate}
  • +
  • Без исполнителя: {data.hygiene.noAssignee}
  • +
  • Без описания: {data.hygiene.noDescription}
  • +
  • Без движения 14 дней+: {data.hygiene.stale14d}
  • +
+ )} +
+ )} +
+ + + {data && effectiveScope === 'personal' && ( +
+
s + x.count, 0)} задач в работе`} + > + {data.workload.wipByStatus.reduce((s, x) => s + x.count, 0)} +
+
+ задач у меня в работе +
+ +
+ )} + {data && effectiveScope === 'team' && ( + + )} +
+
+
+ ); +} diff --git a/specs/digest.feature b/specs/digest.feature new file mode 100644 index 0000000..a15698b --- /dev/null +++ b/specs/digest.feature @@ -0,0 +1,291 @@ +# Gherkin specs для Digest (страница метрик /w/:slug/digest) +# Связанные SDD: docs/design/digest.md (G2, awaiting approval) +# Покрытие: 6 категорий (happy / empty / loading / error / keyboard / edge-data) +# + RBAC (Viewer no team scope) + расчёт метрик + ACL фильтрация + +# ─── Feature 1: Owner смотрит командный дайджест ───────────────────────────── + +Feature: Owner просматривает командный дайджест workspace + Как owner я хочу видеть метрики продуктивности команды за выбранный период + + Background: + Given workspace "Команда А" со slug "team-a" + And я owner этого workspace + And в workspace 50 задач: 30 закрытых, 20 открытых + And из 30 закрытых: 25 закрыты в последние 7 дней, 18 — в срок, 7 — с опозданием + And в предыдущей неделе закрыто было 20 задач + + # Happy + Scenario: Заход на /w/team-a/digest показывает 4 блока метрик + When я перехожу на "/w/team-a/digest" + Then страница загружается + And видна карточка "Продуктивность": + | closed | 25 | + | closedDelta | +25% | + | avgPerDay | 3.6 | + | timeseries | sparkline 7 точек | + And видна карточка "Своевременность": + | onTimeRate | 72% | + | avgLatenessDays| 2.1 | + | overdueNow | 3 | + | avgCycleTimeHours | 18 | + And видна карточка "Гигиена" с hygieneScore 0..100 + And видна карточка "Распределение нагрузки" с WIP по людям и статусам + And по умолчанию scope=team, period=7d + + # Happy: переключение периода + Scenario: Переключение период 7d → 30d перезагружает данные + Given я на странице "/w/team-a/digest" в режиме personal 7d + When я кликаю radio "30д" + Then GET /api/workspaces//digest?scope=personal&period=30d вызывается + And пока ждём — карточки в skeleton-режиме (data-testid="digest-skeleton") + And после ответа карточки обновляются + And URL остаётся "/w/team-a/digest" (период в URL не персистится в MVP) + + # Happy: переключение scope с team на personal + Scenario: Owner смотрит свой личный разрез + Given я на странице "/w/team-a/digest" в режиме team 7d + When я кликаю radio "Личный" + Then GET /api/workspaces//digest?scope=personal&period=7d вызывается + And карточка "Распределение нагрузки" исчезает + And вместо неё показано "Моя WIP: N задач в работе" + And метрики пересчитаны только по моим назначенным задачам + +# ─── Feature 2: Member смотрит личный дайджест ─────────────────────────────── + +Feature: Member просматривает свой личный дайджест + Как member я хочу видеть как именно я работаю по своим задачам + + Background: + Given workspace "Команда А" со slug "team-a" + And я member этого workspace, на меня назначено 10 задач + And 6 из них закрыто в последние 30 дней + + # Happy + Scenario: Personal scope скрывает командные метрики + When я перехожу на "/w/team-a/digest" + Then по умолчанию scope=personal + And карточка "Распределение нагрузки" не содержит wipByAssignee + And показано "Моя WIP" вместо top-list людей + And все метрики посчитаны по WHERE assigneeId = меня + +# ─── Feature 3: Viewer не имеет доступа к team scope ───────────────────────── + +Feature: Viewer ограничен личным разрезом + Как viewer я могу смотреть только свои метрики, командные мне не положены + + Background: + Given workspace "Команда А" + And я viewer этого workspace + + # Happy: viewer видит свою страницу + Scenario: Viewer заходит на /digest — scope=personal по умолчанию + When я перехожу на "/w/team-a/digest" + Then scope=personal активен + And radio "Командный" disabled + And tooltip на disabled radio: "Доступно только Owner или Member" + + # Error: viewer пробует team scope через URL/API + Scenario: Viewer шлёт GET ?scope=team напрямую + When я делаю GET /api/workspaces//digest?scope=team&period=7d + Then ответ 403 + And error.code = "INSUFFICIENT_PERMISSION" + And error.message содержит "scope=team requires OWNER or MEMBER role" + + # Edge: viewer случайно нажал на disabled radio (mouse race) + Scenario: Если запрос с scope=team всё же ушёл — frontend graceful + Given я viewer на "/w/team-a/digest" + When backend возвращает 403 INSUFFICIENT_PERMISSION + Then toast "Командный разрез доступен только участникам с ролью Owner или Member" + And UI автопереключается на scope=personal + And данные перезагружаются с правильным scope + +# ─── Feature 4: Расчёт метрик корректен ────────────────────────────────────── + +Feature: Backend digest endpoint считает метрики правильно + Контрактные тесты бизнес-логики + + # Happy: closed + Scenario: Закрытие задачи считается по первому переходу в DONE + Given задача T1 переходила: OPEN → IN_PROGRESS (вчера 10:00) → DONE (вчера 14:00) + And задача T2 переходила: OPEN → DONE (3 дня назад) → IN_PROGRESS (2 дня назад) → DONE (1 день назад) + When я запрашиваю digest period=7d + Then closed = 2 + And T1 учтена по первому DONE (вчера 14:00) + And T2 учтена по первому DONE (3 дня назад) — даже если она была reopen потом + + # Happy: cycle time + Scenario: Cycle time = closedAt - firstInProgressAt + Given задача T3: createdAt вчера 09:00, IN_PROGRESS вчера 10:00, DONE вчера 14:00 + When я запрашиваю digest period=7d + Then avgCycleTimeHours для T3 = 4.0 (14:00 - 10:00) + And не 5.0 (НЕ от createdAt) + + # Edge: cycle time fallback для задач без IN_PROGRESS истории + Scenario: Старая задача закрылась минуя IN_PROGRESS + Given задача T4: createdAt 5 дней назад, DONE 1 день назад (нет IN_PROGRESS записи в TaskStatusHistory) + When я запрашиваю digest period=7d + Then T4 учитывается в closed + And её cycle time = (closedAt - createdAt) = 4 дня = 96 часов (fallback) + + # Happy: onTimeRate + Scenario: Считается только по закрытым с dueDate + Given 10 закрытых задач за период: 6 с dueDate (4 в срок, 2 просрочены), 4 без dueDate + When я запрашиваю digest period=30d + Then onTimeRate = 0.667 (4/6) + And задачи без dueDate в знаменатель не входят + + # Edge: onTimeRate когда нет задач с dueDate + Scenario: Нет задач с dueDate → onTimeRate=null + Given 5 закрытых задач за период, ни одна не имеет dueDate + When я запрашиваю digest period=7d + Then onTimeRate = null + And UI показывает "—" с tooltip "Нет задач со сроком" + + # Edge: hygiene при totalOpen=0 + Scenario: Новый workspace без открытых задач + Given workspace без задач (или все закрыты) + When я запрашиваю digest + Then hygieneScore = 100 + And totalOpen = 0 + And UI показывает "Ничего открытого" вместо счётчиков проблем + + # Edge: closedDelta когда closedPrev=0 + Scenario: Первый период с закрытыми задачами + Given в предыдущем окне 0 закрытых, в текущем 5 + When я запрашиваю digest + Then closedDelta = null (не +Infinity, не +500%) + And UI показывает "—" с tooltip "Нет данных за прошлый период" + + # Negative: ACL фильтр в team scope + Scenario: MEMBER в team scope видит только доступные ему доски + Given workspace с 3 досками: PUB (public), PRIV1 (private, я в BoardMember), PRIV2 (private, меня нет) + And в PUB 10 закрытых, в PRIV1 5 закрытых, в PRIV2 8 закрытых + When я (member) запрашиваю digest scope=team period=30d + Then closed = 15 (10 + 5, задачи из PRIV2 не учитываются) + And owner workspace в том же запросе получит closed = 23 + +# ─── Feature 5: UI states (loading/empty/error/edge-data) ──────────────────── + +Feature: Состояния страницы /digest + + Background: + Given я member workspace "Команда А" + + # Loading + Scenario: Skeleton при медленной сети + Given network latency = 800ms + When я перехожу на "/w/team-a/digest" + Then немедленно показаны 4 skeleton-карточки (data-testid="digest-skeleton") + And переключатели scope/period активны (не disabled) + And по resize кнопок не происходит layout shift (CLS < 0.1) + And после получения данных skeleton заменяется на реальные карточки + + # Empty — новый workspace + Scenario: Workspace без задач совсем + Given в workspace 0 задач + When я перехожу на "/w/team-a/digest" + Then показана иллюстрация и текст "Здесь будут метрики, когда команда начнёт работать с задачами" + And CTA "Создать первую задачу" ведёт на /w/team-a + And карточки метрик не отображаются + + # Empty — есть задачи, но в периоде ничего + Scenario: Период пустой, но workspace не пустой + Given в workspace 50 задач, но за последние 7 дней не было ни создания ни закрытия + When я перехожу на "/w/team-a/digest" (period=7d default) + Then карточки показывают "0" в основных метриках + And inline hint "За последние 7 дней ничего не закрыто. Попробуйте период 30 дней" + And ссылка в hint переключает на 30d + + # Error + Scenario: Backend 500 на запрос digest + Given GET /api/workspaces//digest возвращает 500 + When я перехожу на "/w/team-a/digest" + Then каждая карточка показывает error-state inline: + | "Не удалось загрузить" | кнопка Retry | + And переключатели scope/period остаются активны + And нажатие Retry повторяет запрос + + # Error: network timeout + Scenario: Запрос завис на >10s + Given network таймаут 10s + When я открываю /digest и сеть подвисает + Then через 10s показан error-state с сообщением "Запрос занял слишком много времени" + And предлагается Retry + + # Edge data: длинные имена в WIP-баре + Scenario: Имя пользователя 80 символов + Given в WIP top-10 есть пользователь с именем "Александр-Кристофер фон Лонг-Хайфен Розенберг Молли младший третий" + When я открываю digest scope=team + Then имя truncated с ellipsis после ~30 символов + And полное имя в tooltip при hover/focus + And aria-label содержит полное имя + + # Edge data: WIP=0 у всех (никто ничего не делает) + Scenario: Никто не работает прямо сейчас + Given в workspace все задачи в OPEN или DONE, ни одной в IN_PROGRESS + When я открываю digest scope=team + Then в карточке "Распределение нагрузки" показано "В работе ничего нет" вместо bar chart + And это не считается ошибкой + + # Edge data: 90d → агрегация по неделям + Scenario: Period=90d рендерит sparkline по неделям + Given period=90d + When данные приходят с timeseries.length = 13 (а не 90) + Then sparkline показывает 13 точек + And tooltip каждой точки: "Неделя N: created X, closed Y" + And бейдж "По неделям" виден в углу sparkline + +# ─── Feature 6: Клавиатурная навигация и a11y ──────────────────────────────── + +Feature: Доступность и клавиатура на /digest + + Background: + Given я owner workspace "Команда А" + And я на странице "/w/team-a/digest" + + # Keyboard happy path + Scenario: Полностью прохожу страницу клавиатурой + When я нажимаю Tab последовательно + Then focus идёт: scope-radio → period-radio → каждая карточка (h2) → footer + And focus visible на всех остановках (outline 2px) + And ни одна остановка не пропускается + + # Keyboard: переключение radio стрелками + Scenario: Radio-группы управляются стрелками + Given focus на radio "Личный" + When я нажимаю Right Arrow + Then focus и выбор переходят на radio "Командный" + And запрос digest scope=team отправляется + And aria-live polite объявляет "Загружаю командные метрики" + + # Keyboard: shortcut R для refresh + Scenario: Клавиша R обновляет данные + Given фокус не в input + When я нажимаю R + Then GET /api/workspaces//digest повторяется с теми же scope/period + And aria-live "Данные обновлены" + + # Screen reader: sparkline имеет текстовую альтернативу + Scenario: SVG-график доступен SR + When SR (VoiceOver / NVDA) фокусируется на sparkline + Then читается aria-label "График: создано 30, закрыто 42 за последние 7 дней" + And рядом есть visually-hidden с детализацией по дням: + | Дата | Создано | Закрыто | + | 2026-05-14 | 5 | 6 | + | 2026-05-15 | 4 | 7 | + | ... | ... | ... | + + # Screen reader: смена scope/period объявляется + Scenario: Aria-live при обновлении данных + Given я на странице + When я переключаю period с 7d на 30d + Then aria-live="polite" объявляет "Данные обновлены за период 30 дней" + + # Touch: tap-цели на мобилке + Scenario: На мобилке radio ≥ 44×44 + Given viewport 360×640 + When я открываю /digest + Then каждая radio-кнопка имеет hit-area ≥ 44×44 + And кнопка Refresh ≥ 44×44 + And нет горизонтального скролла diff --git a/tasks/digest/plan.md b/tasks/digest/plan.md new file mode 100644 index 0000000..a2b64b2 --- /dev/null +++ b/tasks/digest/plan.md @@ -0,0 +1,328 @@ +# Plan: Digest — workspace metrics page (/w/:slug/digest) + +> Owner: jackrescuer-gif (NovakPAai) +> Parent SDD: `docs/design/digest.md` (G2 approved) +> Parent BDD: `specs/digest.feature` (G3 approved) +> Size: L (~14 ideal hours, single PR) +> Deferred issues: #218-#228 (см. SDD §11 и §1) + +--- + +## 1. Цель и success criteria + +Доставить пользователю одну страницу `/w/:slug/digest` с 4 секциями метрик (продуктивность, своевременность, гигиена, нагрузка) за пресеты `7d|30d|90d`, в разрезах `personal|team`. Без новых таблиц в Prisma — всё считается на лету через `TaskStatusHistory`. RBAC: VIEWER — только `personal`. Это MVP-итерация; кэш, CSV, email-дайджест, custom range — отложены. + +**Success criteria**: + +- [ ] `GET /api/workspaces/:id/digest?scope&period` отвечает DTO по SDD §6.2 +- [ ] VIEWER получает 403 `INSUFFICIENT_PERMISSION` на `scope=team`; UI автопереключается на `personal` +- [ ] MEMBER в `scope=team` видит только задачи доступных ему boards (R6 mitigation, тест) +- [ ] Cycle time по `TaskStatusHistory` с fallback на `createdAt` (R2) +- [ ] `closedDelta=null` при `closedPrev=0`; `onTimeRate=null` при 0 задач с dueDate; `hygieneScore=100` при `totalOpen=0` +- [ ] Sparkline для `90d` агрегирует по неделям (13 точек) +- [ ] Все 6 BDD-категорий покрыты тестами (happy/empty/loading/error/keyboard/edge-data) +- [ ] WCAG AA: focus visible, aria-live на смене scope/period, sparkline с табличным fallback, touch targets ≥44×44 +- [ ] LCP < 2.5s на 50k seed; backend endpoint p95 < 800ms +- [ ] `tsc --noEmit + lint + check:rbac + tests` зелёные; coverage ≥ 80% по модулю digest +- [ ] OpenAPI зарегистрирован, Swagger UI показывает endpoint + +--- + +## 2. Step-by-step план + +### Phase A — Backend: contracts and service + +#### Step 1. DTO + Zod schemas +**File**: `backend/src/modules/digest/digest.dto.ts` (new) +**Что**: `digestQueryDto` (`scope`, `period` enum), `DigestDto` (TS type из SDD §6.2). Экспорт типов `DigestPeriod`, `DigestScope`, `DigestDto`, `WipByAssigneeDto`, `WipByStatusDto`. +**Acceptance**: `z.infer` совпадает с типом из SDD §6.2; `tsc --noEmit` зелёный. +**Effort**: S + +#### Step 2. Helpers — period parsing + date math +**File**: `backend/src/modules/digest/digest.dates.ts` (new) +**Что**: Чистые функции `parsePeriod`, `computeWindow(now, days) → { from, to, fromPrev }`, `groupByWeek` для 90d. Всё в UTC (R8). +**Acceptance**: unit-тесты `digest.dates.test.ts` — 6+ boundary cases (DST, end of month, leap year). +**Effort**: S + +#### Step 3. ACL helper — accessible boards в workspace +**File**: `backend/src/shared/utils/task-access.ts` (extend) или новый `board-access.ts` +**Что**: `accessibleBoardIdsInWorkspace(userId, workspaceId): Promise`. Owner → все; иначе — public + private где user в BoardMember. Используется в team scope (R6). Сначала grep — нет ли уже похожего. +**Acceptance**: unit-тест — 4 кейса (owner / member-public / member-private-yes / member-private-no). +**Effort**: M + +#### Step 4. Service — агрегирующая функция +**File**: `backend/src/modules/digest/digest.service.ts` (new) +**Что**: `getDigest({ workspaceId, userId, scope, period })` по алгоритму SDD §5: +- `assertMember(workspaceId, userId)` +- Для `scope=team`: роль `OWNER|MEMBER` (иначе `INSUFFICIENT_PERMISSION`) + `accessibleBoardIdsInWorkspace` +- 4 SELECT-а (closed-window, closed-prev-window, firstInProgress, openTasks) + 1 для `wipByStatus` +- Pure helpers: `buildProductivity`, `buildTimeliness`, `buildHygiene`, `buildWorkload` — вынести для тестируемости +- Cycle time fallback на `createdAt` (R2) +- `closedDelta=null` если `closedPrev=0`; `onTimeRate=null` если знаменатель 0 +**Acceptance**: integration-тесты (Step 18) проходят; нет `any`; функция < 50 строк. +**Effort**: L + +#### Step 5. Router +**File**: `backend/src/modules/digest/digest.router.ts` (new) +**Что**: Один маршрут `GET /` (mounted под `/api/workspaces/:wid/digest`). `Router({ mergeParams: true })`, `authenticate`, `validate(digestQueryDto, 'query')`. Дефолты: `scope='personal'`, `period='7d'`. +**Acceptance**: ручной curl возвращает 200/401/403/404. +**Effort**: S + +#### Step 6. Mount router в app.ts +**File**: `backend/src/app.ts` (edit) +**Что**: `app.use('/api/workspaces/:wid/digest', digestRouter)` — после `workspaceBoardsRouter`. +**Acceptance**: `npm run dev` стартует без ошибок. +**Effort**: S + +#### Step 7. OpenAPI registration +**File**: `backend/src/shared/openapi/routes/digest.ts` (new) + import в `index.ts` +**Что**: `registry.registerPath` для `GET /workspaces/{id}/digest` — query, 200, 401/403/404/400. +**Acceptance**: Swagger UI `/api/docs` показывает endpoint. +**Effort**: S + +#### Step 8. Error mapping +**File**: `backend/src/modules/digest/digest.service.ts` (edit) +**Что**: `AppError` с кодами строго из SDD §6.1: `NOT_WORKSPACE_MEMBER`, `INSUFFICIENT_PERMISSION`, `WORKSPACE_NOT_FOUND`, `INVALID_PERIOD`. +**Acceptance**: Integration-тест проверяет form `{ error: { code, message } }`. +**Effort**: S + +### Phase B — Frontend: page and components + +> Можно начинать параллельно с Phase A после Step 1. + +#### Step 9. API client +**File**: `frontend/src/api/digest.ts` (new) +**Что**: `getDigest({ workspaceId, scope, period, signal }): Promise`. Тип скопировать из SDD (на MVP). +**Effort**: S + +#### Step 10. Custom hook +**File**: `frontend/src/hooks/useDigest.ts` (new) +**Что**: `useDigest(workspaceId, scope, period)` → `{ data, loading, error, refetch }`. AbortController на смене аргументов. +**Acceptance**: unit-test — 4 кейса (success, error, abort on rerun, refetch). +**Effort**: M + +#### Step 11. DigestCard wrapper +**File**: `frontend/src/components/digest/DigestCard.tsx` (new) +**Что**: Props `{ title, loading, error, onRetry, children }`. AntD Card. Skeleton фикс. высоты для CLS<0.1. `role="region" aria-label={title}`. +**Effort**: S + +#### Step 12. DigestSparkline +**File**: `frontend/src/components/digest/DigestSparkline.tsx` (new) +**Что**: SVG-спарклайн created/closed. Inline SVG (если recharts нет в deps). `aria-label` + visually-hidden `
` для SR. Бейдж "По неделям" при 90d. +**Acceptance**: BDD scenario "SVG-график доступен SR" зелёный. +**Effort**: M + +#### Step 13. WorkloadChart +**File**: `frontend/src/components/digest/WorkloadChart.tsx` (new) +**Что**: Два horizontal bar-chart-а. Top-10 desktop, top-5 mobile. Truncate имени + tooltip + полный текст в aria-label. Empty: "В работе ничего нет". +**Effort**: M + +#### Step 14. DigestPage +**File**: `frontend/src/pages/DigestPage.tsx` (new) +**Что**: Header scope/period radio-groups, 4 секции в CSS Grid. `useDigest`. На `scope=personal` — `WorkloadChart` заменяется на скаляр "Моя WIP: N". На 403 → toast + auto-switch на personal. Shortcut R — refresh. `aria-live="polite"`. +**Acceptance**: BDD F1, F2 happy сценарии зелёные. +**Effort**: L + +#### Step 15. Роут + меню +**Files**: +- `frontend/src/App.tsx` — `} />` после `/roadmaps` +- `frontend/src/components/AppLayout.tsx` — tab "Дайджест" рядом с Roadmaps +**Effort**: S + +#### Step 16. Slug → id resolution +**File**: `frontend/src/pages/DigestPage.tsx` (edit) +**Что**: Скопировать паттерн из `BoardPage` (`useWorkspaceBySlug` или Zustand workspace.store). +**Effort**: S + +### Phase C — Tests + +#### Step 17. Backend unit-tests — расчётные helpers +**File**: `backend/src/modules/digest/__tests__/digest.compute.test.ts` (new) +**Что**: 12+ кейсов для `buildProductivity / Timeliness / Hygiene / Workload`. `closedDelta=null`, `onTimeRate=null`, hygiene boundary, cycle time fallback. Маппинг BDD F4. +**Acceptance**: ≥90% coverage этих helpers. +**Effort**: M + +#### Step 18. Backend integration-tests — endpoint +**File**: `backend/src/__tests__/digest.test.ts` (new, по образцу `workspace-features.test.ts`) +**Что**: +- 401 без токена; 403 не-член; 403 viewer team; 404 несуществующий ws; 400 невалидный period +- Owner team 7d — корректные cumulative цифры +- Member personal — фильтр по `assigneeId=me` +- **ACL: member team — задачи из private board без membership НЕ учтены (R6 mitigation)** +- Edge: cycle time fallback; reopen — distinct по taskId; closedPrev=0 → delta=null +- 90d → `timeseries.length === 13` +**Acceptance**: ≥15 it-cases; coverage модуля digest ≥80%. +**Effort**: L + +#### Step 19. Backend perf-smoke (optional, `it.skip`) +**File**: `backend/src/__tests__/digest.perf.test.ts` (new) +**Что**: 50k задач + 200k history → digest 30d p95 < 800ms. +**Effort**: M + +#### Step 20. Frontend component-tests +**Files**: `frontend/src/components/digest/__tests__/*.test.tsx` +**Что**: vitest + RTL. Loading skeleton, Retry клик, sparkline без данных, WorkloadChart truncate. +**Acceptance**: ≥8 кейсов суммарно. +**Effort**: M + +#### Step 21. E2E (Playwright) +**File**: `frontend/e2e/digest.spec.ts` (new) +**Что**: 3 сценария: +1. Owner happy: workspace → board → 5 задач → /digest → 4 карточки и числа +2. Viewer 403: radio "Командный" disabled + tooltip; force API через `page.evaluate` → toast + auto-switch +3. Keyboard: Tab по interactive + outline visible + R → refetch +**Effort**: L + +### Phase D — Polish + Rollout + +#### Step 22. Feature-smoke skill (BEFORE G7) +**Что**: `~/.claude/skills/feature-smoke/SKILL.md` — curl happy/401/403/404/400 + UI handoff на `e2e-runner`. Verdict = `green` обязателен. +**Acceptance**: `tasks/digest/smoke-report.md` с `verdict: green`. +**Effort**: S + +#### Step 23. Pre-commit verify +**Commands**: `cd backend && npm run typecheck && npm run lint && npm run test`; `cd frontend && npm run typecheck && npm run lint && npm test`. +**Effort**: S + +#### Step 24. Code review sequence (G7) +**Что**: Три ревью-агента последовательно: +1. **code-reviewer** — с `vercel-react-best-practices` + `vercel-composition-patterns` +2. **security-reviewer** — focus: R5 (personal scope без per-board ACL), R6 (team scope ACL фильтр), IDOR +3. **UX/UI-reviewer** — с `vercel-web-design-guidelines` + `ux-designer`: 7 UI states из SDD §7.2 +**Acceptance**: все severity (включая LOW) либо зафикшены, либо `deferred_to: ` в commit message. +**Effort**: M + +#### Step 25. PR + merge +**Commands**: `make ship` → CI зелёный + аппрув St1tcher86 → `make merge`. +**Effort**: S + +--- + +## 3. Phase breakdown (parallelization) + +| Phase | Steps | Parallel? | Заметки | +|-------|-------|-----------|---------| +| **A. Backend** | 1-8 | Step 1 → (2,3 parallel) → 4 → (5-8 parallel) | Step 1 — критический путь | +| **B. Frontend** | 9-16 | После Step 1 (DTO зафиксирован). Steps 11-13 parallel. Step 14 после 9,10,11,12,13 | Можно с моком | +| **C. Tests** | 17-21 | Step 17 ∥ Step 4. Step 18 после Step 6. Step 20 ∥ frontend. Step 21 — последним. | TDD: Step 17 ДО Step 4 | +| **D. Polish** | 22-25 | Sequential | Smoke → verify → 3 review → ship | + +**Критический путь**: 1 → 4 → 6 → 14 → 18 → 21 → 22 → 24 → 25. + +**Один PR** — фича атомарна, без миграций. + +--- + +## 4. Risks specific to implementation + +| # | Risk | Probability | Mitigation | addressed_in | +|---|------|-------------|------------|--------------| +| I1 | `accessibleBoardIdsInWorkspace` дублирует логику из `boards.service.ts` | High | Step 3 — сначала grep существующего helper; если есть — переиспользовать. | `backend/src/shared/utils/task-access.ts` (Step 3) + unit-test | +| I2 | N+1 при include `assignee` в `wipByAssignee` (R10 SDD) | Medium | Один `groupBy` + один `findMany` для имён, объединить в Map. Тест: `prisma.$on('query')` считает SELECT-ы ≤6. | Step 4 + Step 18 (query-count assertion) | +| I3 | `TaskStatusHistory.distinct(['taskId'])` может вернуть случайную запись | Medium | `orderBy: { startedAt: 'asc' }` + `distinct`. Перепроверить Prisma 6 docs. Альтернатива — raw `DISTINCT ON`. | Step 4 + Step 18 (reopen-сценарий BDD F4) | +| I4 | Slug → id mapping требует доп. запрос | Low | Паттерн из `BoardPage` (Step 16). | Step 16 | +| I5 | AntD Card в loading даёт CLS > 0.1 | Medium | Skeleton фикс. высоты `style={{ minHeight: 240 }}`. Визуальный smoke в Playwright. | Step 11 + Step 21 | +| I6 | Recharts может отсутствовать → новая зависимость | Low | `grep recharts package.json`. Если нет — inline SVG. Не добавляем lib без согласования. | Step 12 (check first), accepted_because: SDD §8 предусматривает inline SVG | +| I7 | `R` shortcut конфликтует с Cmd+R browser | Low | Shortcut активен только если фокус НЕ в input/textarea. | Step 14 + Step 21 keyboard E2E | +| I8 | Performance > 800ms на 50k seed (R1 SDD) | Medium | Step 19 perf-smoke. Если красный — индексы. Если всё равно — deferred_to новый issue (Phase 2 cache). | Step 19; accepted_because: Phase 2 cache (SDD §11) | +| I9 | Timezone bug UTC vs локаль (R8) | Low | Все границы UTC (Step 2). `vi.useFakeTimers` для тестов. | Step 2 + Step 17 | +| I10 | Cancelled попадают в `closed` | Medium | Фильтр `status.category = 'DONE'` (НЕ `IN [DONE, CANCELLED]`). | Step 4 + Step 18 (явный кейс) | + +--- + +## 5. Test strategy — маппинг BDD → tests + +| BDD Feature / Scenario | Test type | File | Step | +|---|---|---|---| +| F1 Owner team digest (happy) | E2E | `digest.spec.ts` #1 | 21 | +| F1 Переключение период 7d→30d | Component | `useDigest.test.tsx` | 10 | +| F1 Переключение scope team→personal | Component | `DigestPage.test.tsx` | 14+20 | +| F2 Member personal scope | Integration | `digest.test.ts` | 18 | +| F3 Viewer disabled radio | E2E | `digest.spec.ts` #2 | 21 | +| F3 Viewer scope=team → 403 | Integration | `digest.test.ts` | 18 | +| F3 Viewer auto-switch on 403 | E2E | `digest.spec.ts` #2 | 21 | +| F4 Closed first DONE | Integration | `digest.test.ts` | 18 | +| F4 Cycle time = closedAt - inProgressAt | Unit + Integration | `digest.compute.test.ts` + `digest.test.ts` | 17+18 | +| F4 Cycle time fallback createdAt | Integration | `digest.test.ts` | 18 | +| F4 onTimeRate с/без dueDate | Unit | `digest.compute.test.ts` | 17 | +| F4 hygieneScore при totalOpen=0 | Unit | `digest.compute.test.ts` | 17 | +| F4 closedDelta=null если closedPrev=0 | Unit | `digest.compute.test.ts` | 17 | +| **F4 ACL фильтр team scope (R6)** | Integration | `digest.test.ts` | 18 | +| F5 Skeleton при медленной сети | Component | `DigestCard.test.tsx` | 20 | +| F5 Empty (0 задач) | Component | `DigestPage.test.tsx` | 20 | +| F5 Empty (период пустой) | Component | `DigestPage.test.tsx` | 20 | +| F5 Backend 500 → error inline | Component | `useDigest.test.tsx` + `DigestCard.test.tsx` | 10+20 | +| F5 Длинное имя в WIP | Component | `WorkloadChart.test.tsx` | 20 | +| F5 WIP=0 у всех | Component | `WorkloadChart.test.tsx` | 20 | +| F5 90d → timeseries.length=13 | Unit + Integration | `digest.dates.test.ts` + `digest.test.ts` | 2+18 | +| F6 Tab-навигация | E2E | `digest.spec.ts` #3 | 21 | +| F6 Arrow-keys radio | E2E | `digest.spec.ts` #3 | 21 | +| F6 Shortcut R | E2E | `digest.spec.ts` #3 | 21 | +| F6 SR sparkline alt | Component (a11y snapshot) | `DigestSparkline.test.tsx` | 20 | +| F6 Aria-live при обновлении | Component | `DigestPage.test.tsx` | 14+20 | +| F6 Touch targets ≥44 | E2E (CSS check) | `digest.spec.ts` | 21 | + +**Coverage targets**: +- Backend `digest`: ≥80% +- Frontend `digest/`: ≥80% +- E2E: 3 ключевых сценария + +--- + +## 6. Rollout + +### PR-стратегия +**Один PR**, ветка `claude/novak-digest`. Фича атомарна (без миграций, изменения только в `app.ts` + `AppLayout.tsx`). Если PR станет >40 файлов — разделить на `backend → frontend`. + +### Feature flag +**Не нужен**. Endpoint новый, UI новый tab. Revert одним коммитом если сломалось. Если потребует G6 — добавить через `WS_DIGEST` toggle (паттерн из #193). + +### Seed data +Дополнить `backend/prisma/seed.ts`: +- 30+ задач с разными status/createdAt/dueDate/assignee +- `TaskStatusHistory` для каждой (2-3 перехода) — критично для cycle time +- Даты в `[-30d, today]` для демо +**Effort**: M (~1ч). + +### Деплой +1. `make ship` → CI зелёный → аппрув St1tcher86 → `make merge` +2. Прод деплой автоматический +3. Sanity: `/w/.../digest` рендерится без ошибок в console +4. Revert: `git revert ` (фича изолирована) + +--- + +## 7. Out of scope + +| Не делаем | Issue | Причина | +|-----------|-------|---------| +| Redis cache TTL=5min + invalidation | Phase 2 | MVP — синхронный SQL | +| Кастомный диапазон дат | [#222](https://github.com/NovakPAai/flow-tasks/issues/222) | MVP — пресеты | +| CSV экспорт | [#220](https://github.com/NovakPAai/flow-tasks/issues/220) | Не приоритет | +| Email/Telegram digest | [#221](https://github.com/NovakPAai/flow-tasks/issues/221) | Phase 5 | +| Time-in-status bottleneck | [#219](https://github.com/NovakPAai/flow-tasks/issues/219) | Phase 6 | +| Cross-workspace personal digest на `/my-tasks` | [#218](https://github.com/NovakPAai/flow-tasks/issues/218) | Phase 7 | +| Hygiene weights настройка | [#224](https://github.com/NovakPAai/flow-tasks/issues/224) | MVP — простой average | +| Stale threshold настройка | [#225](https://github.com/NovakPAai/flow-tasks/issues/225) | MVP — фикс. 14 дней | +| Description min-length настройка | [#226](https://github.com/NovakPAai/flow-tasks/issues/226) | MVP — фикс. 10 символов | +| Approximate cycle time badge | [#227](https://github.com/NovakPAai/flow-tasks/issues/227) | MVP — тихий fallback + tooltip | +| Timezone preferences | [#228](https://github.com/NovakPAai/flow-tasks/issues/228) | MVP — UTC + локаль браузера | +| Team scope ACL UX banner | [#223](https://github.com/NovakPAai/flow-tasks/issues/223) | MVP — тихая фильтрация | +| Audit log "digest viewed" | — | Read-only, не критично | +| Per-user dashboard customization | — | Out of SDD | +| Storybook stories | — | Нет инфраструктуры | +| Visual regression тесты | — | Нет инфраструктуры | + +--- + +## Self-check перед G4 (plan approval) + +- [x] Все шаги имеют точные file paths +- [x] Каждый шаг имеет acceptance criterion +- [x] Каждый риск имеет mitigation + `addressed_in` / `deferred_to` / `accepted_because` +- [x] Все BDD scenarios (F1-F6) замаппены на тесты +- [x] Out-of-scope явно перечислен со ссылками на issues #218-228 +- [x] Phase parallelization указана +- [x] Нет изменений в Prisma schema (SDD §4) +- [x] Endpoint один (SDD §6.1) +- [x] RBAC mapping корректный