Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions backend/src/__tests__/digest.test.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
238 changes: 238 additions & 0 deletions backend/src/modules/digest/__tests__/digest.compute.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
117 changes: 117 additions & 0 deletions backend/src/modules/digest/__tests__/digest.dates.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading