Merge pull request #48 from Gimanh/feat/analytics

feat: analytics
This commit is contained in:
Nikolai Giman
2026-05-09 23:42:28 +02:00
committed by GitHub
75 changed files with 6561 additions and 30 deletions
+15 -3
View File
@@ -5,6 +5,11 @@
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
[![License](https://img.shields.io/badge/license-Source--Available-blue)](./LICENSE)
[![Active Development](https://img.shields.io/badge/status-active-brightgreen)]()
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**iOS**](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867) · [**Android**](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
## Apps
* [Docs](https://taskview.tech/docs/)
* [Web](https://app.taskview.tech/)
@@ -134,11 +139,18 @@ Make sure the image versions match the version defined in the root package.json.
## Roadmap
- Plugin / extension system
- [X] Migrate to NuxtUI or similar ui library
- Enterprise SSO and identity integrations
- [X] Enterprise SSO and identity integrations
- [X] Redesign
- Desktop version
- [X] API tokens
- [X] Webhooks
- [X] MCP server
- [X] Notifications
- [X] Analytics
- [X] API client
- [ ] Desktop version
- [ ] Plugin / extension system
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
+1
View File
@@ -71,6 +71,7 @@
"pino": "^9.4.0",
"rotating-file-stream": "^3.2.5",
"semver": "^7.6.3",
"taskview-api": "workspace:^",
"taskview-db-schemas": "workspace:^",
"terser": "^5.36.0",
"ua-parser-js": "^2.0.9",
+3
View File
@@ -11,6 +11,7 @@ import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsMana
import { NotificationsManager } from '../tv-modules/notifications/NotificationsManager';
import { OrganizationManager } from '../tv-modules/organizations/OrganizationManager';
import { SsoManager } from '../tv-modules/sso/SsoManager';
import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
import { TasksManager } from '../tv-modules/tasks/TasksManager';
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
@@ -37,6 +38,7 @@ export class AppUser {
public readonly notificationsManager: NotificationsManager;
public readonly organizationManager: OrganizationManager;
public readonly ssoManager: SsoManager;
public readonly analyticsManager: AnalyticsManager;
constructor(userData?: UserJwtPayload) {
this.userData = userData;
@@ -55,6 +57,7 @@ export class AppUser {
this.notificationsManager = new NotificationsManager(this);
this.organizationManager = new OrganizationManager(this);
this.ssoManager = new SsoManager(this);
this.analyticsManager = new AnalyticsManager(this);
}
getTokenId(): number | undefined {
+2 -2
View File
@@ -35,12 +35,12 @@ export class GoalPermissionsRepository {
if (goalInfo.rows[0].owner === user.getUserData()?.id) {
query = `select name as "permissionName", id as "permissionId" from tv_auth.permissions;`;
} else {
query = `select p.name as "permissionName", p.id as "permissionId"
query = `select p.name as "permissionName", p.id as "permissionId"
from collaboration.users cu
left join collaboration.users_to_goals utg on cu.id = utg.user_id
left join tasks.goals tg on utg.goal_id = tg.id
left join collaboration.users_to_roles utr on utr.user_id = cu.id
left join collaboration.roles rol on utr.role_id = rol.id
left join collaboration.roles rol on utr.role_id = rol.id and rol.goal_id = tg.id
left join collaboration.permissions_to_role ptr on rol.id = ptr.role_id
left join tv_auth.permissions p on ptr.permission_id = p.id
where email = $1 and tg.id = $2 and p.name is not null and p.id is not null;`;
+35
View File
@@ -472,5 +472,40 @@
"description": [
"Create personal organizations for users without any organization membership"
]
},
"38": {
"version": "1.45.0",
"name": "Release 1.45.0",
"releaseDate": "20260425",
"scripts": [
"/1.45.0/0.analytics_indexes.sql"
],
"description": [
"Add composite indexes on tasks.tasks for analytics queries"
]
},
"39": {
"version": "1.46.0",
"name": "Release 1.46.0",
"releaseDate": "20260426",
"scripts": [
"/1.46.0/0.add-analytics-permission.sql"
],
"description": [
"Add analytics_can_view permission for project-level analytics access"
]
},
"40": {
"version": "1.47.0",
"name": "Release 1.47.0",
"releaseDate": "20260509",
"scripts": [
"/1.47.0/0.analytics_indexes_v2.sql"
],
"description": [
"Add indexes on tasks_auth.task_assignee, collaboration.users_to_goals, tasks.task_relations(to_task_id) for analytics joins",
"Add partial index on tasks.goals(organization_id) WHERE archive = 0 for analytics goal lookup",
"Add partial index on tasks.tasks(goal_id) WHERE complete IS NOT TRUE for open-task analytics queries"
]
}
}
@@ -134,3 +134,10 @@ values ('task_can_assign_users',
('task_can_watch_assigned_users',
'User can see assigned users to tasks',
4);
insert into tv_auth.permissions (name, description, permission_group, description_locales)
values ('analytics_can_view',
'User can view analytics for this goal',
2,
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb)
on conflict (name) do nothing;
@@ -0,0 +1,14 @@
drop index if exists tasks.idx_tasks_goal_id_date_creation;
create index idx_tasks_goal_id_date_creation on tasks.tasks (goal_id, date_creation);
drop index if exists tasks.idx_tasks_goal_id_date_complete;
create index idx_tasks_goal_id_date_complete on tasks.tasks (goal_id, date_complete) where complete = true;
drop index if exists tasks.idx_tasks_goal_id_end_date_open;
create index idx_tasks_goal_id_end_date_open on tasks.tasks (goal_id, end_date) where complete is not true;
drop index if exists tasks.idx_tasks_goal_id_edit_date_open;
create index idx_tasks_goal_id_edit_date_open on tasks.tasks (goal_id, edit_date) where complete is not true;
drop index if exists tasks.idx_tasks_goal_id_transaction_type;
create index idx_tasks_goal_id_transaction_type on tasks.tasks (goal_id, transaction_type) where amount is not null;
@@ -0,0 +1,8 @@
insert into tv_auth.permissions (name, description, permission_group, description_locales)
values (
'analytics_can_view',
'User can view analytics for this goal',
2,
'{"en": "View analytics. User can view analytics dashboards and KPIs for this project.", "ru": "Просмотр аналитики. Пользователь может просматривать дашборды и KPI этого проекта."}'::jsonb
)
on conflict (name) do nothing;
@@ -0,0 +1,20 @@
drop index if exists tasks_auth.idx_task_assignee_task_id;
create index idx_task_assignee_task_id on tasks_auth.task_assignee (task_id);
drop index if exists tasks_auth.idx_task_assignee_user_task;
create index idx_task_assignee_user_task on tasks_auth.task_assignee (collab_user_id, task_id);
drop index if exists collaboration.idx_users_to_goals_user_goal;
create index idx_users_to_goals_user_goal on collaboration.users_to_goals (user_id, goal_id);
drop index if exists collaboration.idx_users_to_goals_goal_id;
create index idx_users_to_goals_goal_id on collaboration.users_to_goals (goal_id);
drop index if exists tasks.idx_task_relations_to_task;
create index idx_task_relations_to_task on tasks.task_relations (to_task_id, from_task_id);
drop index if exists tasks.idx_goals_org_active;
create index idx_goals_org_active on tasks.goals (organization_id) where archive = 0;
drop index if exists tasks.idx_tasks_goal_id_open;
create index idx_tasks_goal_id_open on tasks.tasks (goal_id) where complete is not true;
+12 -1
View File
@@ -8,13 +8,24 @@ export class Database {
public dbDrizzle: ReturnType<typeof drizzle>;
private constructor() {
const poolMaxRaw = Number(process.env.DB_POOL_MAX);
const poolMax = Number.isFinite(poolMaxRaw) && poolMaxRaw > 0 ? poolMaxRaw : 20;
this.pool = new Pool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: +process.env.DB_PORT!,
max: poolMax,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
this.pool.on('connect', (client) => {
client.query("SET TIME ZONE 'UTC'").catch((err) => {
console.error('Failed to set session timezone to UTC:', err);
});
});
this.dbDrizzle = drizzle({ client: this.pool, casing: 'camelCase' });
@@ -48,7 +59,7 @@ export class Database {
try {
const res = await client.query(text, params);
const duration = Date.now() - start;
$logger.info('executed query', { text, duration, rows: res.rowCount });
$logger.info({ text, duration, rows: res.rowCount }, 'executed query');
// console.log('executed query', { text, duration, rows: res.rowCount });
return res;
} catch (err) {
+2
View File
@@ -16,6 +16,7 @@ import TasksRoutes from '../tv-modules/tasks/TasksRoutes';
import OrganizationRoutes from '../tv-modules/organizations/OrganizationRoutes';
import SsoRoutes from '../tv-modules/sso/SsoRoutes';
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -38,6 +39,7 @@ const routes: Record<string, RoutableConstructor> = {
'/module/sessions': SessionsRoutes,
'/module/organizations': OrganizationRoutes,
'/module/sso': SsoRoutes,
'/module/analytics': AnalyticsRoutes,
'/scim/v2': ScimRoutes,
};
@@ -0,0 +1,106 @@
import { type } from 'arktype'
import type { NextFunction, Request, Response } from 'express'
import type { AnalyticsScope } from 'taskview-api'
import { $logger } from '../../modules/logget'
import { parseDrillDownMeta, resolveRange } from './helpers'
import { AnalyticsDrillDownArkType, AnalyticsFetchSectionsArkType } from './types'
export class AnalyticsController {
fetchSections = async (req: Request, res: Response, next: NextFunction) => {
const out = AnalyticsFetchSectionsArkType(req.query)
if (out instanceof type.errors) {
$logger.warn(`analytics: validation failed: ${out.summary}`)
return res.status(400).send(out.summary)
}
let scope: AnalyticsScope
if (out.scope === 'project') {
if (out.goalId === undefined) {
return res.status(400).send('goalId is required for project scope')
}
scope = { kind: 'project', goalId: out.goalId }
} else {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
if (!range) return res.status(400).send('invalid range')
const sectionIds = out.sections
? out.sections.split(',').map(s => s.trim()).filter(Boolean)
: undefined
try {
const data = await req.appUser.analyticsManager.buildSections({
scope,
organizationId: out.organizationId,
period: out.period,
range,
sectionIds,
})
return res.tvJson(data)
} catch (err) {
$logger.error({
err,
userId: req.appUser.getUserData()?.id,
organizationId: out.organizationId,
scope,
period: out.period,
}, 'Analytics fetchSections failed')
return next(err)
}
}
fetchDrillDown = async (req: Request, res: Response, next: NextFunction) => {
const sectionId = req.params.sectionId
if (!sectionId) return res.status(400).send('missing sectionId')
const out = AnalyticsDrillDownArkType(req.query)
if (out instanceof type.errors) {
$logger.warn(`analytics drill-down: validation failed: ${out.summary}`)
return res.status(400).send(out.summary)
}
let scope: AnalyticsScope
if (out.scope === 'project') {
if (out.goalId === undefined) {
return res.status(400).send('goalId is required for project scope')
}
scope = { kind: 'project', goalId: out.goalId }
} else {
scope = { kind: out.scope }
}
const range = resolveRange(out.period, out.from, out.to)
if (!range) return res.status(400).send('invalid range')
const meta = parseDrillDownMeta(out.meta)
const index = Math.min(out.index ?? 0, 10000)
try {
const data = await req.appUser.analyticsManager.drillDown({
sectionId,
scope,
organizationId: out.organizationId,
period: out.period,
range,
arg: {
bucket: out.bucket ?? '',
index,
datasetId: out.datasetId ?? '',
meta,
},
})
return res.tvJson(data)
} catch (err) {
$logger.error({
err,
sectionId,
userId: req.appUser.getUserData()?.id,
organizationId: out.organizationId,
scope,
}, 'Analytics fetchDrillDown failed')
return next(err)
}
}
}
@@ -0,0 +1,169 @@
import type {
AnalyticsAvailableGoal,
AnalyticsDrillDownResponse,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionsResponse,
} from 'taskview-api'
import type { AppUser } from '../../core/AppUser'
import { $logger } from '../../modules/logget'
import { GoalPermissions } from '../../types/auth.types'
import { AnalyticsRepository } from './AnalyticsRepository'
import { SectionRegistry } from './sections/SectionRegistry'
import type {
AnalyticsArgBuildSections,
AnalyticsArgDrillDown,
BuilderContext,
SectionBuilder,
} from './types'
export class AnalyticsManager {
public readonly repository: AnalyticsRepository
private readonly registry: SectionRegistry
private readonly user: AppUser
constructor(user: AppUser) {
this.user = user
this.repository = new AnalyticsRepository()
this.registry = new SectionRegistry()
}
async getAccessibleGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW])
}
async getDrillDownGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [
GoalPermissions.ANALYTICS_CAN_VIEW,
GoalPermissions.TASKS_CAN_WATCH_DETAILS,
])
}
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
const { scope, organizationId, period, range, sectionIds } = params
const allAccessible = await this.getAccessibleGoalIds(organizationId)
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
const ctx: BuilderContext = {
appUser: this.user,
scope,
period,
range,
accessibleGoalIds,
repository: this.repository,
}
const builders = this.registry.filterByIds(sectionIds)
const eligible = builders.filter(b => this.isBuilderEligible(b, scope))
const settled = await Promise.allSettled(eligible.map(b => b.build(ctx)))
const sections: AnalyticsSection[] = []
const failedSectionIds: string[] = []
settled.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) {
sections.push(r.value)
} else if (r.status === 'rejected') {
const sectionId = eligible[i].id
failedSectionIds.push(sectionId)
$logger.error({
err: r.reason,
sectionId,
userId: this.user.getUserData()?.id,
organizationId,
}, 'Analytics section failed')
}
})
const availableGoals = await this.fetchAvailableGoals(allAccessible)
return {
scope,
period,
range: { from: range.from.toISOString(), to: range.to.toISOString() },
sections,
availableGoals,
failedSectionIds,
}
}
async drillDown(params: AnalyticsArgDrillDown): Promise<AnalyticsDrillDownResponse> {
const { sectionId, scope, organizationId, period, range, arg } = params
const builder = this.registry.get(sectionId)
if (!builder || !builder.drillDown) {
return { sectionId, tasks: [], total: 0 }
}
const aggregateGoalIds = this.narrowToScope(
await this.getAccessibleGoalIds(organizationId),
scope,
)
const accessibleGoalIds = this.narrowToScope(
await this.getDrillDownGoalIds(organizationId),
scope,
)
if (aggregateGoalIds.length > 0 && accessibleGoalIds.length === 0) {
return { sectionId, tasks: [], total: 0, denied: true }
}
const ctx: BuilderContext = {
appUser: this.user,
scope,
period,
range,
accessibleGoalIds,
repository: this.repository,
}
const tasks = await builder.drillDown(ctx, arg).catch((err) => {
$logger.error(`Analytics drill-down ${sectionId} failed: ${err}`)
return []
})
return { sectionId, tasks, total: tasks.length }
}
private async fetchGoalIds(organizationId: number, permissions: string[]): Promise<number[]> {
const userData = this.user.getUserData()
if (!userData?.id || !userData?.email) return []
const ids = await this.user.organizationManager.isCurrentUserOrgOwner(organizationId)
? await this.repository.fetchAllGoalIdsInOrg(organizationId)
: await this.repository.fetchGoalIdsWithPermissions(
userData.id,
userData.email,
organizationId,
permissions,
)
return this.applyTokenFilter(ids)
}
private applyTokenFilter(ids: number[]): number[] {
const tokenAllowed = this.user.getAllowedGoalIds()
if (tokenAllowed && tokenAllowed.length > 0) {
return ids.filter(id => tokenAllowed.includes(id))
}
return ids
}
private narrowToScope(allAccessible: number[], scope: AnalyticsScope): number[] {
if (scope.kind === 'project') {
return allAccessible.includes(scope.goalId) ? [scope.goalId] : []
}
return allAccessible
}
private isBuilderEligible(builder: SectionBuilder, scope: AnalyticsScope): boolean {
if (builder.requiresGoalScope && scope.kind !== 'project') return false
return true
}
private async fetchAvailableGoals(goalIds: number[]): Promise<AnalyticsAvailableGoal[]> {
if (goalIds.length === 0) return []
return await this.repository.getGoalsForIds(goalIds)
}
}
@@ -0,0 +1,808 @@
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
import { GoalsSchema } from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import { toIntArraySql } from './helpers'
import type {
ActiveProjectsSectionRow,
AgingOpenTasksSectionRow,
AmountCoverageKpiRow,
BlockedByDependenciesSectionRow,
CompletedTasksKpiRow,
CreatedTasksKpiRow,
CycleTimeHistogramSectionRow,
CycleTimeKpiRow,
CycleTimePerProjectSectionRow,
IncomeExpenseMonthSectionRow,
IncomeExpensePerProjectSectionRow,
NetProfitKpiRow,
OverdueByAgeSectionRow,
OverdueKpiRow,
PlannedExpenseKpiRow,
PlannedIncomeKpiRow,
PriorityMixOverTimeSectionRow,
StaleTasksSectionRow,
StatusDistributionSectionRow,
ThroughputSectionRow,
TimeInKanbanStatusSectionRow,
TopProjectsByAmountSectionRow,
TotalExpenseKpiRow,
TotalIncomeKpiRow,
WorkloadByAssigneeSectionRow,
} from './sections/row.types'
import type { AnalyticsRange, DrillDownTaskRow } from './types'
type Bucket = 'day' | 'week' | 'month'
const BUCKET_SQL: Record<Bucket, { trunc: SQL, interval: SQL }> = {
day: { trunc: sql.raw("'day'"), interval: sql.raw("'1 day'::interval") },
week: { trunc: sql.raw("'week'"), interval: sql.raw("'1 week'::interval") },
month: { trunc: sql.raw("'month'"), interval: sql.raw("'1 month'::interval") },
}
function bucketLiterals(bucket: Bucket): { trunc: SQL, interval: SQL } {
const lit = BUCKET_SQL[bucket]
if (!lit) throw new Error(`Invalid bucket: ${String(bucket)}`)
return lit
}
const DRILL_DOWN_LIMIT = 200
const DRILL_DOWN_TASK_FIELDS = sql`
t.id::int as "id",
t.description as "description",
t.goal_id::int as "goalId",
g.name as "goalName",
coalesce(t.complete, false) as "complete",
t.priority_id::int as "priorityId",
t.end_date::text as "endDate",
t.date_creation::text as "date_creation",
t.date_complete::text as "date_complete"
`
export class AnalyticsRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
// ================== Goal lookups ==================
async fetchAllGoalIdsInOrg(organizationId: number): Promise<number[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ id: GoalsSchema.id })
.from(GoalsSchema)
.where(and(
eq(GoalsSchema.organizationId, organizationId),
eq(GoalsSchema.archive, 0),
)),
)
return (result ?? []).map(r => r.id).filter((id): id is number => id !== null)
}
async fetchGoalIdsWithPermissions(
userId: number,
email: string,
organizationId: number,
permissionNames: string[],
): Promise<number[]> {
if (permissionNames.length === 0) return []
const result = await this.db.dbDrizzle.execute<{ id: number }>(sql`
select g.id from tasks.goals g
where g.organization_id = ${organizationId}
and g.archive = 0
and (
g.owner = ${userId}
or (
select count(distinct p.name)
from collaboration.users cu
join collaboration.users_to_goals utg on utg.user_id = cu.id and utg.goal_id = g.id
join collaboration.users_to_roles utr on utr.user_id = cu.id
join collaboration.roles r on r.id = utr.role_id and r.goal_id = g.id
join collaboration.permissions_to_role ptr on ptr.role_id = r.id
join tv_auth.permissions p on p.id = ptr.permission_id
where cu.email = ${email}
and p.name = any(${sql`ARRAY[${sql.join(permissionNames.map(n => sql`${n}`), sql`, `)}]::text[]`})
) = ${permissionNames.length}
)
`)
return result.rows.map(r => Number(r.id)).filter(id => Number.isInteger(id))
}
async getGoalsForIds(ids: number[]) {
if (ids.length === 0) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({
id: GoalsSchema.id,
name: GoalsSchema.name,
})
.from(GoalsSchema)
.where(inArray(GoalsSchema.id, ids)),
)
return (result ?? []).map(r => ({ id: r.id ?? 0, name: r.name ?? '' }))
}
// ================== KPI ==================
async countCreated(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<CreatedTasksKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.count ?? 0)
}
async countCompleted(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<CompletedTasksKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.count ?? 0)
}
async countOverdue(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<OverdueKpiRow>(sql`
select count(*)::int as count
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and end_date is not null
and end_date < current_date
`)
return Number(result.rows[0]?.count ?? 0)
}
async medianCycleTime(goalIds: number[], range: AnalyticsRange): Promise<number | null> {
const result = await this.db.dbDrizzle.execute<CycleTimeKpiRow>(sql`
select percentile_cont(0.5) within group (
order by extract(epoch from (date_complete - date_creation)) / 86400
)::float as median
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and date_creation is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
const m = result.rows[0]?.median
return m === null || m === undefined ? null : Number(m)
}
async sumIncome(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<TotalIncomeKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type = 1
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumExpense(goalIds: number[], range: AnalyticsRange): Promise<number> {
const result = await this.db.dbDrizzle.execute<TotalExpenseKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type = 0
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumIncomeAndExpense(goalIds: number[], range: AnalyticsRange): Promise<{ income: number, expense: number }> {
const result = await this.db.dbDrizzle.execute<NetProfitKpiRow>(sql`
select
coalesce(sum(case when transaction_type = 1 then amount else 0 end), 0)::float as income,
coalesce(sum(case when transaction_type = 0 then amount else 0 end), 0)::float as expense
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and transaction_type in (0, 1)
and amount is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
`)
const r = result.rows[0] ?? { income: 0, expense: 0 }
return { income: Number(r.income), expense: Number(r.expense) }
}
async sumPlannedIncome(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<PlannedIncomeKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and transaction_type = 1
and amount is not null
`)
return Number(result.rows[0]?.total ?? 0)
}
async sumPlannedExpense(goalIds: number[]): Promise<number> {
const result = await this.db.dbDrizzle.execute<PlannedExpenseKpiRow>(sql`
select coalesce(sum(amount), 0)::float as total
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and transaction_type = 0
and amount is not null
`)
return Number(result.rows[0]?.total ?? 0)
}
async amountCoverage(goalIds: number[]): Promise<{ total: number, withAmount: number }> {
const result = await this.db.dbDrizzle.execute<AmountCoverageKpiRow>(sql`
select
count(*)::int as total,
count(*) filter (where amount is not null)::int as with_amount
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
`)
const r = result.rows[0] ?? { total: 0, with_amount: 0 }
return { total: Number(r.total), withAmount: Number(r.with_amount) }
}
// ================== Productivity ==================
async fetchThroughput(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<ThroughputSectionRow[]> {
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
const goalIdsSql = toIntArraySql(goalIds)
const result = await this.db.dbDrizzle.execute<ThroughputSectionRow>(sql`
with buckets as (
select generate_series(
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
${intervalSql}
) as bucket
),
created as (
select date_trunc(${bucketSql}, date_creation) as bucket, count(*)::int as count
from tasks.tasks
where goal_id = any(${goalIdsSql})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
group by date_trunc(${bucketSql}, date_creation)
),
completed as (
select date_trunc(${bucketSql}, date_complete) as bucket, count(*)::int as count
from tasks.tasks
where goal_id = any(${goalIdsSql})
and complete = true
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
group by date_trunc(${bucketSql}, date_complete)
)
select
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
coalesce(c.count, 0)::int as created,
coalesce(d.count, 0)::int as completed
from buckets b
left join created c on c.bucket = b.bucket
left join completed d on d.bucket = b.bucket
order by b.bucket asc
`)
return result.rows as ThroughputSectionRow[]
}
async fetchPriorityMix(goalIds: number[], range: AnalyticsRange, bucket: Bucket): Promise<PriorityMixOverTimeSectionRow[]> {
const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket)
const goalIdsSql = toIntArraySql(goalIds)
const result = await this.db.dbDrizzle.execute<PriorityMixOverTimeSectionRow>(sql`
with buckets as (
select generate_series(
date_trunc(${bucketSql}, ${range.from.toISOString()}::timestamp),
date_trunc(${bucketSql}, ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
${intervalSql}
) as bucket
),
created as (
select date_trunc(${bucketSql}, date_creation) as bucket, priority_id
from tasks.tasks
where goal_id = any(${goalIdsSql})
and date_creation >= ${range.from.toISOString()}
and date_creation < ${range.to.toISOString()}
)
select
to_char(b.bucket, 'YYYY-MM-DD') as bucket,
coalesce(sum(case when c.priority_id = 3 then 1 else 0 end), 0)::int as high,
coalesce(sum(case when c.priority_id = 2 then 1 else 0 end), 0)::int as medium,
coalesce(sum(case when c.priority_id = 1 then 1 else 0 end), 0)::int as low,
coalesce(sum(case when c.priority_id is null then 1 else 0 end), 0)::int as none
from buckets b
left join created c on c.bucket = b.bucket
group by b.bucket
order by b.bucket asc
`)
return result.rows as PriorityMixOverTimeSectionRow[]
}
// ================== Workload ==================
async fetchWorkloadByAssignee(goalIds: number[]): Promise<WorkloadByAssigneeSectionRow[]> {
const result = await this.db.dbDrizzle.execute<WorkloadByAssigneeSectionRow>(sql`
select * from (
select
cu.id as user_id,
coalesce(cu.email, 'Unknown') as user_name,
sum(case when t.priority_id = 3 then 1 else 0 end)::int as high,
sum(case when t.priority_id = 2 then 1 else 0 end)::int as medium,
sum(case when t.priority_id = 1 then 1 else 0 end)::int as low,
sum(case when t.priority_id is null then 1 else 0 end)::int as no_priority
from tasks.tasks t
join tasks_auth.task_assignee ta on ta.task_id = t.id
join collaboration.users cu on cu.id = ta.collab_user_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
group by cu.id, cu.email
) s
order by (s.high * 3 + s.medium * 2 + s.low + s.no_priority) desc
limit 30
`)
return result.rows as WorkloadByAssigneeSectionRow[]
}
async fetchBlockedByDeps(goalIds: number[]): Promise<BlockedByDependenciesSectionRow[]> {
const result = await this.db.dbDrizzle.execute<BlockedByDependenciesSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
count(distinct t.id)::int as blocked
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
join tasks.task_relations r on r.to_task_id = t.id
join tasks.tasks src on src.id = r.from_task_id
where g.id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and (src.complete is null or src.complete = false)
group by g.id, g.name
order by blocked desc
`)
return result.rows as BlockedByDependenciesSectionRow[]
}
async fetchTimeInKanbanStatus(goalId: number, accessibleGoalIds: number[]): Promise<TimeInKanbanStatusSectionRow[]> {
const result = await this.db.dbDrizzle.execute<TimeInKanbanStatusSectionRow>(sql`
select
s.id as status_id,
coalesce(s.name, 'Без статуса') as status_name,
avg(extract(epoch from (now() - coalesce(t.edit_date, t.date_creation))) / 86400.0)::float as avg_days,
count(t.id)::int as task_count
from tasks.tasks t
left join tasks.statuses s on s.id = t.status_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
group by s.id, s.name, s.view_order
order by s.view_order nulls last, s.name
`)
return result.rows as TimeInKanbanStatusSectionRow[]
}
async fetchAgingOpenTasks(goalIds: number[]): Promise<AgingOpenTasksSectionRow[]> {
const result = await this.db.dbDrizzle.execute<AgingOpenTasksSectionRow>(sql`
select
cu.id as user_id,
coalesce(cu.email, 'Unknown') as user_name,
avg(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as avg_age,
max(extract(epoch from (now() - t.date_creation)) / 86400.0)::float as max_age,
count(distinct t.id)::int as task_count
from tasks.tasks t
join tasks_auth.task_assignee ta on ta.task_id = t.id
join collaboration.users cu on cu.id = ta.collab_user_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
group by cu.id, cu.email
having count(distinct t.id) > 0
order by avg_age desc nulls last
limit 20
`)
return result.rows as AgingOpenTasksSectionRow[]
}
// ================== Quality ==================
async fetchOverdueByAge(goalIds: number[]): Promise<OverdueByAgeSectionRow> {
const result = await this.db.dbDrizzle.execute<OverdueByAgeSectionRow>(sql`
select
sum(case when (current_date - end_date) between 1 and 3 then 1 else 0 end)::int as bucket_1_3,
sum(case when (current_date - end_date) between 4 and 7 then 1 else 0 end)::int as bucket_4_7,
sum(case when (current_date - end_date) between 8 and 14 then 1 else 0 end)::int as bucket_8_14,
sum(case when (current_date - end_date) > 14 then 1 else 0 end)::int as bucket_15_plus
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and (complete is null or complete = false)
and end_date is not null
and end_date < current_date
`)
return (result.rows[0] ?? {
bucket_1_3: 0, bucket_4_7: 0, bucket_8_14: 0, bucket_15_plus: 0,
}) as OverdueByAgeSectionRow
}
async fetchCycleTimeHistogram(goalIds: number[], range: AnalyticsRange): Promise<CycleTimeHistogramSectionRow> {
const result = await this.db.dbDrizzle.execute<CycleTimeHistogramSectionRow>(sql`
with durations as (
select extract(epoch from (date_complete - date_creation)) / 86400.0 as days
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and date_creation is not null
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
)
select
sum(case when days < 1 then 1 else 0 end)::int as bucket_0_1,
sum(case when days >= 1 and days < 3 then 1 else 0 end)::int as bucket_1_3,
sum(case when days >= 3 and days < 7 then 1 else 0 end)::int as bucket_3_7,
sum(case when days >= 7 and days < 14 then 1 else 0 end)::int as bucket_7_14,
sum(case when days >= 14 and days < 30 then 1 else 0 end)::int as bucket_14_30,
sum(case when days >= 30 then 1 else 0 end)::int as bucket_30_plus
from durations
`)
return (result.rows[0] ?? {
bucket_0_1: 0, bucket_1_3: 0, bucket_3_7: 0,
bucket_7_14: 0, bucket_14_30: 0, bucket_30_plus: 0,
}) as CycleTimeHistogramSectionRow
}
async fetchStaleTasks(goalIds: number[]): Promise<StaleTasksSectionRow[]> {
const result = await this.db.dbDrizzle.execute<StaleTasksSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
count(t.id)::int as stale
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
group by g.id, g.name
order by stale desc
`)
return result.rows as StaleTasksSectionRow[]
}
async fetchCycleTimePerProject(goalIds: number[], range: AnalyticsRange): Promise<CycleTimePerProjectSectionRow[]> {
const result = await this.db.dbDrizzle.execute<CycleTimePerProjectSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
percentile_cont(0.5) within group (
order by extract(epoch from (t.date_complete - t.date_creation)) / 86400.0
)::float as median_days,
count(t.id)::int as completed
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.complete = true
and t.date_complete is not null
and t.date_creation is not null
and t.date_complete >= ${range.from.toISOString()}
and t.date_complete < ${range.to.toISOString()}
group by g.id, g.name
having count(t.id) > 0
order by median_days desc nulls last
`)
return result.rows as CycleTimePerProjectSectionRow[]
}
// ================== Usage ==================
async fetchStatusDistribution(goalId: number, accessibleGoalIds: number[]): Promise<StatusDistributionSectionRow[]> {
const result = await this.db.dbDrizzle.execute<StatusDistributionSectionRow>(sql`
select
s.id as status_id,
coalesce(s.name, 'No status') as status_name,
count(t.id)::int as count
from tasks.tasks t
left join tasks.statuses s on s.id = t.status_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
group by s.id, s.name
having count(t.id) > 0
order by count desc
`)
return result.rows as StatusDistributionSectionRow[]
}
async fetchActiveProjects(goalIds: number[]): Promise<ActiveProjectsSectionRow[]> {
const result = await this.db.dbDrizzle.execute<ActiveProjectsSectionRow>(sql`
with last_activity as (
select g.id as goal_id,
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
from tasks.goals g
left join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
group by g.id
)
select status_key, count(*)::int as count
from (
select
case
when last_at is null then 'empty'
when last_at >= now() - interval '14 days' then 'active'
when last_at >= now() - interval '30 days' then 'fading'
else 'dead'
end as status_key
from last_activity
) s
group by status_key
order by case status_key
when 'active' then 1
when 'fading' then 2
when 'dead' then 3
when 'empty' then 4
end
`)
return result.rows as ActiveProjectsSectionRow[]
}
// ================== Financial ==================
async fetchIncomeExpenseMonth(goalIds: number[], range: AnalyticsRange): Promise<IncomeExpenseMonthSectionRow[]> {
const result = await this.db.dbDrizzle.execute<IncomeExpenseMonthSectionRow>(sql`
with months as (
select generate_series(
date_trunc('month', ${range.from.toISOString()}::timestamp),
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
'1 month'::interval
) as month
),
totals as (
select
date_trunc('month', date_complete) as month,
sum(case when transaction_type = 1 then coalesce(amount, 0) else 0 end)::float as income,
sum(case when transaction_type = 0 then coalesce(amount, 0) else 0 end)::float as expense
from tasks.tasks
where goal_id = any(${toIntArraySql(goalIds)})
and complete = true
and date_complete is not null
and amount is not null
and transaction_type in (0, 1)
and date_complete >= ${range.from.toISOString()}
and date_complete < ${range.to.toISOString()}
group by date_trunc('month', date_complete)
)
select
to_char(m.month, 'YYYY-MM') as month,
coalesce(t.income, 0)::float as income,
coalesce(t.expense, 0)::float as expense
from months m
left join totals t on t.month = m.month
order by m.month asc
`)
return result.rows as IncomeExpenseMonthSectionRow[]
}
async fetchIncomeExpensePerProject(goalIds: number[]): Promise<IncomeExpensePerProjectSectionRow[]> {
const result = await this.db.dbDrizzle.execute<IncomeExpensePerProjectSectionRow>(sql`
select
g.id as goal_id,
g.name as goal_name,
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense,
(sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)
- sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end))::float as net
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.amount is not null
and t.transaction_type in (0, 1)
group by g.id, g.name
having sum(coalesce(t.amount, 0)) > 0
order by net desc
limit 20
`)
return result.rows as IncomeExpensePerProjectSectionRow[]
}
async fetchTopProjectsByAmount(goalIds: number[]): Promise<TopProjectsByAmountSectionRow[]> {
const result = await this.db.dbDrizzle.execute<TopProjectsByAmountSectionRow>(sql`
select * from (
select
g.id as goal_id,
g.name as goal_name,
sum(case when t.transaction_type = 1 then coalesce(t.amount, 0) else 0 end)::float as income,
sum(case when t.transaction_type = 0 then coalesce(t.amount, 0) else 0 end)::float as expense
from tasks.goals g
join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)})
and t.amount is not null
and t.transaction_type in (0, 1)
group by g.id, g.name
having sum(coalesce(t.amount, 0)) > 0
) s
order by (s.income + s.expense) desc
limit 15
`)
return result.rows as TopProjectsByAmountSectionRow[]
}
// ================== Drill-down ==================
async fetchOverdueTasks(goalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.end_date is not null
and t.end_date < current_date
order by t.end_date asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOverdueTasksInRange(
goalIds: number[],
minDays: number,
maxDays: number | null,
): Promise<DrillDownTaskRow[]> {
const maxClause = maxDays !== null
? sql`and (current_date - t.end_date) <= ${maxDays}`
: sql``
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.end_date is not null
and (current_date - t.end_date) >= ${minDays}
${maxClause}
order by t.end_date asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchStaleTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
and coalesce(t.edit_date, t.date_creation) < now() - interval '30 days'
order by coalesce(t.edit_date, t.date_creation) asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchBlockedTasksInGoal(goalId: number, accessibleGoalIds: number[]): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks.task_relations r on r.to_task_id = t.id
join tasks.tasks src on src.id = r.from_task_id
where t.goal_id = ${goalId}
and t.goal_id = any(${toIntArraySql(accessibleGoalIds)})
and (t.complete is null or t.complete = false)
and (src.complete is null or src.complete = false)
order by t.id
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksAssignedTo(goalIds: number[], userId: number): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks_auth.task_assignee ta on ta.task_id = t.id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and ta.collab_user_id = ${userId}
order by t.id, t.date_creation asc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksAssignedWithPriority(
goalIds: number[],
userId: number,
priorityFilter: number | 'null' | undefined,
): Promise<DrillDownTaskRow[]> {
const priorityClause: SQL = priorityFilter === undefined
? sql``
: priorityFilter === 'null'
? sql`and t.priority_id is null`
: sql`and t.priority_id = ${priorityFilter}`
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join tasks_auth.task_assignee ta on ta.task_id = t.id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and ta.collab_user_id = ${userId}
${priorityClause}
order by t.id, t.date_creation desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchOpenTasksInActiveProjects(
goalIds: number[],
statusKey: 'active' | 'fading' | 'dead',
): Promise<DrillDownTaskRow[]> {
let activityClause: SQL
if (statusKey === 'active') {
activityClause = sql`last_at >= now() - interval '14 days'`
} else if (statusKey === 'fading') {
activityClause = sql`last_at >= now() - interval '30 days' and last_at < now() - interval '14 days'`
} else {
activityClause = sql`last_at < now() - interval '30 days'`
}
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
with last_activity as (
select g.id as goal_id,
max(coalesce(t.edit_date, t.date_creation, t.date_complete)) as last_at
from tasks.goals g
left join tasks.tasks t on t.goal_id = g.id
where g.id = any(${toIntArraySql(goalIds)}) and g.archive = 0
group by g.id
),
matching_goals as (
select goal_id from last_activity where ${activityClause}
)
select distinct on (t.id) ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
join matching_goals m on m.goal_id = t.goal_id
where (t.complete is null or t.complete = false)
order by t.id, coalesce(t.edit_date, t.date_creation) desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
async fetchPlannedTasksByType(goalIds: number[], transactionType: 0 | 1): Promise<DrillDownTaskRow[]> {
const result = await this.db.dbDrizzle.execute<DrillDownTaskRow>(sql`
select ${DRILL_DOWN_TASK_FIELDS}
from tasks.tasks t
join tasks.goals g on g.id = t.goal_id
where t.goal_id = any(${toIntArraySql(goalIds)})
and (t.complete is null or t.complete = false)
and t.transaction_type = ${transactionType}
and t.amount is not null
order by t.amount desc
limit ${DRILL_DOWN_LIMIT}
`)
return result.rows as DrillDownTaskRow[]
}
}
@@ -0,0 +1,27 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { AnalyticsController } from './AnalyticsController'
import { CanAccessAnalytics } from './middlewares/CanAccessAnalytics'
export default class AnalyticsRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: AnalyticsController
constructor() {
this.router = Router()
this.controller = new AnalyticsController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
const guards = [IsLoggedIn, RejectApiTokenAuth, CanAccessAnalytics]
this.router.get('/sections', guards, this.controller.fetchSections)
this.router.get('/drilldown/:sectionId', guards, this.controller.fetchDrillDown)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { type } from 'arktype'
import { sql, type SQL } from 'drizzle-orm'
import type { AnalyticsPeriod } from 'taskview-api'
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta } from './types'
const MAX_INT32 = 2147483647
export function toIntArraySql(ids: ReadonlyArray<number>): SQL {
const safe = ids.filter(
(id): id is number =>
typeof id === 'number' && Number.isInteger(id) && id > 0 && id < MAX_INT32,
)
return sql.raw(`ARRAY[${safe.join(',')}]::int[]`)
}
export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
if (!raw) return {}
try {
const result = DrillDownMetaArkType(JSON.parse(raw))
return result instanceof type.errors ? {} : result
} catch {
return {}
}
}
export function resolveRange(
period: AnalyticsPeriod,
from?: string,
to?: string,
): AnalyticsRange | null {
const now = new Date()
if (period === 'custom') {
if (!from || !to) return null
const fromDate = new Date(from)
const toDate = new Date(to)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
if (fromDate > toDate) return null
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
return { from: fromDate, to: toDate }
}
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
}
const days = daysByPeriod[period]
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return { from: fromDate, to: now }
}
@@ -0,0 +1,41 @@
import type { NextFunction, Request, Response } from 'express'
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher'
import { $logger } from '../../../modules/logget'
import { GoalPermissions } from '../../../types/auth.types'
import { logError } from '../../../utils/api'
import { parsePositiveInt } from '../../../utils/helpers'
export const CanAccessAnalytics = async (req: Request, res: Response, next: NextFunction) => {
const orgId = parsePositiveInt(req.query?.organizationId)
if (orgId === null) return res.status(400).end()
const member = await req.appUser.organizationManager.getCurrentUserMember(orgId)
if (!member) return res.status(403).end()
if (await req.appUser.organizationManager.isCurrentUserOrgOwner(orgId)) return next()
const scope = req.query?.scope
if (scope === 'project') {
const goalId = parsePositiveInt(req.query?.goalId)
if (goalId === null) return res.status(400).end()
const checker = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError)
if (!checker) {
$logger.error('Can not get permissions for CanAccessAnalytics middleware')
return res.status(500).end()
}
if (!checker.hasPermissions(GoalPermissions.ANALYTICS_CAN_VIEW)) return res.status(403).end()
return next()
}
const accessibleGoalIds = await req.appUser.analyticsManager
.getAccessibleGoalIds(orgId)
.catch(logError)
if (!accessibleGoalIds || accessibleGoalIds.length === 0) return res.status(403).end()
return next()
}
@@ -0,0 +1,81 @@
import type { SectionBuilder } from '../types'
import { CreatedTasksKpi } from './kpi/CreatedTasksKpi'
import { CompletedTasksKpi } from './kpi/CompletedTasksKpi'
import { OverdueKpi } from './kpi/OverdueKpi'
import { CycleTimeKpi } from './kpi/CycleTimeKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
// import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
// import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
import { NetProfitKpi } from './financial/NetProfitKpi'
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
const builders: SectionBuilder[] = [
// KPI
new CreatedTasksKpi(),
new CompletedTasksKpi(),
new OverdueKpi(),
new CycleTimeKpi(),
new TotalIncomeKpi(),
new TotalExpenseKpi(),
new NetProfitKpi(),
new PlannedIncomeKpi(),
new PlannedExpenseKpi(),
new AmountCoverageKpi(),
// Productivity
new ThroughputSection(),
// new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
// new BlockedByDependenciesSection(),
// new TimeInKanbanStatusSection(),
// new AgingOpenTasksSection(),
// Quality
new OverdueByAgeSection(),
// new CycleTimeHistogramSection(),
new StaleTasksSection(),
// new CycleTimePerProjectSection(),
// Usage
// new StatusDistributionSection(),
new ActiveProjectsSection(),
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new TopProjectsByAmountSection(),
]
export class SectionRegistry {
private readonly byId: Map<string, SectionBuilder>
constructor() {
this.byId = new Map(builders.map(b => [b.id, b]))
}
all(): SectionBuilder[] {
return [...this.byId.values()]
}
get(id: string): SectionBuilder | undefined {
return this.byId.get(id)
}
filterByIds(ids?: string[]): SectionBuilder[] {
if (!ids || ids.length === 0) return this.all()
return ids.map(id => this.byId.get(id)).filter((b): b is SectionBuilder => !!b)
}
}
@@ -0,0 +1,52 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class AmountCoverageKpi implements SectionBuilder {
readonly id = 'kpi.amount_coverage'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { total, withAmount } = await ctx.repository.amountCoverage(ctx.accessibleGoalIds)
const percent = total === 0 ? 0 : Math.round((withAmount / total) * 100)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: percent,
unit: 'percent',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'percent' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,68 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class IncomeExpenseMonthSection implements SectionBuilder {
readonly id = 'chart.income_expense_month'
readonly group = 'financial' as const
readonly allowedChartTypes = ['bar', 'line', 'area', 'stackedArea'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchIncomeExpenseMonth(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.month),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
},
],
unit: 'currency',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class IncomeExpensePerProjectSection implements SectionBuilder {
readonly id = 'chart.income_expense_per_project'
readonly group = 'financial' as const
readonly allowedChartTypes = ['bar', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchIncomeExpensePerProject(ctx.accessibleGoalIds)
const loc = this.loc
const goalIds = rows.map(r => r.goal_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
meta: { goalIds },
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
meta: { goalIds },
},
],
unit: 'currency',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,78 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class NetProfitKpi implements SectionBuilder {
readonly id = 'kpi.net_profit'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const cur = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumIncomeAndExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const current = cur.income - cur.expense
const previous = prev.income - prev.expense
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, previous),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return {
value: 100,
direction: current > 0 ? ('up' as const) : ('down' as const),
isGood: current >= 0,
}
}
const pct = Math.round(((current - prev) / Math.abs(prev)) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,57 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
import { sectionLocales } from '../locales'
export class PlannedExpenseKpi implements SectionBuilder {
readonly id = 'kpi.planned_expense'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.sumPlannedExpense(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(value),
unit: 'currency',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 0)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,57 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, DrillDownTaskRow, SectionBuilder, SectionDrillDownArg } from '../../types'
import { sectionLocales } from '../locales'
export class PlannedIncomeKpi implements SectionBuilder {
readonly id = 'kpi.planned_income'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.sumPlannedIncome(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(value),
unit: 'currency',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchPlannedTasksByType(ctx.accessibleGoalIds, 1)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,73 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TopProjectsByAmountSection implements SectionBuilder {
readonly id = 'chart.top_projects_by_amount'
readonly group = 'financial' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 900
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchTopProjectsByAmount(ctx.accessibleGoalIds)
const loc = this.loc
const goalIds = rows.map(r => r.goal_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'income',
label: loc.datasets!.income,
values: rows.map(r => Number(r.income)),
colorToken: 'success',
stack: 'amount',
meta: { goalIds },
},
{
id: 'expense',
label: loc.datasets!.expense,
values: rows.map(r => Number(r.expense)),
colorToken: 'danger',
stack: 'amount',
meta: { goalIds },
},
],
unit: 'currency',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TotalExpenseKpi implements SectionBuilder {
readonly id = 'kpi.total_expense'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumExpense(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: false }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct <= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TotalIncomeKpi implements SectionBuilder {
readonly id = 'kpi.total_income'
readonly group = 'financial' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.sumIncome(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: Math.round(current),
unit: 'currency',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'financial',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'currency' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CompletedTasksKpi implements SectionBuilder {
readonly id = 'kpi.completed_tasks'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.countCompleted(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: current,
unit: 'count',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,71 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CreatedTasksKpi implements SectionBuilder {
readonly id = 'kpi.created_tasks'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const { from, to } = ctx.range
const windowMs = to.getTime() - from.getTime()
const prevFrom = new Date(from.getTime() - windowMs)
const current = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from, to })
const prev = await ctx.repository.countCreated(ctx.accessibleGoalIds, { from: prevFrom, to: from })
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value: current,
unit: 'count',
delta: this.buildDelta(current, prev),
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private buildDelta(current: number, prev: number) {
if (prev === 0 && current === 0) {
return { value: 0, direction: 'flat' as const, isGood: true }
}
if (prev === 0) {
return { value: 100, direction: 'up' as const, isGood: true }
}
const pct = Math.round(((current - prev) / prev) * 100)
return {
value: Math.abs(pct),
direction: pct > 0 ? ('up' as const) : pct < 0 ? ('down' as const) : ('flat' as const),
isGood: pct >= 0,
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,52 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimeKpi implements SectionBuilder {
readonly id = 'kpi.cycle_time'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const median = await ctx.repository.medianCycleTime(ctx.accessibleGoalIds, ctx.range)
const value = median === null ? 0 : Math.round(median * 10) / 10
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value,
unit: 'days',
}
return {
id: this.id,
title: this.loc.title,
description: this.loc.description,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,56 @@
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class OverdueKpi implements SectionBuilder {
readonly id = 'kpi.overdue'
readonly group = 'kpi' as const
readonly allowedChartTypes = [] as const
readonly defaultChartType = null
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const value = await ctx.repository.countOverdue(ctx.accessibleGoalIds)
const payload: AnalyticsKpiPayload = {
kind: 'kpi',
value,
unit: 'count',
}
return {
id: this.id,
title: this.loc.title,
help: this.loc.help,
group: this.group,
allowedChartTypes: [],
defaultChartType: null,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, _arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchOverdueTasks(ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: 'kpi',
allowedChartTypes: [],
defaultChartType: null,
payload: { kind: 'kpi', value: 0, unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,682 @@
import type { LocalizedText } from 'taskview-api'
export type SectionLocale = {
title: LocalizedText
description?: LocalizedText
help?: {
summary: LocalizedText
details: LocalizedText
}
datasets?: Record<string, LocalizedText>
labels?: Record<string, LocalizedText>
xAxisLabel?: LocalizedText
yAxisLabel?: LocalizedText
}
const join = (parts: string[]) => parts.join('\n')
export const sectionLocales = {
// ===== KPI =====
'kpi.created_tasks': {
title: { ru: 'Создано задач', en: 'Created tasks' },
help: {
summary: {
ru: 'Количество созданных задач за выбранный период',
en: 'Number of tasks created in the selected period',
},
details: {
ru: 'Показывает входящий поток работы. Сравните со счётчиком «Закрыто задач» — если создаётся больше, чем закрывается, команда не успевает справляться, и бэклог растёт. Delta показывает изменение относительно предыдущего периода той же длины.',
en: 'Shows incoming work volume. Compare against "Completed tasks" — if creation outpaces completion, the backlog is growing and the team is falling behind. The delta compares against the equivalent previous period.',
},
},
},
'kpi.completed_tasks': {
title: { ru: 'Закрыто задач', en: 'Completed tasks' },
help: {
summary: {
ru: 'Количество задач, закрытых за период',
en: 'Tasks completed in the selected period',
},
details: {
ru: 'Реальный выход команды — сколько работы было доведено до конца. Сопоставляйте с «Создано задач»: если создано ≫ закрыто, нарастает бэклог. Стабильный тренд роста = хороший признак ускорения процессов.',
en: 'Actual team output — how much work got finished. Compare with "Created tasks": if creation far exceeds completion, the backlog is growing. A steady upward trend indicates process acceleration.',
},
},
},
'kpi.overdue': {
title: { ru: 'Просрочено', en: 'Overdue' },
help: {
summary: {
ru: 'Открытые задачи с прошедшим дедлайном',
en: 'Open tasks past their due date',
},
details: {
ru: 'Работа, требующая срочного внимания. Если число стабильно растёт — команда перегружена или дедлайны нереалистичны. Кликните по KPI, чтобы увидеть конкретные просроченные задачи.',
en: 'Work that needs immediate attention. A steadily growing number signals team overload or unrealistic deadlines. Click the KPI to drill into the specific overdue tasks.',
},
},
},
'kpi.cycle_time': {
title: { ru: 'Cycle time (медиана)', en: 'Cycle time (median)' },
description: { ru: 'Медианное время от создания до завершения', en: 'Median time from creation to completion' },
help: {
summary: {
ru: 'Медианное время от создания задачи до её закрытия',
en: 'Median time from task creation to completion',
},
details: {
ru: 'Сколько в среднем живёт задача с момента создания до закрытия. Рост показателя = замедление процессов. Используется медиана, а не среднее — выбросы (застрявшие задачи) не искажают картину.',
en: "How long a task typically lives from creation to completion. A rising number means slowing processes. We use the median rather than the mean so that outliers (stuck tasks) don't distort the picture.",
},
},
},
'kpi.total_income': {
title: { ru: 'Доходы', en: 'Income' },
description: { ru: 'Сумма за период', en: 'Sum for period' },
help: {
summary: {
ru: 'Сумма всех доходов за выбранный период',
en: 'Total income for the selected period',
},
details: {
ru: 'Складываются суммы по закрытым задачам, у которых отмечен тип «доход». Сравнение — с предыдущим периодом такой же длины. Зелёная стрелка вверх — доход вырос, это хорошо.',
en: 'Sum of amounts on closed tasks marked as "income". Compared against the previous equivalent period. A green up arrow means income grew, which is good.',
},
},
},
'kpi.total_expense': {
title: { ru: 'Расходы', en: 'Expense' },
description: { ru: 'Сумма за период', en: 'Sum for period' },
help: {
summary: {
ru: 'Сумма всех расходов за выбранный период',
en: 'Total expenses for the selected period',
},
details: {
ru: 'Складываются суммы по закрытым задачам, у которых отмечен тип «расход». Сравнение — с предыдущим периодом такой же длины. Зелёная стрелка вниз — расходы снизились, это хорошо.',
en: 'Sum of amounts on closed tasks marked as "expense". Compared against the previous equivalent period. A green down arrow means expenses dropped, which is good.',
},
},
},
'kpi.planned_income': {
title: { ru: 'Планируемые доходы', en: 'Planned income' },
description: { ru: 'По открытым задачам', en: 'From open tasks' },
help: {
summary: {
ru: 'Сумма доходов по открытым задачам с указанной суммой',
en: 'Sum of income from open tasks with amount set',
},
details: {
ru: 'Складываются суммы по всем открытым (не завершённым) задачам с типом «доход» и заполненной суммой. Это снимок ожидаемых поступлений — пока задача не закрыта, доход считается планируемым. Не зависит от выбранного периода.',
en: 'Sum of amounts from all open (incomplete) tasks marked as "income" with a filled amount. This is a snapshot of expected income — until a task is closed, the income is planned. Not affected by the selected period.',
},
},
},
'kpi.planned_expense': {
title: { ru: 'Планируемые расходы', en: 'Planned expense' },
description: { ru: 'По открытым задачам', en: 'From open tasks' },
help: {
summary: {
ru: 'Сумма расходов по открытым задачам с указанной суммой',
en: 'Sum of expenses from open tasks with amount set',
},
details: {
ru: 'Складываются суммы по всем открытым (не завершённым) задачам с типом «расход» и заполненной суммой. Это снимок ожидаемых трат — пока задача не закрыта, расход считается планируемым. Не зависит от выбранного периода.',
en: 'Sum of amounts from all open (incomplete) tasks marked as "expense" with a filled amount. This is a snapshot of expected spending — until a task is closed, the expense is planned. Not affected by the selected period.',
},
},
},
'kpi.net_profit': {
title: { ru: 'Чистая прибыль', en: 'Net profit' },
description: { ru: 'Доходы минус расходы', en: 'Income minus expense' },
help: {
summary: {
ru: 'Чистая прибыль за период: доходы минус расходы',
en: 'Net profit for the period: income minus expense',
},
details: {
ru: 'Считается как разница между всеми доходами и расходами по закрытым задачам в периоде. Положительное число — заработали больше, чем потратили. Дельта показывает, насколько изменилась прибыль по сравнению с предыдущим периодом такой же длины.',
en: 'Calculated as the difference between all income and expense from closed tasks in the period. A positive number means you earned more than you spent. The delta shows how profit changed vs the previous equivalent period.',
},
},
},
'kpi.amount_coverage': {
title: { ru: 'Заполнено amount', en: 'Amount coverage' },
description: { ru: '% задач с заполненной суммой', en: '% of tasks with amount filled' },
help: {
summary: {
ru: 'Доля задач с заполненным полем суммы',
en: 'Share of tasks with the amount field filled',
},
details: {
ru: 'Показатель качества данных для финансовой аналитики. Если % низкий — графики «Доходы/расходы» и «Топ проектов» отражают только малую часть реальности. Стоит либо не доверять им, либо наладить практику заполнения amount/transactionType.',
en: 'Data quality indicator for financial analytics. If this percentage is low, the Income/Expense and Top Projects charts only reflect a small slice of reality — either treat them with caution or establish a practice of filling the amount/transactionType fields.',
},
},
},
// ===== Productivity =====
'chart.throughput': {
title: { ru: 'Создано vs закрыто', en: 'Created vs completed' },
description: {
ru: 'Баланс входящей и закрываемой работы',
en: 'Balance of incoming and completed work',
},
help: {
summary: {
ru: 'Созданные и закрытые задачи по периодам',
en: 'Created and completed tasks over time',
},
details: {
ru: 'Главный индикатор здоровья проекта. Зазор между линиями — предупреждение: вы создаёте больше, чем закрываете, и бэклог растёт. Постоянный зазор → накопление долга, команда не справляется. Линии близко друг к другу → работа идёт в темпе поступления.',
en: 'The primary project health indicator. A gap between the lines is a warning — creation outpaces completion, so the backlog is growing. A persistent gap means the team is falling behind; tight lines mean work is getting done at the rate it arrives.',
},
},
datasets: {
created: { ru: 'Создано', en: 'Created' },
completed: { ru: 'Закрыто', en: 'Completed' },
},
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.priority_mix': {
title: { ru: 'Приоритеты создаваемых задач', en: 'Priority mix over time' },
description: { ru: 'Распределение новых задач по приоритету', en: 'Distribution of new tasks by priority' },
help: {
summary: {
ru: 'Как меняется распределение приоритетов у создаваемых задач',
en: 'How priority mix of newly created tasks shifts over time',
},
details: {
ru: 'Если доля High растёт — вероятно, проблемы с планированием или команда в режиме пожаротушения. Здоровая продуктовая работа имеет больше Medium и Low, чем High. Также обратите внимание на долю задач «Без приоритета» — это сигнал плохой практики триажа.',
en: 'If the High share grows, the team may be firefighting or planning poorly. Healthy product work has more Medium/Low than High. Also watch the "No priority" share — a large portion points to poor triage practice.',
},
},
datasets: {
high: { ru: 'Высокий', en: 'High' },
medium: { ru: 'Средний', en: 'Medium' },
low: { ru: 'Низкий', en: 'Low' },
none: { ru: 'Без приоритета', en: 'No priority' },
},
yAxisLabel: { ru: 'Создано задач', en: 'Tasks created' },
},
// ===== Workload =====
'chart.workload_by_assignee': {
title: { ru: 'Нагрузка по исполнителям', en: 'Workload by assignee' },
description: { ru: 'Открытые задачи, сгруппированные по приоритету', en: 'Open tasks grouped by priority' },
help: {
summary: {
ru: 'Количество открытых задач на каждого исполнителя с разбивкой по приоритету',
en: 'Open tasks per assignee, broken down by priority',
},
details: {
ru: 'Быстрый взгляд на перегруз команды. Если у одного человека 15+ задач или много High-приоритета — нужно перераспределить нагрузку. Это не рейтинг эффективности: размер задач разный, и некоторые люди формально числятся в assignee, но не работают.',
en: 'A quick check for team overload. If one person has 15+ tasks or many High-priority items, workload needs rebalancing. This is not a performance ranking: task sizes vary and some people appear as formal assignees without actively working.',
},
},
datasets: {
high: { ru: 'Высокий', en: 'High' },
medium: { ru: 'Средний', en: 'Medium' },
low: { ru: 'Низкий', en: 'Low' },
no_priority: { ru: 'Без приоритета', en: 'No priority' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.blocked_by_deps': {
title: { ru: 'Заблокировано зависимостями', en: 'Blocked by dependencies' },
description: { ru: 'Открытые задачи, ждущие завершения зависимостей', en: 'Open tasks waiting on incomplete prerequisites' },
help: {
summary: {
ru: 'Открытые задачи, которые не могут стартовать, пока не закрыты их предшественники',
en: 'Open tasks that cannot start until their predecessors are completed',
},
details: {
ru: 'Явные блокеры процесса — здесь нужно вмешательство PM в первую очередь. Высокое число = поток остановлен, нужно разблокировать ключевые задачи. Зависимости берутся из графа задач (стрелка от A к B = B зависит от A).',
en: 'Explicit process blockers — the PM should address these first. A high count means the pipeline is stalled and key prerequisites need attention. Dependencies are derived from the task graph (an arrow from A to B means B depends on A).',
},
},
datasets: {
blocked: { ru: 'Заблокировано', en: 'Blocked' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.time_in_kanban_status': {
title: { ru: 'Время в колонках канбана', en: 'Time in kanban columns' },
description: { ru: 'Среднее время жизни открытой задачи в каждом статусе', en: 'Average open-task age per kanban column' },
help: {
summary: {
ru: 'Среднее время, которое открытые задачи проводят в каждом статусе',
en: 'Average age of open tasks broken down by kanban column',
},
details: {
ru: 'Выявляет узкие места процесса. Если задачи застревают в «Review» на 5+ дней — не хватает ревьюеров. Если в «In Progress» — WIP-лимит превышен. Требует выбора проекта, потому что колонки канбана уникальны для каждого.',
en: 'Reveals process bottlenecks. If tasks sit in "Review" for 5+ days, you lack reviewers; long times in "In Progress" mean the WIP limit is exceeded. Requires selecting a project because kanban columns are unique per project.',
},
},
datasets: {
avg_days: { ru: 'Среднее время в колонке', en: 'Avg time in column' },
},
yAxisLabel: { ru: 'Дней', en: 'Days' },
},
'chart.aging_open_tasks': {
title: { ru: 'Возраст открытых задач', en: 'Aging of open tasks' },
description: { ru: 'Средний и максимальный возраст открытых задач по исполнителям', en: 'Average and maximum open-task age per assignee' },
help: {
summary: {
ru: 'Сколько дней прошло с момента создания открытых задач, по исполнителям',
en: "Days since creation for each assignee's open tasks",
},
details: {
ru: join([
'Что считается «возрастом»:',
'Сколько дней прошло с момента создания задачи до сегодня. Например: задача создана 1 апреля, сегодня 21 апреля → возраст = 20 дней.',
'',
'Что показывает график:',
'Для каждого исполнителя берутся все его открытые (незавершённые) задачи и считается:',
'• Средний возраст — насколько старые в среднем его задачи',
'• Максимум — возраст самой старой его открытой задачи',
'',
'Как читать:',
'• Высокий средний → накопилось много старых задач: возможно, перегруз или человек не двигает бэклог',
'• Высокий максимум при низком среднем → в целом всё быстро, но есть одна-две «висящих» задачи — добить или закрыть как устаревшие',
'• Оба значения низкие → задачи либо свежие, либо быстро закрываются — здоровая ситуация',
'• Большой разрыв между ними → есть «тяжёлые хвосты»: отдельные давние задачи выбиваются из общего ритма',
'',
'Нюансы:',
'• Учитываются исполнители (assignee), а не создатели задач',
'• Если задача назначена двоим — попадёт к обоим (это корректно: оба за неё отвечают)',
'• Сверху списка — исполнители с самой старой в среднем работой',
'',
'Что НЕ измеряется:',
'• Время в работе — это отдельная метрика Cycle Time',
'• Время до дедлайна — смотрите Overdue-метрики',
'• Эффективность — старая задача может быть просто большой или заблокированной',
]),
en: join([
'What "age" means:',
'How many days have passed since the task was created until today. Example: task created Apr 1, today is Apr 21 → age = 20 days.',
'',
'What the chart shows:',
'For each assignee, we take all their open (incomplete) tasks and compute:',
'• Average age — how old their tasks are on average',
'• Max — age of their oldest open task',
'',
'How to read it:',
'• High average → lots of old tasks piled up: possibly overloaded or not moving the backlog',
'• High max with low average → generally fast, but one or two "stuck" tasks — finish them or close as obsolete',
'• Both low → tasks are either fresh or closed quickly — a healthy state',
'• Large gap between them → "heavy tails": isolated old tasks breaking away from the norm',
'',
'Notes:',
'• Counted by assignee, not by creator',
'• A task assigned to two people appears for both (correct: both are responsible)',
'• Top of the list = assignees with the oldest typical work',
'',
'What is NOT measured:',
"• Time in active work — that's the separate Cycle Time metric",
'• Time until deadline — see Overdue metrics',
'• Efficiency — an old task may simply be large or blocked',
]),
},
},
datasets: {
avg_age: { ru: 'Средний возраст', en: 'Average age' },
max_age: { ru: 'Максимум', en: 'Max' },
},
xAxisLabel: { ru: 'Дней', en: 'Days' },
},
// ===== Quality =====
'chart.overdue_by_age': {
title: { ru: 'Просроченные по срокам давности', en: 'Overdue by age' },
help: {
summary: {
ru: 'Просроченные задачи, сгруппированные по времени с даты дедлайна',
en: 'Overdue tasks grouped by how long they have been overdue',
},
details: {
ru: 'Приоритизация «тушения пожаров». Задачи, просроченные 1-3 дня — скорее всего в работе и скоро закроются. 15+ дней — либо срочно решать, либо закрывать как устаревшие: такие дедлайны уже потеряли смысл.',
en: 'Firefighting priority. Tasks overdue 13 days are likely close to finishing. Tasks overdue 15+ days need urgent resolution or should be closed as obsolete — those deadlines have already lost meaning.',
},
},
datasets: {
overdue: { ru: 'Просрочено', en: 'Overdue' },
},
labels: {
bucket_1_3: { ru: '13 дн', en: '13 d' },
bucket_4_7: { ru: '47 дн', en: '47 d' },
bucket_8_14: { ru: '814 дн', en: '814 d' },
bucket_15_plus: { ru: '15+ дн', en: '15+ d' },
},
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.cycle_time_histogram': {
title: { ru: 'Распределение cycle time', en: 'Cycle time distribution' },
description: {
ru: 'Сколько задач закрылось в каждом диапазоне по длительности',
en: 'How many tasks closed in each duration range',
},
help: {
summary: {
ru: 'Сколько закрытых задач попало в каждый диапазон по длительности',
en: 'How many completed tasks fall into each duration range',
},
details: {
ru: join([
'Что показывает:',
'Все закрытые за период задачи разбиты на 6 диапазонов по длительности (от создания до закрытия). Столбец показывает, сколько задач попало в каждый диапазон.',
'',
'Как читать:',
'• Большинство в «<1д» и «1-3д» → команда закрывает задачи быстро',
'• Перевес в «7-14д» и больше → задачи крупные или долго лежат в бэклоге',
'• Высокий столбец в «30+д» → есть проблема с давними задачами, которые наконец-то были закрыты',
'',
'Нюанс:',
'Учитывается полная жизнь задачи — от создания до закрытия, включая время в бэклоге. Задача, созданная 2 месяца назад и закрытая за день, попадёт в «30+д», а не в «<1д».',
]),
en: join([
'What it shows:',
'All tasks closed during the period are split into 6 duration buckets (from creation to completion). Each bar shows how many tasks fell into that bucket.',
'',
'How to read it:',
'• Most in "<1d" and "1-3d" → team closes tasks quickly',
'• Skewed toward "7-14d" and higher → tasks are large or sit in the backlog for a long time',
'• Tall bar in "30+d" → old tasks finally closed, signalling backlog debt',
'',
'Note:',
'Measures the full task life — from creation to close, including time spent in the backlog. A task created 2 months ago and finished in a day lands in "30+d", not "<1d".',
]),
},
},
datasets: {
tasks: { ru: 'Задач', en: 'Tasks' },
},
xAxisLabel: { ru: 'Длительность', en: 'Duration' },
yAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.stale_tasks': {
title: { ru: 'Забытые задачи', en: 'Stale tasks' },
description: { ru: 'Открытые задачи без изменений более 30 дней', en: 'Open tasks without changes for over 30 days' },
help: {
summary: {
ru: 'Открытые задачи, по которым не было никаких изменений более 30 дней',
en: 'Open tasks with no changes for more than 30 days',
},
details: {
ru: 'Карта «где гниёт работа» по проектам. Кандидаты либо на чистку (удалить устаревшее), либо на ре-активацию (если всё ещё актуально). Большое число на проекте = бэклог перегружен неактуальной работой, пора провести ревью.',
en: '"Where work rots" — per project. Candidates for cleanup (delete obsolete) or reactivation (if still relevant). A high number on a project means the backlog is bloated with obsolete work and needs a review.',
},
},
datasets: {
stale: { ru: 'Без движения >30д', en: 'No activity >30d' },
},
xAxisLabel: { ru: 'Задач', en: 'Tasks' },
},
'chart.cycle_time_per_project': {
title: { ru: 'Cycle time по проектам', en: 'Cycle time per project' },
description: { ru: 'Медианное время выполнения закрытых задач', en: 'Median completion time for finished tasks' },
help: {
summary: {
ru: 'Сколько в среднем задача живёт от создания до закрытия в каждом проекте',
en: 'How long a task typically lives from creation to completion in each project',
},
details: {
ru: join([
'Что показывает:',
'Для каждого проекта — типичное время, за которое задача проходит путь от создания до закрытия. Используется медиана: «половина задач в этом проекте закрывается быстрее, чем за X дней».',
'',
'Какие задачи учитываются:',
'Только закрытые задачи, у которых дата закрытия попадает в выбранный период. Проекты без закрытых задач за период не показаны.',
'',
'Как читать:',
'Видно, какие проекты движутся быстрее, какие медленнее. Если один проект в 2-3 раза медленнее остальных — повод поговорить с его PM: возможно, мешают блокеры, задачи слишком крупные или процесс провисает.',
'',
'⚠ Не пугайтесь больших чисел:',
'',
'1. Считается полная жизнь задачи, а не время в работе.',
'Если задача 2 месяца лежала в бэклоге, а потом её сделали за 3 дня — здесь будет 63 дня. Реальное «время в работе» — только 3 из них. К сожалению, отделить «время лежания» от «времени работы» пока нельзя.',
'',
'2. Период фильтрует по дате закрытия, не создания.',
'Задача создана в январе, закрыта в апреле — попадёт в апрельский период. Её время = вся её жизнь (95 дней), а не «время за апрель».',
'',
'3. Используется медиана, а не среднее.',
'Один задавненный тикет, который наконец-то закрыли, не сломает показатель. Медиана говорит честно: «половина задач закрывается быстрее».',
'',
'4. Не сравнивайте напрямую разные по сути проекты.',
'Маркетинг с короткими постами и разработка с крупными фичами имеют разные «нормальные» значения. Сравнивайте проект сам с собой во времени, а не с соседями по списку.',
]),
en: join([
'What it shows:',
"For each project — the typical time a task spends from creation to completion. We use the median: \"half of this project's tasks close faster than X days\".",
'',
'Which tasks are counted:',
'Only closed tasks whose completion date falls within the selected period. Projects with no completions in the period are hidden.',
'',
'How to read it:',
"You can see which projects move faster and which slower. If one project is 23× slower than the rest, it's worth talking to its PM — there may be blockers, oversized tasks, or a sagging process.",
'',
"⚠ Don't panic over big numbers:",
'',
'1. We count the full task life, not just time in active work.',
"If a task sat in the backlog for 2 months and was then done in 3 days, it counts as 63 days. The actual \"in-progress\" time was only 3 days. We can't separate \"waiting\" from \"working\" yet.",
'',
'2. The period filters by completion date, not creation.',
'A task created in January and closed in April lands in the April period. Its time = its whole life (95 days), not "time during April".',
'',
'3. We use the median, not the mean.',
"A long-forgotten ticket that finally closed won't break the metric. The median says honestly: \"half of the tasks close faster\".",
'',
"4. Don't directly compare projects of different nature.",
'A marketing project with short posts and a dev project with large features have different normal values. Compare a project against itself over time, not against its neighbors in the list.',
]),
},
},
datasets: {
median: { ru: 'Медиана cycle time', en: 'Median cycle time' },
},
xAxisLabel: { ru: 'Дней', en: 'Days' },
},
// ===== Usage =====
'chart.status_distribution': {
title: { ru: 'Распределение по статусам', en: 'Status distribution' },
description: {
ru: 'Открытые задачи в колонках канбана',
en: 'Open tasks across kanban columns',
},
help: {
summary: {
ru: 'Доли открытых задач в каждой колонке канбана выбранного проекта',
en: 'Share of open tasks in each kanban column of the selected project',
},
details: {
ru: 'Моментальный снимок «где сейчас концентрация работы». Перекос в одну колонку (например, «In Review») = процесс застрял там, нужно разблокировать. Требует выбора проекта, потому что колонки канбана у каждого проекта свои.',
en: 'A snapshot of "where the work currently sits". A heavy skew into one column (e.g. "In Review") means the process is stuck there and needs unblocking. Requires selecting a project because each project has its own kanban columns.',
},
},
datasets: {
count: { ru: 'Задач', en: 'Tasks' },
},
},
'chart.active_projects': {
title: { ru: 'Активные vs мёртвые проекты', en: 'Active vs dead projects' },
description: { ru: 'По активности за 14 / 30 дней', en: 'By activity in last 14 / 30 days' },
help: {
summary: {
ru: 'Сколько проектов активны, затухают или мертвы по последней активности',
en: 'How many projects are active, fading, or dead by recent activity',
},
details: {
ru: join([
'Что показывает:',
'Все ваши проекты разбиты на 4 группы по тому, когда в них последний раз что-то делали:',
'• Активен — были изменения за последние 14 дней',
'• Затухает — последние правки 14-30 дней назад',
'• Мёртв — никакой активности более 30 дней',
'• Без задач — проект создан, но задач в нём нет',
'',
'Зачем смотреть:',
'Мёртвые и пустые проекты захламляют боковое меню — их можно архивировать, чтобы было видно только живое. Затухающие — повод проверить, всё ли в порядке (закрыли тему или забыли).',
'',
'Drill-down:',
'Кликните по столбцу или сектору, чтобы посмотреть открытые задачи в проектах этой категории. Особенно полезно для «мёртвых» — увидите, что лежит в заброшенных проектах.',
]),
en: join([
'What it shows:',
'All your projects split into 4 groups based on when something was last done in them:',
'• Active — there were edits in the last 14 days',
'• Fading — last edits 14-30 days ago',
'• Dead — no activity for over 30 days',
'• Empty — project exists but has no tasks',
'',
'Why look at it:',
'Dead and empty projects clutter the sidebar — archive them to keep only the live ones in view. Fading projects are worth a check — finished or forgotten.',
'',
'Drill-down:',
"Click a bar or sector to see open tasks in projects of that category. Especially useful for \"dead\" — see what's lying in abandoned projects.",
]),
},
},
datasets: {
count: { ru: 'Проектов', en: 'Projects' },
},
labels: {
active: { ru: 'Активен', en: 'Active' },
fading: { ru: 'Затухает', en: 'Fading' },
dead: { ru: 'Мёртв', en: 'Dead' },
empty: { ru: 'Без задач', en: 'Empty' },
},
yAxisLabel: { ru: 'Проектов', en: 'Projects' },
},
// ===== Financial =====
'chart.income_expense_month': {
title: { ru: 'Доходы и расходы по месяцам', en: 'Income and expense per month' },
description: { ru: 'Суммы завершённых финансовых задач', en: 'Amounts of completed financial tasks' },
help: {
summary: {
ru: 'Суммы завершённых финансовых задач, сгруппированные по месяцам',
en: 'Completed financial task amounts grouped by month',
},
details: {
ru: 'Требует заполнения полей Amount и Transaction Type на задачах. Используется командами, ведущими лёгкий финансовый трекинг в TaskView (частый кейс у small business). Для достоверности проверьте KPI «Заполнено amount» — при низком покрытии картина неполная.',
en: 'Requires the Amount and Transaction Type fields to be filled on tasks. Used by teams running lightweight financial tracking inside TaskView (common small-business case). Cross-check with the "Amount coverage" KPI — a low coverage means the picture is incomplete.',
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
yAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
'chart.income_expense_per_project': {
title: { ru: 'Доходы и расходы по проектам', en: 'Income and expense per project' },
description: {
ru: 'Сколько каждый проект принёс и сколько потратил',
en: 'How much each project earned and spent',
},
help: {
summary: {
ru: 'Доходы и расходы каждого проекта рядом, чтобы сравнить напрямую',
en: 'Income and expense for each project side-by-side for direct comparison',
},
details: {
ru: join([
'Что показывает:',
'Для каждого проекта — два столбца рядом: зелёный (доходы) и красный (расходы). Берутся суммы из задач, где у вас отмечена сумма и тип транзакции.',
'',
'Чем отличается от «Топ проектов по сумме»:',
'Там показан общий оборот — высота столбца = доходы + расходы вместе. Здесь — сравнение двух величин напрямую: видно, где проект зарабатывает больше, чем тратит, а где наоборот.',
'',
'Как читать:',
'• Зелёный выше красного → проект прибыльный',
'• Красный выше зелёного → проект пока в минус',
'• Оба маленькие → слабая активность или вы редко заполняете финансовые поля',
'',
'Сортировка — по чистой прибыли по убыванию: прибыльные проекты сверху.',
'',
'Что нужно для попадания в график:',
'У задачи должна быть указана сумма и тип (доход/расход). Если вы не пользуетесь финансовыми полями TaskView — этот график будет пустым. Чтобы понять качество данных, смотрите карточку «Заполнено amount».',
'',
'Что НЕ включено:',
'• Задачи без указанной суммы',
'• Задачи без типа транзакции',
'• Реальная прибыль после налогов и комиссий — TaskView показывает только то, что вы ввели сами',
]),
en: join([
'What it shows:',
"For each project — two side-by-side bars: green (income) and red (expense). The numbers come from tasks where you've filled in an amount and transaction type.",
'',
'Difference from "Top projects by amount":',
'That chart shows total turnover — bar height = income + expense combined. This one compares the two values directly: you can see which project earns more than it spends and vice versa.',
'',
'How to read it:',
'• Green taller than red → project is profitable',
'• Red taller than green → project is in the red so far',
'• Both small → weak activity or you rarely fill in financial fields',
'',
'Sorted by net profit descending: profitable projects on top.',
'',
"What's needed to appear on the chart:",
"A task needs both an amount and a type (income/expense). If you don't use TaskView's financial fields, this chart will be empty. To gauge data quality, check the \"Amount coverage\" card.",
'',
'What is NOT included:',
'• Tasks without an amount',
'• Tasks without a transaction type',
'• Real profit after taxes and fees — TaskView only shows what you enter yourself',
]),
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
yAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
'chart.top_projects_by_amount': {
title: { ru: 'Топ проектов по сумме', en: 'Top projects by amount' },
description: { ru: 'Суммарный доход и расход в каждом проекте', en: 'Total income and expense per project' },
help: {
summary: {
ru: 'Проекты, отсортированные по суммарному финансовому обороту',
en: 'Projects ranked by total financial turnover',
},
details: {
ru: 'Где крутятся деньги. Income + Expense как стек показывает полный оборот, а не только прибыль — так видно и затратные проекты, а не только прибыльные. Для сравнения чистой прибыли смотрите разницу сегментов.',
en: 'Shows where the money flows. Stacking income and expense reveals total turnover, not just profit — so expensive projects are visible, not only profitable ones. To compare net profit, eyeball the segment gap.',
},
},
datasets: {
income: { ru: 'Доходы', en: 'Income' },
expense: { ru: 'Расходы', en: 'Expense' },
},
xAxisLabel: { ru: 'Сумма', en: 'Amount' },
},
} satisfies Record<string, SectionLocale>
export type SectionLocaleId = keyof typeof sectionLocales
@@ -0,0 +1,68 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class PriorityMixOverTimeSection implements SectionBuilder {
readonly id = 'chart.priority_mix'
readonly group = 'productivity' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const bucket = this.pickBucket(ctx.range.from, ctx.range.to)
const rows = await ctx.repository.fetchPriorityMix(ctx.accessibleGoalIds, ctx.range, bucket)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.bucket),
labelKind: 'date',
datasets: [
{ id: 'high', label: loc.datasets!.high, values: rows.map(r => Number(r.high)), colorToken: 'danger', stack: 'priority' },
{ id: 'medium', label: loc.datasets!.medium, values: rows.map(r => Number(r.medium)), colorToken: 'warning', stack: 'priority' },
{ id: 'low', label: loc.datasets!.low, values: rows.map(r => Number(r.low)), colorToken: 'info', stack: 'priority' },
{ id: 'none', label: loc.datasets!.none, values: rows.map(r => Number(r.none)), colorToken: 'neutral', stack: 'priority' },
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private pickBucket(from: Date, to: Date): 'day' | 'week' | 'month' {
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)
if (days <= 14) return 'day'
if (days <= 120) return 'week'
return 'month'
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,82 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class ThroughputSection implements SectionBuilder {
readonly id = 'chart.throughput'
readonly group = 'productivity' as const
readonly allowedChartTypes = ['area', 'line', 'bar'] as const
readonly defaultChartType = 'area' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const bucket = this.pickBucket(ctx.range.from, ctx.range.to)
const rows = await ctx.repository.fetchThroughput(ctx.accessibleGoalIds, ctx.range, bucket)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.bucket),
labelKind: 'date',
datasets: [
{
id: 'created',
label: loc.datasets!.created,
values: rows.map(r => Number(r.created)),
colorToken: 'info',
},
{
id: 'completed',
label: loc.datasets!.completed,
values: rows.map(r => Number(r.completed)),
colorToken: 'success',
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private pickBucket(from: Date, to: Date): 'day' | 'week' | 'month' {
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)
if (days <= 14) return 'day'
if (days <= 120) return 'week'
return 'month'
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'date',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,73 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimeHistogramSection implements SectionBuilder {
readonly id = 'chart.cycle_time_histogram'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const row = await ctx.repository.fetchCycleTimeHistogram(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const labels = ['<1д', '13д', '37д', '714д', '1430д', '30+д']
const values = [
Number(row.bucket_0_1),
Number(row.bucket_1_3),
Number(row.bucket_3_7),
Number(row.bucket_7_14),
Number(row.bucket_14_30),
Number(row.bucket_30_plus),
]
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels,
labelKind: 'category',
datasets: [
{
id: 'tasks',
label: loc.datasets!.tasks,
values,
colorToken: 'primary',
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,63 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class CycleTimePerProjectSection implements SectionBuilder {
readonly id = 'chart.cycle_time_per_project'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchCycleTimePerProject(ctx.accessibleGoalIds, ctx.range)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'median',
label: loc.datasets!.median,
values: rows.map(r => r.median_days === null || r.median_days === undefined ? 0 : Math.round(Number(r.median_days) * 10) / 10),
colorToken: 'info',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'days',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,93 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class OverdueByAgeSection implements SectionBuilder {
readonly id = 'chart.overdue_by_age'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const row = await ctx.repository.fetchOverdueByAge(ctx.accessibleGoalIds)
const loc = this.loc
const bucketKeys = ['bucket_1_3', 'bucket_4_7', 'bucket_8_14', 'bucket_15_plus'] as const
const labelTexts = bucketKeys.map(k => loc.labels![k])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: labelTexts.map(l => l.ru),
labelTexts,
labelKind: 'category',
datasets: [
{
id: 'overdue',
label: loc.datasets!.overdue,
values: [
Number(row.bucket_1_3),
Number(row.bucket_4_7),
Number(row.bucket_8_14),
Number(row.bucket_15_plus),
],
colorToken: 'danger',
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
const ranges: Array<[number, number | null]> = [
[1, 3],
[4, 7],
[8, 14],
[15, null],
]
const range = ranges[arg.index]
if (!range) return []
const [minDays, maxDays] = range
return ctx.repository.fetchOverdueTasksInRange(ctx.accessibleGoalIds, minDays, maxDays)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'category',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class StaleTasksSection implements SectionBuilder {
readonly id = 'chart.stale_tasks'
readonly group = 'quality' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchStaleTasks(ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'stale',
label: loc.datasets!.stale,
values: rows.map(r => Number(r.stale)),
colorToken: 'warning',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const goalIds = arg.meta?.goalIds ?? []
const goalId = goalIds[arg.index]
if (!goalId || !ctx.accessibleGoalIds.includes(goalId)) return []
return ctx.repository.fetchStaleTasksInGoal(goalId, ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,130 @@
// SQL row types returned by each section's query.
// Field names match the SELECT aliases verbatim (snake_case where applicable).
// ===== KPI =====
export type CreatedTasksKpiRow = { count: number }
export type CompletedTasksKpiRow = { count: number }
export type OverdueKpiRow = { count: number }
export type CycleTimeKpiRow = { median: number | null }
export type TotalIncomeKpiRow = { total: number }
export type TotalExpenseKpiRow = { total: number }
export type PlannedIncomeKpiRow = { total: number }
export type PlannedExpenseKpiRow = { total: number }
export type NetProfitKpiRow = { income: number; expense: number }
export type AmountCoverageKpiRow = { total: number; with_amount: number }
// ===== Productivity =====
export type ThroughputSectionRow = {
bucket: string
created: number
completed: number
}
export type PriorityMixOverTimeSectionRow = {
bucket: string
high: number
medium: number
low: number
none: number
}
// ===== Workload =====
export type WorkloadByAssigneeSectionRow = {
user_id: number | null
user_name: string | null
high: number
medium: number
low: number
no_priority: number
}
export type BlockedByDependenciesSectionRow = {
goal_id: number
goal_name: string
blocked: number
}
export type TimeInKanbanStatusSectionRow = {
status_id: number | null
status_name: string | null
avg_days: number | null
task_count: number
}
export type AgingOpenTasksSectionRow = {
user_id: number | null
user_name: string | null
avg_age: number | null
max_age: number | null
task_count: number
}
// ===== Quality =====
export type OverdueByAgeSectionRow = {
bucket_1_3: number
bucket_4_7: number
bucket_8_14: number
bucket_15_plus: number
}
export type CycleTimeHistogramSectionRow = {
bucket_0_1: number
bucket_1_3: number
bucket_3_7: number
bucket_7_14: number
bucket_14_30: number
bucket_30_plus: number
}
export type StaleTasksSectionRow = {
goal_id: number
goal_name: string
stale: number
}
export type CycleTimePerProjectSectionRow = {
goal_id: number
goal_name: string
median_days: number | null
completed: number
}
// ===== Usage =====
export type StatusDistributionSectionRow = {
status_id: number | null
status_name: string | null
count: number
}
export type ActiveProjectsSectionRow = {
status_key: 'active' | 'fading' | 'dead' | 'empty'
count: number
}
// ===== Financial =====
export type IncomeExpenseMonthSectionRow = {
month: string
income: number
expense: number
}
export type IncomeExpensePerProjectSectionRow = {
goal_id: number
goal_name: string
income: number
expense: number
net: number
}
export type TopProjectsByAmountSectionRow = {
goal_id: number
goal_name: string
income: number
expense: number
}
@@ -0,0 +1,89 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
type StatusKey = 'active' | 'fading' | 'dead' | 'empty'
const COLOR_BY_STATUS: Record<StatusKey, 'success' | 'warning' | 'danger' | 'neutral'> = {
active: 'success',
fading: 'warning',
dead: 'danger',
empty: 'neutral',
}
export class ActiveProjectsSection implements SectionBuilder {
readonly id = 'chart.active_projects'
readonly group = 'usage' as const
readonly allowedChartTypes = ['bar', 'donut'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 600
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchActiveProjects(ctx.accessibleGoalIds)
const loc = this.loc
const labelTexts = rows.map(r => loc.labels![r.status_key])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: labelTexts.map(l => l.ru),
labelTexts,
labelKind: 'category',
datasets: [
{
id: 'count',
label: loc.datasets!.count,
values: rows.map(r => Number(r.count)),
meta: { statusKeys: rows.map(r => r.status_key) },
},
],
unit: 'count',
yAxisLabel: loc.yAxisLabel,
}
const firstRow = rows[0]
if (firstRow) {
payload.datasets[0].colorToken = COLOR_BY_STATUS[firstRow.status_key] ?? 'primary'
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
if (ctx.accessibleGoalIds.length === 0) return []
const statusKeys = arg.meta?.statusKeys ?? []
const statusKey = statusKeys[arg.index]
if (!statusKey || statusKey === 'empty') return []
return ctx.repository.fetchOpenTasksInActiveProjects(ctx.accessibleGoalIds, statusKey)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,64 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class StatusDistributionSection implements SectionBuilder {
readonly id = 'chart.status_distribution'
readonly group = 'usage' as const
readonly allowedChartTypes = ['donut', 'bar'] as const
readonly defaultChartType = 'donut' as const
readonly requiresGoalScope = true
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.scope.kind !== 'project') return null
const goalId = ctx.scope.goalId
if (!ctx.accessibleGoalIds.includes(goalId)) return null
const rows = await ctx.repository.fetchStatusDistribution(goalId, ctx.accessibleGoalIds)
const loc = this.loc
const topN = 6
let labels: string[]
let values: number[]
if (rows.length > topN) {
const top = rows.slice(0, topN - 1)
const rest = rows.slice(topN - 1)
labels = [...top.map(r => r.status_name ?? 'No status'), 'Другое']
values = [...top.map(r => Number(r.count)), rest.reduce((sum, r) => sum + Number(r.count), 0)]
} else {
labels = rows.map(r => r.status_name ?? 'No status')
values = rows.map(r => Number(r.count))
}
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels,
labelKind: 'category',
datasets: [
{
id: 'count',
label: loc.datasets!.count,
values,
},
],
unit: 'count',
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,80 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class AgingOpenTasksSection implements SectionBuilder {
readonly id = 'chart.aging_open_tasks'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar', 'line', 'area'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchAgingOpenTasks(ctx.accessibleGoalIds)
const loc = this.loc
const userIds = rows.map(r => r.user_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.user_name ?? 'Unknown'),
labelKind: 'category',
datasets: [
{
id: 'avg_age',
label: loc.datasets!.avg_age,
values: rows.map(r => r.avg_age === null || r.avg_age === undefined ? 0 : Math.round(Number(r.avg_age) * 10) / 10),
colorToken: 'warning',
meta: { userIds },
},
{
id: 'max_age',
label: loc.datasets!.max_age,
values: rows.map(r => r.max_age === null || r.max_age === undefined ? 0 : Math.round(Number(r.max_age) * 10) / 10),
colorToken: 'danger',
meta: { userIds },
},
],
unit: 'days',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const userIds = arg.meta?.userIds ?? []
const userId = userIds[arg.index]
if (!userId || ctx.accessibleGoalIds.length === 0) return []
return ctx.repository.fetchOpenTasksAssignedTo(ctx.accessibleGoalIds, userId)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'days' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class BlockedByDependenciesSection implements SectionBuilder {
readonly id = 'chart.blocked_by_deps'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchBlockedByDeps(ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.goal_name),
labelKind: 'category',
datasets: [
{
id: 'blocked',
label: loc.datasets!.blocked,
values: rows.map(r => Number(r.blocked)),
colorToken: 'danger',
meta: { goalIds: rows.map(r => r.goal_id) },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const goalIds = arg.meta?.goalIds ?? []
const goalId = goalIds[arg.index]
if (!goalId || !ctx.accessibleGoalIds.includes(goalId)) return []
return ctx.repository.fetchBlockedTasksInGoal(goalId, ctx.accessibleGoalIds)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: { kind: 'series', labels: [], labelKind: 'category', datasets: [], unit: 'count' },
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,53 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class TimeInKanbanStatusSection implements SectionBuilder {
readonly id = 'chart.time_in_kanban_status'
readonly group = 'workload' as const
readonly allowedChartTypes = ['bar'] as const
readonly defaultChartType = 'bar' as const
readonly requiresGoalScope = true
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.scope.kind !== 'project') return null
const goalId = ctx.scope.goalId
if (!ctx.accessibleGoalIds.includes(goalId)) return null
const rows = await ctx.repository.fetchTimeInKanbanStatus(goalId, ctx.accessibleGoalIds)
const loc = this.loc
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.status_name ?? 'Без статуса'),
labelKind: 'category',
datasets: [
{
id: 'avg_days',
label: loc.datasets!.avg_days,
values: rows.map(r => r.avg_days === null || r.avg_days === undefined ? 0 : Math.round(Number(r.avg_days) * 10) / 10),
colorToken: 'info',
},
],
unit: 'days',
yAxisLabel: loc.yAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
}
}
}
@@ -0,0 +1,117 @@
import type { AnalyticsSection, AnalyticsSeriesPayload } from 'taskview-api'
import type { BuilderContext, SectionDrillDownArg, DrillDownTaskRow, SectionBuilder } from '../../types'
import { sectionLocales } from '../locales'
export class WorkloadByAssigneeSection implements SectionBuilder {
readonly id = 'chart.workload_by_assignee'
readonly group = 'workload' as const
readonly allowedChartTypes = ['stackedBar', 'stackedArea', 'bar', 'line', 'area'] as const
readonly defaultChartType = 'stackedBar' as const
readonly cacheTtlSec = 300
private get loc() {
return sectionLocales[this.id]
}
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
if (ctx.accessibleGoalIds.length === 0) return this.empty()
const rows = await ctx.repository.fetchWorkloadByAssignee(ctx.accessibleGoalIds)
const loc = this.loc
const userIds = rows.map(r => r.user_id)
const payload: AnalyticsSeriesPayload = {
kind: 'series',
labels: rows.map(r => r.user_name ?? 'Unknown'),
labelKind: 'category',
datasets: [
{
id: 'high',
label: loc.datasets!.high,
values: rows.map(r => Number(r.high)),
colorToken: 'danger',
stack: 'priority',
meta: { userIds },
},
{
id: 'medium',
label: loc.datasets!.medium,
values: rows.map(r => Number(r.medium)),
colorToken: 'warning',
stack: 'priority',
meta: { userIds },
},
{
id: 'low',
label: loc.datasets!.low,
values: rows.map(r => Number(r.low)),
colorToken: 'info',
stack: 'priority',
meta: { userIds },
},
{
id: 'no_priority',
label: loc.datasets!.no_priority,
values: rows.map(r => Number(r.no_priority)),
colorToken: 'neutral',
stack: 'priority',
meta: { userIds },
},
],
unit: 'count',
xAxisLabel: loc.xAxisLabel,
}
return {
id: this.id,
title: loc.title,
description: loc.description,
help: loc.help,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload,
generatedAt: new Date().toISOString(),
drillDown: { kind: 'tasks' },
}
}
async drillDown(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]> {
const userIds = arg.meta?.userIds ?? []
const userId = userIds[arg.index]
if (!userId || ctx.accessibleGoalIds.length === 0) return []
const priorityByDataset: Record<string, number | 'null'> = {
high: 3,
medium: 2,
low: 1,
no_priority: 'null',
}
const priorityFilter = priorityByDataset[arg.datasetId]
if (priorityFilter === undefined) return []
return ctx.repository.fetchOpenTasksAssignedWithPriority(
ctx.accessibleGoalIds,
userId,
priorityFilter,
)
}
private empty(): AnalyticsSection {
return {
id: this.id,
title: this.loc.title,
group: this.group,
allowedChartTypes: [...this.allowedChartTypes],
defaultChartType: this.defaultChartType,
payload: {
kind: 'series',
labels: [],
labelKind: 'category',
datasets: [],
unit: 'count',
},
generatedAt: new Date().toISOString(),
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import { type } from 'arktype'
import type {
AnalyticsChartType,
AnalyticsPeriod,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionGroup,
} from 'taskview-api'
import type { AppUser } from '../../core/AppUser'
import type { AnalyticsRepository } from './AnalyticsRepository'
const positiveIntFromQuery = type('string | number')
.pipe((v) => Number(v))
.narrow((n, ctx) => Number.isInteger(n) && n > 0 ? true : ctx.mustBe('a positive integer'))
const nonNegativeIntFromQuery = type('string | number')
.pipe((v) => Number(v))
.narrow((n, ctx) => Number.isInteger(n) && n >= 0 ? true : ctx.mustBe('a non-negative integer'))
export const AnalyticsFetchSectionsArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'from?': 'string',
'to?': 'string',
'sections?': 'string',
})
export const AnalyticsDrillDownArkType = type({
scope: "'org' | 'project'",
organizationId: positiveIntFromQuery,
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
'goalId?': positiveIntFromQuery,
'from?': 'string',
'to?': 'string',
'bucket?': 'string',
'datasetId?': 'string',
'meta?': 'string',
'index?': nonNegativeIntFromQuery,
})
export const DrillDownMetaArkType = type({
'goalIds?': 'number[]',
'userIds?': 'number[]',
'statusKeys?': "('active' | 'fading' | 'dead' | 'empty')[]",
})
export type DrillDownMeta = typeof DrillDownMetaArkType.infer
export type AnalyticsRange = {
from: Date
to: Date
}
export type BuilderContext = {
appUser: AppUser
scope: AnalyticsScope
period: AnalyticsPeriod
range: AnalyticsRange
accessibleGoalIds: number[]
repository: AnalyticsRepository
}
export type SectionDrillDownArg = {
bucket: string
index: number
datasetId: string
meta?: DrillDownMeta
}
export type DrillDownTaskRow = {
id: number
description: string
goalId: number
goalName: string
complete: boolean
priorityId: number | null
endDate: string | null
date_creation: string
date_complete: string | null
}
export interface SectionBuilder {
readonly id: string
readonly group: AnalyticsSectionGroup
readonly allowedChartTypes: readonly AnalyticsChartType[]
readonly defaultChartType: AnalyticsChartType | null
readonly requiresGoalScope?: boolean
readonly cacheTtlSec?: number
build(ctx: BuilderContext): Promise<AnalyticsSection | null>
drillDown?(ctx: BuilderContext, arg: SectionDrillDownArg): Promise<DrillDownTaskRow[]>
}
export type AnalyticsArgBuildSections = {
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
sectionIds?: string[]
}
export type AnalyticsArgDrillDown = {
sectionId: string
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
range: AnalyticsRange
arg: SectionDrillDownArg
}
@@ -1,11 +1,18 @@
import type { AppUser } from '../../core/AppUser'
import { isNotNullable } from '../../utils/helpers'
import { OrganizationRepository } from './OrganizationRepository'
import type { OrganizationArgCreate, OrganizationArgUpdate } from './types'
import {
OrgRoles,
type OrganizationArgCreate,
type OrganizationArgUpdate,
} from './types'
type OrgMember = Awaited<ReturnType<OrganizationRepository['getMemberByEmail']>>
export class OrganizationManager {
public readonly repository: OrganizationRepository
private readonly user: AppUser
private readonly memberCache: Map<number, OrgMember> = new Map()
constructor(user: AppUser) {
this.user = user
@@ -40,9 +47,10 @@ export class OrganizationManager {
async update(data: OrganizationArgUpdate) {
if (data.slug) {
data = { ...data, slug: data.slug.toLowerCase() }
const existing = await this.repository.findBySlug(data.slug)
const slug = data.slug.toLowerCase()
const existing = await this.repository.findBySlug(slug)
if (existing && existing.id !== data.organizationId) return false
data = { ...data, slug }
}
return await this.repository.update(data)
@@ -115,9 +123,20 @@ export class OrganizationManager {
}
async getCurrentUserMember(orgId: number) {
if (this.memberCache.has(orgId)) {
return this.memberCache.get(orgId)!
}
const email = this.getUserEmail()
if (!email) return false
return await this.repository.getMemberByEmail(orgId, email)
const member = await this.repository.getMemberByEmail(orgId, email)
this.memberCache.set(orgId, member)
return member
}
async isCurrentUserOrgOwner(orgId: number): Promise<boolean> {
const member = await this.getCurrentUserMember(orgId)
if (!member) return false
return member.role === OrgRoles.OWNER
}
private generateSlug(): string {
+2
View File
@@ -126,6 +126,8 @@ export const GoalPermissions = {
INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage',
INTEGRATIONS_CAN_VIEW: 'integrations_can_view',
ANALYTICS_CAN_VIEW: 'analytics_can_view',
} as const;
export type PermissionsEntityType =
+11
View File
@@ -60,6 +60,17 @@ export function parseDeviceName(userAgent: string | undefined): string {
return parts.length > 0 ? parts.join(', ') : 'Unknown'
}
export function parsePositiveInt(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isInteger(value) && value > 0 ? value : null
}
if (typeof value === 'string' && /^\d+$/.test(value)) {
const n = Number(value)
return Number.isSafeInteger(n) && n > 0 ? n : null
}
return null
}
export const chunk = <T>(array: T[], size: number): T[][] => {
if (!Array.isArray(array)) {
throw new TypeError('Expected array');
+46
View File
@@ -138,6 +138,9 @@ importers:
semver:
specifier: ^7.6.3
version: 7.7.3
taskview-api:
specifier: workspace:^
version: link:../taskview-packages/taskview-api
taskview-db-schemas:
specifier: workspace:^
version: link:../taskview-packages/taskview-db-schemas
@@ -415,6 +418,12 @@ importers:
centrifuge:
specifier: ^5.5.3
version: 5.5.3
chart.js:
specifier: ^4.5.1
version: 4.5.1
chartjs-plugin-annotation:
specifier: ^3.1.0
version: 3.1.0(chart.js@4.5.1)
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -439,6 +448,9 @@ importers:
vue:
specifier: ^3.5.27
version: 3.5.27(typescript@5.9.3)
vue-chartjs:
specifier: ^5.3.3
version: 5.3.3(chart.js@4.5.1)(vue@3.5.27(typescript@5.9.3))
vue-i18n:
specifier: ^11.2.8
version: 11.2.8(vue@3.5.27(typescript@5.9.3))
@@ -2124,6 +2136,9 @@ packages:
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
@@ -3804,6 +3819,7 @@ packages:
'@xmldom/xmldom@0.9.9':
resolution: {integrity: sha512-qycIHAucxy/LXAYIjmLmtQ8q9GPnMbnjG1KXhWm9o5sCr6pOYDATkMPiTNa6/v8eELyqOQ2FsEqeoFYmgv/gJg==}
engines: {node: '>=14.6'}
deprecated: this version has critical issues, please update to the latest version
JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
@@ -4255,6 +4271,15 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
chartjs-plugin-annotation@3.1.0:
resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==}
peerDependencies:
chart.js: '>=4.0.0'
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -8498,6 +8523,12 @@ packages:
vt-pbf@3.1.3:
resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==}
vue-chartjs@5.3.3:
resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==}
peerDependencies:
chart.js: ^4.1.1
vue: ^3.0.0-0 || ^2.7.0
vue-component-type-helpers@3.2.5:
resolution: {integrity: sha512-tkvNr+bU8+xD/onAThIe7CHFvOJ/BO6XCOrxMzeytJq40nTfpGDJuVjyCM8ccGZKfAbGk2YfuZyDMXM56qheZQ==}
@@ -10672,6 +10703,8 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
'@kurkle/color@0.3.4': {}
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
@@ -13266,6 +13299,14 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
chartjs-plugin-annotation@3.1.0(chart.js@4.5.1):
dependencies:
chart.js: 4.5.1
check-error@2.1.3: {}
chevrotain@7.1.1:
@@ -17819,6 +17860,11 @@ snapshots:
'@mapbox/vector-tile': 1.3.1
pbf: 3.3.0
vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.27(typescript@5.9.3)):
dependencies:
chart.js: 4.5.1
vue: 3.5.27(typescript@5.9.3)
vue-component-type-helpers@3.2.5: {}
vue-demi@0.14.10(vue@3.5.27(typescript@5.9.3)):
@@ -0,0 +1,563 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, initApi } from './init-api'
describe('Analytics access control', () => {
let user1Api: TvApi
let user2Api: TvApi
let user2Email: string
let user1AuthHeader: string
let deleteAllGoals: () => Promise<void>
let analyticsPermissionId: number
const createdOrgIds: number[] = []
const createdApiTokenIds: number[] = []
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
user2Api = init.$tvApiForSecondUser
user2Email = init.user2Email
deleteAllGoals = init.deleteAllGoals
user1AuthHeader = user1Api['$axios'].defaults.headers.common['Authorization'] as string
const allPermissions = await user1Api.collaboration.fetchAllPermissions()
const found = allPermissions.find(p => p.name === TvPermissions.ANALYTICS_CAN_VIEW)
if (!found) {
throw new Error(
'Permission "analytics_can_view" is not in DB. '
+ 'Run migration 1.46.0/0.add-analytics-permission.sql.',
)
}
analyticsPermissionId = found.id
})
afterAll(async () => {
await deleteAllGoals()
for (const id of createdApiTokenIds) {
await user1Api.apiTokens.delete(id).catch(() => {})
}
for (const orgId of createdOrgIds) {
await user1Api.organizations.delete(orgId).catch(() => {})
await user2Api.organizations.delete(orgId).catch(() => {})
}
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
const actual = e.response?.status
expect(actual, `Expected ${status}, got ${actual}`).toBe(status)
}
}
/**
* Create a fresh org owned by user1, with two projects.
* user2 is added as a member (no analytics permission yet).
*/
async function setupSharedOrg(label: string) {
const ts = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const org = await user1Api.organizations.create({ name: `Analytics ${label} ${ts}` })
createdOrgIds.push(org.id)
const projectA = await user1Api.goals.createGoal({
name: `Project A ${ts}`,
organizationId: org.id,
})
const projectB = await user1Api.goals.createGoal({
name: `Project B ${ts}`,
organizationId: org.id,
})
if (!projectA || !projectB) throw new Error('Failed to create project goals')
await user1Api.organizations.addMember({
organizationId: org.id,
email: user2Email,
role: 'member',
})
return { orgId: org.id, projectA, projectB }
}
/**
* Grant `analytics_can_view` permission on `goalId` to user2.
* Verifies the toggle actually turned the permission ON (defends against
* future regression if `createRoleForGoal` ever ships with default permissions).
*/
async function grantAnalyticsViewToUser2(goalId: number) {
const collab = await user1Api.collaboration.inviteUserToGoal({
email: user2Email,
goalId,
})
if (!collab) throw new Error('Failed to invite user2 as collaborator')
const role = await user1Api.collaboration.createRoleForGoal({
goalId,
roleName: `Analytics Viewer ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
const toggleResult = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: analyticsPermissionId,
})
if (!toggleResult || toggleResult.add !== true) {
throw new Error(
`Expected toggleRolePermission to add the permission (add=true), got ${JSON.stringify(toggleResult)}. `
+ 'This likely means createRoleForGoal now ships with default permissions; tests need to be updated.',
)
}
await user1Api.collaboration.toggleUserRoles({
goalId,
userId: collab.id,
roles: [role.id],
})
}
describe('Authentication (HTTP 401 / 403)', () => {
it('anonymous request → 401', async () => {
const noAuth = axios.create({ baseURL: API_URL })
await expectHttpStatus(
noAuth.get('/module/analytics/sections', {
params: { scope: 'org', organizationId: 1, period: '30d' },
}),
401,
)
})
it('API-token request → 403 (RejectApiTokenAuth)', async () => {
const created = await user1Api.apiTokens.create({ name: `Analytics test ${Date.now()}` })
if (!created?.token) throw new Error('Failed to create API token')
createdApiTokenIds.push(created.item.id)
const tokenAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
})
// Need a real organizationId for the token user; use any (middleware order
// checks RejectApiTokenAuth before org membership)
await expectHttpStatus(
tokenAxios.get('/module/analytics/sections', {
params: { scope: 'org', organizationId: 1, period: '30d' },
}),
403,
)
})
})
describe('Validation (HTTP 400)', () => {
it('rejects request without organizationId', async () => {
await expectHttpStatus(
// @ts-expect-error -- intentionally missing organizationId
user1Api.analytics.fetchSections({ scope: { kind: 'org' }, period: '30d' }),
400,
)
})
it('rejects scope=project without goalId', async () => {
const { orgId } = await setupSharedOrg('val-no-goal')
await expectHttpStatus(
user1Api.analytics.fetchSections({
// @ts-expect-error -- intentionally missing goalId
scope: { kind: 'project' },
organizationId: orgId,
period: '30d',
}),
400,
)
})
it('rejects unknown period via raw HTTP', async () => {
const { orgId } = await setupSharedOrg('val-bad-period')
await expectHttpStatus(
axios.get(`${API_URL}/module/analytics/sections`, {
headers: { Authorization: user1AuthHeader },
params: { scope: 'org', organizationId: orgId, period: 'foo' },
}),
400,
)
})
})
describe('Org membership (HTTP 403)', () => {
it('non-member of org cannot access analytics', async () => {
const tsOther = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const otherOrg = await user2Api.organizations.create({ name: `User2 Org ${tsOther}` })
createdOrgIds.push(otherOrg.id)
await expectHttpStatus(
user1Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: otherOrg.id,
period: '30d',
}),
403,
)
})
})
describe('Org owner — sees all org projects', () => {
it('owner gets all org goals in availableGoals (scope=org)', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('owner-org')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
})
const goalIds = result.availableGoals.map(g => g.id).sort()
expect(goalIds).toContain(projectA.id)
expect(goalIds).toContain(projectB.id)
})
it('owner of org with no projects → 200 with empty availableGoals', async () => {
const ts = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const emptyOrg = await user1Api.organizations.create({ name: `Empty Org ${ts}` })
createdOrgIds.push(emptyOrg.id)
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: emptyOrg.id,
period: '30d',
})
expect(result.availableGoals).toEqual([])
})
it('owner can fetch project-scoped sections for projectA', async () => {
const { orgId, projectA } = await setupSharedOrg('owner-projA')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
})
expect(result).toBeTruthy()
expect(result.scope).toEqual({ kind: 'project', goalId: projectA.id })
})
it('owner can fetch project-scoped sections for projectB', async () => {
const { orgId, projectB } = await setupSharedOrg('owner-projB')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectB.id },
organizationId: orgId,
period: '30d',
})
expect(result.scope).toEqual({ kind: 'project', goalId: projectB.id })
})
it('respects period filter (smoke for 7d)', async () => {
const { orgId, projectA } = await setupSharedOrg('owner-7d')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '7d',
})
expect(result.period).toBe('7d')
})
it('respects sectionIds filter — returns only requested sections', async () => {
const { orgId, projectA } = await setupSharedOrg('owner-filter')
const result = await user1Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
sections: ['kpi.overdue'],
})
// Either the section is present, or none are (project can be empty),
// but no other sections than the one requested may appear.
const ids = new Set(result.sections.map(s => s.id))
ids.delete('kpi.overdue')
expect(ids.size).toBe(0)
})
})
describe('Member without analytics_can_view — 403 everywhere', () => {
it('member, scope=org → 403 (no accessible goals)', async () => {
const { orgId } = await setupSharedOrg('member-noperm-org')
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
}),
403,
)
})
it('member, scope=project on owner-only project → 403', async () => {
const { orgId, projectA } = await setupSharedOrg('member-noperm-project')
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
})
describe('Member who created their own project — automatic access', () => {
it('member who is owner of a project sees it in analytics without explicit permission', async () => {
// user1 = owner of org. user2 = member. But here we let an admin create
// the project so they become its goal owner without needing collab.
const ts = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const org = await user1Api.organizations.create({ name: `MemberOwner Org ${ts}` })
createdOrgIds.push(org.id)
// Promote user2 to admin so they can create a project
await user1Api.organizations.addMember({
organizationId: org.id,
email: user2Email,
role: 'admin',
})
const ownProject = await user2Api.goals.createGoal({
name: `User2 Owns ${ts}`,
organizationId: org.id,
})
if (!ownProject) throw new Error('Failed to create user2 project')
// user2 owns this project → fetchPermissionsForGoal returns ALL permissions
// for goal owner, including ANALYTICS_CAN_VIEW. So user2 can see analytics
// without an explicit role-based permission grant.
const result = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: ownProject.id },
organizationId: org.id,
period: '30d',
})
expect(result.scope).toEqual({ kind: 'project', goalId: ownProject.id })
const orgScope = await user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: org.id,
period: '30d',
})
const goalIds = orgScope.availableGoals.map(g => g.id)
expect(goalIds).toContain(ownProject.id)
})
})
describe('Member with analytics_can_view on projectA only', () => {
it('member can fetch projectA, but not projectB', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('member-perm-A')
await grantAnalyticsViewToUser2(projectA.id)
const allowed = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
})
expect(allowed.scope).toEqual({ kind: 'project', goalId: projectA.id })
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectB.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
it('member, scope=org → only projectA in availableGoals', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('member-perm-org-scope')
await grantAnalyticsViewToUser2(projectA.id)
const result = await user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
})
const goalIds = result.availableGoals.map(g => g.id)
expect(goalIds).toContain(projectA.id)
expect(goalIds).not.toContain(projectB.id)
})
it('member loses access after being removed from the goal collaboration', async () => {
const { orgId, projectA } = await setupSharedOrg('member-revoke')
await grantAnalyticsViewToUser2(projectA.id)
// Sanity: access works before revoke
const ok = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
})
expect(ok.scope).toEqual({ kind: 'project', goalId: projectA.id })
// Revoke: remove user2 from goal collaboration
const collabUsers = await user1Api.collaboration.fetchUsersForGoal(projectA.id)
const collabUser = collabUsers.find(u => u.email === user2Email)
if (!collabUser) throw new Error('user2 should be a collaborator')
await user1Api.collaboration.deleteUserFromGoal({
id: collabUser.id,
goalId: projectA.id,
})
// Now access should be denied
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
})
describe('Admin treated like member (no automatic org-wide access)', () => {
it('admin without permission → 403 on org scope (no accessible goals)', async () => {
const { orgId } = await setupSharedOrg('admin-noperm')
await user1Api.organizations.updateMemberRole({
organizationId: orgId,
email: user2Email,
role: 'admin',
})
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
}),
403,
)
})
it('admin with permission on projectA only → sees only projectA', async () => {
const { orgId, projectA, projectB } = await setupSharedOrg('admin-perm-A')
await user1Api.organizations.updateMemberRole({
organizationId: orgId,
email: user2Email,
role: 'admin',
})
await grantAnalyticsViewToUser2(projectA.id)
const orgResult = await user2Api.analytics.fetchSections({
scope: { kind: 'org' },
organizationId: orgId,
period: '30d',
})
const goalIds = orgResult.availableGoals.map(g => g.id)
expect(goalIds).toContain(projectA.id)
expect(goalIds).not.toContain(projectB.id)
await expectHttpStatus(
user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectB.id },
organizationId: orgId,
period: '30d',
}),
403,
)
})
})
describe('Drill-down access', () => {
it('drill-down 403 if no permission on the project', async () => {
const { orgId, projectA } = await setupSharedOrg('drill-noperm')
await expectHttpStatus(
user2Api.analytics.fetchDrillDown({
sectionId: 'kpi.overdue',
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
bucket: '',
datasetId: 'kpi',
index: 0,
}),
403,
)
})
it('drill-down 200 with empty tasks if user has permission but no overdue tasks', async () => {
const { orgId, projectA } = await setupSharedOrg('drill-perm')
await grantAnalyticsViewToUser2(projectA.id)
const result = await user2Api.analytics.fetchDrillDown({
sectionId: 'kpi.overdue',
scope: { kind: 'project', goalId: projectA.id },
organizationId: orgId,
period: '30d',
bucket: '',
datasetId: 'kpi',
index: 0,
})
expect(result.sectionId).toBe('kpi.overdue')
expect(Array.isArray(result.tasks)).toBe(true)
})
it('drill-down with cross-org goalId via member returns empty (no data leak)', async () => {
const { orgId: orgAId, projectA: projectAOfOrgA } = await setupSharedOrg('drill-cross')
await grantAnalyticsViewToUser2(projectAOfOrgA.id)
// user2 is owner of a separate orgB with a project there
const tsB = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const orgB = await user2Api.organizations.create({ name: `Cross Org B ${tsB}` })
createdOrgIds.push(orgB.id)
const projectInOrgB = await user2Api.goals.createGoal({
name: `Project in B ${tsB}`,
organizationId: orgB.id,
})
if (!projectInOrgB) throw new Error('Failed to create cross-org project')
// user2 tries drill-down for orgB's project but scoped under orgA.
// Middleware passes because user2 owns the project (has all permissions on it).
// The data layer filters by orgA, so the drill-down must return no tasks
// belonging to projectInOrgB. The response itself is 200, but tasks are scoped.
const result = await user2Api.analytics.fetchDrillDown({
sectionId: 'kpi.overdue',
scope: { kind: 'project', goalId: projectInOrgB.id },
organizationId: orgAId,
period: '30d',
bucket: '',
datasetId: 'kpi',
index: 0,
})
// No task from orgB must appear in the response when querying under orgA
const leakedGoalIds = result.tasks.map(t => t.goalId)
expect(leakedGoalIds).not.toContain(projectInOrgB.id)
})
})
describe('Cross-org isolation (no data leakage)', () => {
it('cross-org goalId returns no foreign-org data; sanity that own org data is visible', async () => {
const { orgId: orgAId, projectA: projectAOfOrgA } = await setupSharedOrg('cross-A')
await grantAnalyticsViewToUser2(projectAOfOrgA.id)
const tsB = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const orgB = await user2Api.organizations.create({ name: `Org B ${tsB}` })
createdOrgIds.push(orgB.id)
const projectInOrgB = await user2Api.goals.createGoal({
name: `Project in B ${tsB}`,
organizationId: orgB.id,
})
if (!projectInOrgB) throw new Error('Failed to create project in orgB')
// user2 is member of orgA AND owner of orgB+projectInOrgB.
// They request analytics with organizationId=orgA but goalId from orgB.
// Backend may return 200 (middleware passes because user has permissions
// on the goal directly via ownership), but the data layer filters by
// organizationId, so cross-org data must NOT appear in the response.
const result = await user2Api.analytics.fetchSections({
scope: { kind: 'project', goalId: projectInOrgB.id },
organizationId: orgAId,
period: '30d',
})
const goalIds = result.availableGoals.map(g => g.id)
// Sanity: orgA data is reachable (user2 has analytics_can_view on projectAOfOrgA)
expect(goalIds).toContain(projectAOfOrgA.id)
// Critical: orgB data must NOT leak through orgA-scoped request
expect(goalIds).not.toContain(projectInOrgB.id)
})
})
})
@@ -0,0 +1,61 @@
import TvApiBase from './base'
import type { AppResponse } from './base.types'
import type {
AnalyticsFetchDrillDownArg,
AnalyticsDrillDownResponse,
AnalyticsFetchSectionsArg,
AnalyticsSectionsResponse,
AnalyticsScope,
} from './analytics.types'
function scopeToParams(scope: AnalyticsScope): Record<string, string> {
switch (scope.kind) {
case 'org':
return { scope: 'org' }
case 'project':
return { scope: 'project', goalId: String(scope.goalId) }
}
}
export default class TvAnalyticsApi extends TvApiBase {
protected moduleUrl = '/module/analytics'
public async fetchSections(arg: AnalyticsFetchSectionsArg, signal?: AbortSignal) {
const params: Record<string, string> = {
...scopeToParams(arg.scope),
organizationId: String(arg.organizationId),
period: arg.period,
}
if (arg.from) params.from = arg.from
if (arg.to) params.to = arg.to
if (arg.sections?.length) params.sections = arg.sections.join(',')
return this.request(
this.$axios.get<AppResponse<AnalyticsSectionsResponse>>(
`${this.moduleUrl}/sections`,
{ params, signal },
),
)
}
public async fetchDrillDown(arg: AnalyticsFetchDrillDownArg, signal?: AbortSignal) {
const params: Record<string, string> = {
...scopeToParams(arg.scope),
organizationId: String(arg.organizationId),
period: arg.period,
}
if (arg.from) params.from = arg.from
if (arg.to) params.to = arg.to
if (arg.bucket) params.bucket = arg.bucket
if (arg.datasetId) params.datasetId = arg.datasetId
if (arg.index !== undefined) params.index = String(arg.index)
if (arg.meta) params.meta = JSON.stringify(arg.meta)
return this.request(
this.$axios.get<AppResponse<AnalyticsDrillDownResponse>>(
`${this.moduleUrl}/drilldown/${arg.sectionId}`,
{ params, signal },
),
)
}
}
@@ -0,0 +1,167 @@
export type AnalyticsChartType =
| 'line'
| 'bar'
| 'area'
| 'stackedBar'
| 'stackedArea'
| 'horizontalBar'
| 'donut'
| 'histogram'
| 'radar'
export type AnalyticsScope =
| { kind: 'org' }
| { kind: 'project'; goalId: number }
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'
export type AnalyticsRange = {
from: string
to: string
}
export type LocalizedText = {
ru: string
en: string
}
export type AnalyticsUnit =
| 'count'
| 'days'
| 'hours'
| 'percent'
| 'currency'
export type AnalyticsColorToken =
| 'primary'
| 'success'
| 'warning'
| 'danger'
| 'neutral'
| 'info'
export type AnalyticsDataset = {
id: string
label: LocalizedText
values: (number | null)[]
colorToken?: AnalyticsColorToken
stack?: string
meta?: Record<string, unknown>
}
export type AnalyticsReferenceLine = {
id: string
label: LocalizedText
value: number
axis: 'x' | 'y'
colorToken?: AnalyticsColorToken
}
export type AnalyticsSeriesPayload = {
kind: 'series'
labels: string[]
labelTexts?: LocalizedText[]
labelKind: 'date' | 'category'
datasets: AnalyticsDataset[]
referenceLines?: AnalyticsReferenceLine[]
xAxisLabel?: LocalizedText
yAxisLabel?: LocalizedText
unit: AnalyticsUnit
}
export type AnalyticsKpiDelta = {
value: number
direction: 'up' | 'down' | 'flat'
isGood: boolean
}
export type AnalyticsKpiPayload = {
kind: 'kpi'
value: number
delta?: AnalyticsKpiDelta
unit: AnalyticsUnit
sparkline?: number[]
}
export type AnalyticsSectionGroup =
| 'kpi'
| 'productivity'
| 'quality'
| 'workload'
| 'financial'
| 'usage'
export type AnalyticsDrillDownKind = 'tasks' | 'users' | 'projects'
export type AnalyticsSectionHelp = {
summary: LocalizedText
details: LocalizedText
}
export type AnalyticsSection = {
id: string
title: LocalizedText
description?: LocalizedText
help?: AnalyticsSectionHelp
group: AnalyticsSectionGroup
allowedChartTypes: AnalyticsChartType[]
defaultChartType: AnalyticsChartType | null
payload: AnalyticsSeriesPayload | AnalyticsKpiPayload
drillDown?: { kind: AnalyticsDrillDownKind }
generatedAt: string
}
export type AnalyticsAvailableGoal = {
id: number
name: string
}
export type AnalyticsSectionsResponse = {
scope: AnalyticsScope
period: AnalyticsPeriod
range: AnalyticsRange
sections: AnalyticsSection[]
availableGoals: AnalyticsAvailableGoal[]
failedSectionIds: string[]
}
export type AnalyticsFetchSectionsArg = {
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
from?: string
to?: string
sections?: string[]
}
export type AnalyticsDrillDownTask = {
id: number
description: string
goalId: number
goalName: string
complete: boolean
priorityId: number | null
endDate: string | null
date_creation: string
date_complete: string | null
}
export type AnalyticsDrillDownResponse = {
sectionId: string
tasks: AnalyticsDrillDownTask[]
total: number
denied?: boolean
}
export type AnalyticsFetchDrillDownArg = {
sectionId: string
scope: AnalyticsScope
organizationId: number
period: AnalyticsPeriod
from?: string
to?: string
bucket?: string
index?: number
datasetId?: string
meta?: Record<string, unknown>
}
@@ -125,6 +125,11 @@ export const TvPermissions: Record<Uppercase<keyof GoalPermissions>, keyof GoalP
INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage',
INTEGRATIONS_CAN_VIEW: 'integrations_can_view',
/**
* Can view analytics dashboards and KPIs for this goal
*/
ANALYTICS_CAN_VIEW: 'analytics_can_view',
} as const;
export type GoalPermissions = {
@@ -167,4 +172,6 @@ export type GoalPermissions = {
integrations_can_manage?: true;
integrations_can_view?: true;
analytics_can_view?: true;
};
+2 -1
View File
@@ -15,4 +15,5 @@ export * from '@/api/webhooks.types';
export * from '@/api/api-tokens.types';
export * from '@/api/sessions.types';
export * from '@/api/organizations.types';
export * from '@/api/sso.types';
export * from '@/api/sso.types';
export * from '@/api/analytics.types';
+5
View File
@@ -13,6 +13,7 @@ import TvApiTokens from "./api/api-tokens";
import TvSessions from "./api/sessions";
import TvOrganizationsApi from "./api/organizations";
import TvSsoApi from "./api/sso";
import TvAnalyticsApi from "./api/analytics";
export class TvApi {
@@ -46,6 +47,8 @@ export class TvApi {
public sso: TvSsoApi;
public analytics: TvAnalyticsApi;
constructor($axios: AxiosInstance) {
this.$axios = $axios;
@@ -76,6 +79,8 @@ export class TvApi {
this.organizations = new TvOrganizationsApi(this.$axios);
this.sso = new TvSsoApi(this.$axios);
this.analytics = new TvAnalyticsApi(this.$axios);
}
public setBaseUrl(baseUrl: string) {
+3
View File
@@ -59,6 +59,8 @@
"arktype": "2.1.20",
"axios": "1.13.5",
"centrifuge": "^5.5.3",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"date-fns": "^4.1.0",
"firebase": "^12.10.0",
"pinia": "^2.3.1",
@@ -67,6 +69,7 @@
"tailwindcss": "^4.1.18",
"taskview-api": "workspace:^",
"vue": "^3.5.27",
"vue-chartjs": "^5.3.3",
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0",
+7
View File
@@ -176,6 +176,13 @@ const items = computed<DropdownMenuItem[][]>(() => [
router.push({ name: 'organizations' })
},
},
{
label: t('userMenu.analytics'),
icon: 'i-lucide-bar-chart-3',
onSelect() {
router.push({ name: 'analytics' })
},
},
],
[
{
@@ -0,0 +1,145 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex min-h-10 justify-end">
<UTabs
v-if="showSwitcher"
v-model="currentChartType"
:items="chartTypeOptions"
size="xs"
variant="pill"
:ui="{ list: 'bg-zinc-100 dark:bg-zinc-800' }"
/>
</div>
<div class="relative" :style="{ height: `${height ?? 320}px` }">
<canvas ref="canvas" />
</div>
</div>
</template>
<script setup lang="ts">
import { Chart, type ChartConfiguration } from 'chart.js'
import type { AnalyticsChartType, AnalyticsSection } from 'taskview-api'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { registerChartJs } from './chart-setup'
import { useAnalyticsChartConfig } from './composables/useAnalyticsChartConfig'
const { t, locale } = useI18n()
const props = defineProps<{
section: AnalyticsSection
height?: number
}>()
const emit = defineEmits<{
(e: 'drill-down', payload: { datasetId: string; bucket: string; index: number; meta?: Record<string, unknown> }): void
}>()
registerChartJs()
const canvas = ref<HTMLCanvasElement | null>(null)
let instance: Chart | null = null
const currentChartType = ref<AnalyticsChartType>(
props.section.defaultChartType ?? props.section.allowedChartTypes[0] ?? 'bar',
)
const chartTypeOptions = computed(() =>
props.section.allowedChartTypes.map(t => ({
value: t,
label: chartTypeLabel(t),
icon: chartTypeIcon(t),
})),
)
const showSwitcher = computed(() => props.section.allowedChartTypes.length > 1)
const { build } = useAnalyticsChartConfig()
function render() {
if (!canvas.value) return
if (props.section.payload.kind !== 'series') return
const config: ChartConfiguration = build(props.section, currentChartType.value)
const isDrillable = !!props.section.drillDown
config.options = {
...(config.options ?? {}),
onHover: (event, elements) => {
const target = event.native?.target as HTMLElement | undefined
if (!target) return
target.style.cursor = isDrillable && elements.length ? 'pointer' : 'default'
},
}
if (isDrillable) {
config.options.onClick = (_evt, elements, chart) => {
if (!elements.length) return
const el = elements[0]
const datasetIndex = el.datasetIndex
const index = el.index
const ds = chart.data.datasets[datasetIndex]
const label = chart.data.labels?.[index]
const payload = props.section.payload as Extract<AnalyticsSection['payload'], { kind: 'series' }>
const sectionDs = payload.datasets[datasetIndex]
emit('drill-down', {
datasetId: sectionDs?.id ?? String(ds.label ?? datasetIndex),
bucket: String(label ?? ''),
index,
meta: sectionDs?.meta,
})
}
}
if (instance) {
instance.destroy()
instance = null
}
instance = new Chart(canvas.value, config)
}
function chartTypeLabel(type: AnalyticsChartType): string {
return t(`analytics.chartTypes.${type}`)
}
function chartTypeIcon(type: AnalyticsChartType): string {
const icons: Record<AnalyticsChartType, string> = {
line: 'i-lucide-line-chart',
bar: 'i-lucide-bar-chart-3',
area: 'i-lucide-area-chart',
stackedBar: 'i-lucide-bar-chart',
stackedArea: 'i-lucide-area-chart',
horizontalBar: 'i-lucide-bar-chart-horizontal',
donut: 'i-lucide-pie-chart',
histogram: 'i-lucide-bar-chart-4',
radar: 'i-lucide-hexagon',
}
return icons[type]
}
onMounted(() => {
render()
})
onBeforeUnmount(() => {
if (instance) {
instance.destroy()
instance = null
}
})
watch(() => props.section, () => {
if (!props.section.allowedChartTypes.includes(currentChartType.value)) {
// Reassigning currentChartType triggers its own watcher, which calls render().
// Skip render here to avoid double-init race ("Canvas is already in use").
currentChartType.value = props.section.defaultChartType ?? props.section.allowedChartTypes[0] ?? 'bar'
return
}
render()
}, { deep: true })
watch(currentChartType, () => render())
watch(locale, () => render())
</script>
@@ -0,0 +1,135 @@
<template>
<USlideover
v-model:open="open"
:title="analyticsStore.drillDown.sectionTitle ?? t('analytics.drillDown.defaultTitle')"
:description="analyticsStore.drillDown.bucket ?? ''"
:fullscreen="isMobile"
>
<template #body>
<UAlert
v-if="drillDownErrorMessage"
color="error"
variant="soft"
:title="drillDownErrorMessage"
icon="i-lucide-alert-triangle"
class="mb-4"
/>
<div v-if="analyticsStore.drillDown.loading" class="flex items-center justify-center py-12">
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin text-zinc-400" />
</div>
<div
v-else-if="!analyticsStore.drillDown.error && analyticsStore.drillDown.denied"
class="flex flex-col items-center gap-2 py-12 text-center text-sm text-zinc-500"
>
<UIcon name="i-lucide-lock" class="size-8 text-zinc-400" />
<p>{{ t('analytics.drillDown.denied') }}</p>
</div>
<div
v-else-if="!analyticsStore.drillDown.error && analyticsStore.drillDown.tasks.length === 0"
class="flex flex-col items-center gap-2 py-12 text-center text-sm text-zinc-500"
>
<UIcon name="i-lucide-check-circle-2" class="size-8 text-zinc-400" />
<p>{{ t('analytics.drillDown.empty') }}</p>
</div>
<ul v-else-if="analyticsStore.drillDown.tasks.length > 0" class="flex flex-col gap-2">
<li
v-for="task in analyticsStore.drillDown.tasks"
:key="task.id"
class="cursor-pointer rounded-lg border border-zinc-200 p-3 transition hover:border-emerald-400 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:border-emerald-500 dark:hover:bg-zinc-900"
role="button"
tabindex="0"
@click="openTask(task)"
@keydown.enter="openTask(task)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
v-if="task.priorityId"
class="text-xs font-medium"
:class="priorityColor[task.priorityId]"
>
{{ priorityLabel[task.priorityId] }}
</span>
<span class="truncate text-sm font-medium">
{{ task.description || t('analytics.drillDown.noTaskTitle') }}
</span>
</div>
<div class="mt-1 flex items-center gap-3 text-xs text-zinc-500">
<span>{{ task.goalName }}</span>
<span v-if="task.endDate">{{ t('analytics.drillDown.due') }} {{ fmtDate(task.endDate) }}</span>
<span>{{ t('analytics.drillDown.created') }} {{ fmtDate(task.date_creation) }}</span>
<span v-if="task.complete" class="text-emerald-600 dark:text-emerald-400">
{{ t('analytics.drillDown.closed') }} {{ fmtDate(task.date_complete) }}
</span>
</div>
</div>
<UIcon name="i-lucide-chevron-right" class="size-4 shrink-0 text-zinc-400" />
</div>
</li>
</ul>
</template>
</USlideover>
</template>
<script setup lang="ts">
import { ALL_TASKS_LIST_ID, type AnalyticsDrillDownTask } from 'taskview-api'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useTaskView } from '@/composables/useTaskView'
import { useAnalyticsStore } from '@/stores/analytics.store'
const analyticsStore = useAnalyticsStore()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const { isMobile } = useTaskView()
const open = computed({
get: () => analyticsStore.drillDown.open,
set: (v) => {
if (!v) analyticsStore.closeDrillDown()
else analyticsStore.drillDown.open = true
},
})
const drillDownErrorMessage = computed(() => {
const e = analyticsStore.drillDown.error
if (!e) return null
return t(`analytics.errors.${e.kind}`)
})
const priorityLabel: Record<number, string> = {
1: t('analytics.priorities.low'),
2: t('analytics.priorities.medium'),
3: t('analytics.priorities.high'),
}
const priorityColor: Record<number, string> = {
1: 'text-blue-500',
2: 'text-amber-500',
3: 'text-red-500',
}
function fmtDate(iso: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleDateString()
}
function openTask(task: AnalyticsDrillDownTask) {
analyticsStore.closeDrillDown()
router.push({
name: 'user',
params: {
orgSlug: route.params.orgSlug,
projectId: task.goalId,
listId: ALL_TASKS_LIST_ID,
taskId: task.id,
},
})
}
</script>
@@ -0,0 +1,74 @@
<template>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<USelectMenu
:model-value="currentScopeValue"
:items="scopeOptions"
value-key="value"
class="sm:w-64"
@update:model-value="onScopeChange"
/>
<USelectMenu
:model-value="analyticsStore.period"
:items="periodOptions"
value-key="value"
class="sm:w-40"
@update:model-value="onPeriodChange"
/>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
size="sm"
:loading="analyticsStore.loading"
@click="analyticsStore.fetchSections()"
/>
</div>
</template>
<script setup lang="ts">
import type { AnalyticsPeriod, AnalyticsScope } from 'taskview-api'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAnalyticsStore } from '@/stores/analytics.store'
const analyticsStore = useAnalyticsStore()
const { t } = useI18n()
const periodOptions = computed<{ label: string; value: AnalyticsPeriod }[]>(() => [
{ label: t('analytics.filters.periods.7d'), value: '7d' },
{ label: t('analytics.filters.periods.30d'), value: '30d' },
{ label: t('analytics.filters.periods.90d'), value: '90d' },
{ label: t('analytics.filters.periods.180d'), value: '180d' },
{ label: t('analytics.filters.periods.365d'), value: '365d' },
])
type ScopeOption = { label: string; value: string; scope: AnalyticsScope }
const scopeOptions = computed<ScopeOption[]>(() => {
const base: ScopeOption[] = [
{ label: t('analytics.filters.scopes.org'), value: 'org', scope: { kind: 'org' } },
]
const projects = analyticsStore.availableGoals.map(g => ({
label: g.name,
value: `project:${g.id}`,
scope: { kind: 'project' as const, goalId: g.id },
}))
return [...base, ...projects]
})
const currentScopeValue = computed(() => {
const s = analyticsStore.scope
if (s.kind === 'org') return 'org'
return `project:${s.goalId}`
})
function onScopeChange(value: string) {
const option = scopeOptions.value.find(o => o.value === value)
if (option) analyticsStore.setScope(option.scope)
}
function onPeriodChange(value: AnalyticsPeriod) {
analyticsStore.setPeriod(value)
}
</script>
@@ -0,0 +1,47 @@
<template>
<span class="inline-flex">
<UTooltip :text="pick(props.help.summary)" :delay-duration="150">
<UButton
icon="i-lucide-circle-help"
color="neutral"
variant="ghost"
size="xs"
square
:aria-label="t('analytics.help.aria')"
@click="isOpen = true"
/>
</UTooltip>
<UModal v-model:open="isOpen" :title="pick(props.sectionTitle)">
<template #body>
<div class="flex flex-col gap-4">
<div>
<h4 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ pick(props.help.summary) }}
</h4>
</div>
<p class="whitespace-pre-line text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
{{ pick(props.help.details) }}
</p>
</div>
</template>
</UModal>
</span>
</template>
<script setup lang="ts">
import type { AnalyticsSectionHelp, LocalizedText } from 'taskview-api'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAnalyticsLocale } from './composables/useAnalyticsLocale'
const props = defineProps<{
help: AnalyticsSectionHelp
sectionTitle: LocalizedText
}>()
const { t } = useI18n()
const { pick } = useAnalyticsLocale()
const isOpen = ref(false)
</script>
@@ -0,0 +1,101 @@
<template>
<UCard
:class="isClickable ? 'cursor-pointer transition hover:border-emerald-400 dark:hover:border-emerald-500' : ''"
role="button"
:tabindex="isClickable ? 0 : -1"
@click="onClick"
@keydown.enter="onClick"
>
<div class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<div class="text-sm text-zinc-500 dark:text-zinc-400">
{{ pick(section.title) }}
</div>
<AnalyticsHelpButton
v-if="section.help"
:help="section.help"
:section-title="section.title"
@click.stop
/>
</div>
<div class="text-3xl font-semibold">
{{ formattedValue }}
</div>
<div v-if="payload.delta" class="flex items-center gap-1 text-sm" :class="deltaClass">
<UIcon :name="deltaIcon" class="size-4" />
<span>{{ payload.delta.value }}%</span>
</div>
<div v-if="section.description" class="text-xs text-zinc-500 dark:text-zinc-400">
{{ pick(section.description) }}
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { AnalyticsKpiPayload, AnalyticsSection } from 'taskview-api'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AnalyticsHelpButton from './AnalyticsHelpButton.vue'
import { useAnalyticsLocale } from './composables/useAnalyticsLocale'
const props = defineProps<{
section: AnalyticsSection
}>()
const { t } = useI18n()
const emit = defineEmits<{
(e: 'drill-down', payload: { sectionId: string; datasetId: string; bucket: string; index: number; meta?: Record<string, unknown> }): void
}>()
const { pick } = useAnalyticsLocale()
const payload = computed(() => props.section.payload as AnalyticsKpiPayload)
const isClickable = computed(() => !!props.section.drillDown)
function onClick() {
if (!isClickable.value) return
emit('drill-down', {
sectionId: props.section.id,
datasetId: 'kpi',
bucket: pick(props.section.title),
index: 0,
})
}
const formattedValue = computed(() => {
const v = payload.value.value
switch (payload.value.unit) {
case 'percent':
return `${v}%`
case 'days':
return `${v} ${t('analytics.units.days')}`
case 'hours':
return `${v} ${t('analytics.units.hours')}`
case 'currency':
return v.toLocaleString()
default:
return v.toLocaleString()
}
})
const deltaClass = computed(() => {
if (!payload.value.delta) return ''
if (payload.value.delta.direction === 'flat') return 'text-zinc-500'
return payload.value.delta.isGood ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
})
const deltaIcon = computed(() => {
if (!payload.value.delta) return ''
switch (payload.value.delta.direction) {
case 'up':
return 'i-lucide-trending-up'
case 'down':
return 'i-lucide-trending-down'
default:
return 'i-lucide-minus'
}
})
</script>
@@ -0,0 +1,55 @@
<template>
<UCard>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h3 class="text-base font-semibold">
{{ pick(section.title) }}
</h3>
<p v-if="section.description" class="text-sm text-zinc-500 dark:text-zinc-400">
{{ pick(section.description) }}
</p>
</div>
<AnalyticsHelpButton
v-if="section.help"
:help="section.help"
:section-title="section.title"
/>
</div>
</template>
<div v-if="section.payload.kind === 'series' && section.payload.datasets.length === 0" class="py-12 text-center text-sm text-zinc-500">
{{ t('analytics.sectionCard.noData') }}
</div>
<AnalyticsChart
v-else
:section="section"
@drill-down="onDrillDown(section, $event)"
/>
</UCard>
</template>
<script setup lang="ts">
import type { AnalyticsSection } from 'taskview-api'
import { useI18n } from 'vue-i18n'
import AnalyticsChart from './AnalyticsChart.vue'
import AnalyticsHelpButton from './AnalyticsHelpButton.vue'
import { useAnalyticsLocale } from './composables/useAnalyticsLocale'
defineProps<{
section: AnalyticsSection
}>()
const { t } = useI18n()
const emit = defineEmits<{
(e: 'drill-down', payload: { sectionId: string; datasetId: string; bucket: string; index: number; meta?: Record<string, unknown> }): void
}>()
const { pick } = useAnalyticsLocale()
function onDrillDown(section: AnalyticsSection, payload: { datasetId: string; bucket: string; index: number; meta?: Record<string, unknown> }) {
emit('drill-down', { sectionId: section.id, ...payload })
}
</script>
@@ -0,0 +1,10 @@
<template>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<USkeleton v-for="i in 4" :key="`kpi-${i}`" class="h-28 rounded-lg" />
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<USkeleton v-for="i in 6" :key="`chart-${i}`" class="h-72 rounded-lg" />
</div>
</div>
</template>
@@ -0,0 +1,47 @@
import {
ArcElement,
BarController,
BarElement,
CategoryScale,
Chart,
DoughnutController,
Filler,
Legend,
LineController,
LineElement,
LinearScale,
PointElement,
RadarController,
RadialLinearScale,
TimeScale,
Title,
Tooltip,
} from 'chart.js'
import annotationPlugin from 'chartjs-plugin-annotation'
let registered = false
export function registerChartJs() {
if (registered) return
registered = true
Chart.register(
LineController,
BarController,
DoughnutController,
RadarController,
CategoryScale,
LinearScale,
TimeScale,
RadialLinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Tooltip,
Legend,
Title,
Filler,
annotationPlugin,
)
}
@@ -0,0 +1,296 @@
import type { ChartConfiguration, ChartType } from 'chart.js'
import type {
AnalyticsChartType,
AnalyticsSection,
AnalyticsSeriesPayload,
} from 'taskview-api'
import { useI18n } from 'vue-i18n'
import { useAnalyticsLocale } from './useAnalyticsLocale'
import { useAnalyticsTheme } from './useAnalyticsTheme'
type AnyChartConfig = ChartConfiguration<ChartType, (number | null)[], string>
export function useAnalyticsChartConfig() {
const { pick } = useAnalyticsLocale()
const { colorFor, paletteForCount, transparentize } = useAnalyticsTheme()
const { t } = useI18n()
function displayLabels(payload: AnalyticsSeriesPayload): string[] {
if (payload.labelTexts && payload.labelTexts.length === payload.labels.length) {
return payload.labelTexts.map(pick)
}
return payload.labels
}
function build(section: AnalyticsSection, chartType: AnalyticsChartType): AnyChartConfig {
if (section.payload.kind !== 'series') {
throw new Error('useAnalyticsChartConfig only supports series payloads')
}
const payload = section.payload
switch (chartType) {
case 'line':
return lineOrArea(section, payload, false, false)
case 'area':
return lineOrArea(section, payload, true, false)
case 'stackedArea':
return lineOrArea(section, payload, true, true)
case 'bar':
return barChart(section, payload, 'x', false)
case 'stackedBar':
return barChart(section, payload, 'x', true)
case 'horizontalBar':
return barChart(section, payload, 'y', payload.datasets.some(d => d.stack))
case 'donut':
return donutChart(section, payload)
case 'histogram':
return histogramChart(section, payload)
case 'radar':
return radarChart(section, payload)
}
}
function radarChart(
_section: AnalyticsSection,
payload: AnalyticsSeriesPayload,
): AnyChartConfig {
const MAX_ENTITIES = 5
const limitedLabels = displayLabels(payload).slice(0, MAX_ENTITIES)
const axisLabels = payload.datasets.map(ds => pick(ds.label))
const colors = paletteForCount(limitedLabels.length)
const newDatasets = limitedLabels.map((label, i) => {
const color = colors[i]
return {
label,
data: payload.datasets.map(ds => {
const v = ds.values[i]
return v === null || v === undefined ? 0 : v
}),
backgroundColor: transparentize(color, 0.18),
borderColor: color,
pointBackgroundColor: color,
pointBorderColor: '#ffffff',
pointRadius: 3,
pointHoverRadius: 5,
borderWidth: 2,
}
})
return {
type: 'radar',
data: {
labels: axisLabels,
datasets: newDatasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' as const },
tooltip: tooltipConfig(payload),
},
scales: {
r: {
beginAtZero: true,
ticks: { display: true, stepSize: 1 },
},
},
} as AnyChartConfig['options'],
}
}
function lineOrArea(
section: AnalyticsSection,
payload: AnalyticsSeriesPayload,
fill: boolean,
stacked: boolean,
): AnyChartConfig {
return {
type: 'line' as ChartType,
data: {
labels: displayLabels(payload),
datasets: payload.datasets.map((ds, i) => {
const color = colorFor(ds.colorToken, i)
return {
label: pick(ds.label),
data: ds.values.map(v => (v === null ? Number.NaN : v)),
borderColor: color,
backgroundColor: fill ? transparentize(color, 0.2) : color,
fill,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
stack: stacked ? (ds.stack ?? 'default') : undefined,
}
}),
},
options: baseOptions(section, payload, { stacked }) as AnyChartConfig['options'],
}
}
function barChart(
section: AnalyticsSection,
payload: AnalyticsSeriesPayload,
indexAxis: 'x' | 'y',
stacked: boolean,
): AnyChartConfig {
const labels = displayLabels(payload)
const useMultiColor = payload.datasets.length === 1 && labels.length > 1
return {
type: 'bar',
data: {
labels,
datasets: payload.datasets.map((ds, i) => {
const singleColor = colorFor(ds.colorToken, i)
const backgroundColor = useMultiColor
? paletteForCount(labels.length)
: singleColor
return {
label: pick(ds.label),
data: ds.values.map(v => (v === null ? Number.NaN : v)),
backgroundColor,
borderColor: backgroundColor,
borderRadius: 4,
stack: stacked ? (ds.stack ?? 'default') : undefined,
}
}),
},
options: { ...baseOptions(section, payload, { stacked }), indexAxis } as AnyChartConfig['options'],
}
}
function donutChart(section: AnalyticsSection, payload: AnalyticsSeriesPayload): AnyChartConfig {
const firstDs = payload.datasets[0]
const labels = displayLabels(payload)
const colors = paletteForCount(labels.length)
return {
type: 'doughnut',
data: {
labels,
datasets: [
{
label: firstDs ? pick(firstDs.label) : pick(section.title),
data: firstDs?.values.map(v => (v === null ? 0 : v)) ?? [],
backgroundColor: colors,
borderWidth: 2,
borderColor: '#ffffff',
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' as const },
tooltip: tooltipConfig(payload),
},
} as AnyChartConfig['options'],
}
}
function histogramChart(
section: AnalyticsSection,
payload: AnalyticsSeriesPayload,
): AnyChartConfig {
const cfg = barChart(section, payload, 'x', false)
const refs = payload.referenceLines ?? []
const annotations: Record<string, unknown> = {}
const labelPositions: Array<'start' | 'center' | 'end'> = ['start', 'center', 'end']
refs.forEach((line, i) => {
annotations[line.id] = {
type: 'line',
scaleID: line.axis === 'x' ? 'x' : 'y',
value: line.value,
borderColor: colorFor(line.colorToken ?? 'warning'),
borderWidth: 2,
borderDash: [6, 4],
label: {
display: true,
content: pick(line.label),
position: labelPositions[i % labelPositions.length],
backgroundColor: colorFor(line.colorToken ?? 'warning'),
color: '#ffffff',
padding: { top: 3, bottom: 3, left: 6, right: 6 },
font: { size: 11, weight: 'bold' },
},
}
})
cfg.options = {
...cfg.options,
plugins: {
...(cfg.options?.plugins ?? {}),
annotation: { annotations },
},
} as AnyChartConfig['options']
return cfg
}
function baseOptions(
_section: AnalyticsSection,
payload: AnalyticsSeriesPayload,
opts: { stacked: boolean },
) {
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: {
display: payload.datasets.length > 1,
position: 'bottom' as const,
},
tooltip: tooltipConfig(payload),
},
scales: {
x: {
stacked: opts.stacked,
title: payload.xAxisLabel
? { display: true, text: pick(payload.xAxisLabel) }
: undefined,
},
y: {
stacked: opts.stacked,
beginAtZero: true,
title: payload.yAxisLabel
? { display: true, text: pick(payload.yAxisLabel) }
: undefined,
ticks: payload.unit === 'percent' ? { callback: (v: string | number) => `${v}%` } : undefined,
},
},
}
}
function tooltipConfig(payload: AnalyticsSeriesPayload) {
return {
callbacks: {
label: (ctx: { dataset: { label?: string }, parsed: number | { y: number } }) => {
const label = ctx.dataset.label ? `${ctx.dataset.label}: ` : ''
const value = typeof ctx.parsed === 'number' ? ctx.parsed : ctx.parsed.y
return `${label}${formatValue(value, payload.unit)}`
},
},
}
}
function formatValue(value: number, unit: AnalyticsSeriesPayload['unit']): string {
if (value === null || Number.isNaN(value)) return '—'
switch (unit) {
case 'percent':
return `${value}%`
case 'days':
return `${value} ${t('analytics.units.days')}`
case 'hours':
return `${value} ${t('analytics.units.hours')}`
case 'currency':
return value.toLocaleString()
default:
return String(value)
}
}
return { build, formatValue }
}
@@ -0,0 +1,14 @@
import { useI18n } from 'vue-i18n'
import type { LocalizedText } from 'taskview-api'
export function useAnalyticsLocale() {
const { locale } = useI18n()
function pick(text: LocalizedText | undefined): string {
if (!text) return ''
const loc = locale.value as keyof LocalizedText
return text[loc] ?? text.en ?? text.ru ?? ''
}
return { pick }
}
@@ -0,0 +1,39 @@
import type { AnalyticsColorToken } from 'taskview-api'
const palette: Record<AnalyticsColorToken, string> = {
primary: '#10b981',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
neutral: '#71717a',
info: '#3b82f6',
}
const fallbackOrder: AnalyticsColorToken[] = [
'primary',
'info',
'warning',
'success',
'danger',
'neutral',
]
export function useAnalyticsTheme() {
function colorFor(token: AnalyticsColorToken | undefined, index = 0): string {
if (token) return palette[token]
return palette[fallbackOrder[index % fallbackOrder.length]]
}
function paletteForCount(count: number): string[] {
return Array.from({ length: count }, (_, i) => palette[fallbackOrder[i % fallbackOrder.length]])
}
function transparentize(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
return { colorFor, paletteForCount, transparentize }
}
+63
View File
@@ -436,11 +436,74 @@ export default {
docker: 'Docker Images',
accountSettings: 'Account settings',
organizations: 'Organizations',
analytics: 'Analytics',
switchOrganization: 'Switch organization',
logout: 'Log out',
logoutFailed: 'Logout failed',
logoutFailedDescription: 'Try clearing browser cache to remove the authorization token',
},
analytics: {
page: {
title: 'Analytics',
subtitle: 'Productivity and project health metrics',
emptyState: 'No data yet. Create tasks to see analytics.',
emptyStateForPeriod: 'No data for the selected period. Try a longer one.',
partialFailure: 'Some sections failed to load. Try refreshing.',
},
errors: {
forbidden: 'You do not have access to these analytics.',
network: 'Could not reach the server. Check your connection.',
server: 'Server returned an error. Try refreshing the page.',
unknown: 'Failed to load analytics.',
},
help: {
aria: 'Help for this metric',
},
chartTypes: {
line: 'Line',
bar: 'Bar',
area: 'Area',
stackedBar: 'Stacked',
stackedArea: 'Stacked area',
horizontalBar: 'Horiz.',
donut: 'Donut',
histogram: 'Histogram',
radar: 'Radar',
},
units: {
days: 'd',
hours: 'h',
},
priorities: {
low: 'Low',
medium: 'Medium',
high: 'High',
},
filters: {
periods: {
'7d': '7 days',
'30d': '30 days',
'90d': '90 days',
'180d': 'Quarter',
'365d': 'Year',
},
scopes: {
org: 'Whole organization',
},
},
drillDown: {
defaultTitle: 'Tasks',
empty: 'No tasks in this selection',
denied: 'You do not have permission to view individual task details',
noTaskTitle: 'Untitled',
due: 'due',
created: 'created',
closed: 'closed',
},
sectionCard: {
noData: 'No data for the selected period',
},
},
account: {
title: 'Account settings',
management: 'Account management',
+63
View File
@@ -408,11 +408,74 @@ export default {
docker: 'Docker образы',
accountSettings: 'Настройки аккаунта',
organizations: 'Организации',
analytics: 'Аналитика',
switchOrganization: 'Переключить организацию',
logout: 'Выйти',
logoutFailed: 'Не удалось выйти',
logoutFailedDescription: 'Попробуйте очистить кэш браузера, чтобы удалить токен авторизации',
},
analytics: {
page: {
title: 'Аналитика',
subtitle: 'Показатели продуктивности и здоровья проектов',
emptyState: 'Нет данных. Создайте задачи, чтобы увидеть аналитику.',
emptyStateForPeriod: 'За выбранный период данных нет. Попробуйте увеличить период.',
partialFailure: 'Некоторые секции не удалось загрузить. Попробуйте обновить.',
},
errors: {
forbidden: 'У вас нет доступа к этой аналитике.',
network: 'Не удалось связаться с сервером. Проверьте подключение.',
server: 'Сервер вернул ошибку. Попробуйте обновить страницу.',
unknown: 'Не удалось загрузить аналитику.',
},
help: {
aria: 'Справка по показателю',
},
chartTypes: {
line: 'Линия',
bar: 'Столбцы',
area: 'Область',
stackedBar: 'Стек',
stackedArea: 'Стек (область)',
horizontalBar: 'Гориз.',
donut: 'Кольцо',
histogram: 'Гистограмма',
radar: 'Радар',
},
units: {
days: 'дн',
hours: 'ч',
},
priorities: {
low: 'Низкий',
medium: 'Средний',
high: 'Высокий',
},
filters: {
periods: {
'7d': '7 дней',
'30d': '30 дней',
'90d': '90 дней',
'180d': 'Квартал',
'365d': 'Год',
},
scopes: {
org: 'Вся организация',
},
},
drillDown: {
defaultTitle: 'Задачи',
empty: 'Нет задач в этой выборке',
denied: 'У вас нет прав на просмотр деталей задач',
noTaskTitle: 'Без названия',
due: 'до',
created: 'создано',
closed: 'закрыта',
},
sectionCard: {
noData: 'Нет данных за выбранный период',
},
},
account: {
title: 'Настройки аккаунта',
management: 'Управление аккаунтом',
+5
View File
@@ -79,6 +79,11 @@ const router = createRouter({
name: 'organizations',
component: () => import('./pages/user/organizations.vue'),
},
{
path: 'analytics',
name: 'analytics',
component: () => import('./pages/user/analytics.vue'),
},
{
path: ':projectId?/:listId?/:taskId?',
name: 'user',
+218
View File
@@ -0,0 +1,218 @@
<template>
<UDashboardPanel id="analytics">
<template #header>
<UDashboardNavbar :title="t('analytics.page.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-6 p-2 lg:p-6">
<AnalyticsFilters />
<UAlert
v-if="errorMessage"
color="error"
variant="soft"
:title="errorMessage"
icon="i-lucide-alert-triangle"
/>
<UAlert
v-else-if="analyticsStore.failedSectionIds.length > 0 && analyticsStore.sections.length > 0"
color="warning"
variant="soft"
:title="t('analytics.page.partialFailure')"
icon="i-lucide-alert-circle"
/>
<AnalyticsSkeleton v-if="showSkeleton" />
<template v-else-if="analyticsStore.sections.length > 0">
<div
v-if="analyticsStore.kpiSections.length > 0"
class="grid grid-cols-2 gap-3 lg:grid-cols-4"
>
<AnalyticsKpiCard
v-for="kpi in analyticsStore.kpiSections"
:key="kpi.id"
:section="kpi"
@drill-down="onDrillDown"
/>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<AnalyticsSectionCard
v-for="section in analyticsStore.chartSections"
:key="section.id"
:section="section"
@drill-down="onDrillDown"
/>
</div>
</template>
<div
v-else-if="showEmpty"
class="flex flex-col items-center gap-3 py-16 text-center"
>
<UIcon name="i-lucide-bar-chart-3" class="size-12 text-zinc-400" />
<p class="text-sm text-zinc-500">
{{ emptyMessage }}
</p>
</div>
</div>
<AnalyticsDrillDownSlideover />
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import type { AnalyticsPeriod } from 'taskview-api'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AnalyticsDrillDownSlideover from '@/components/features/analytics/AnalyticsDrillDownSlideover.vue'
import AnalyticsFilters from '@/components/features/analytics/AnalyticsFilters.vue'
import AnalyticsKpiCard from '@/components/features/analytics/AnalyticsKpiCard.vue'
import AnalyticsSectionCard from '@/components/features/analytics/AnalyticsSectionCard.vue'
import AnalyticsSkeleton from '@/components/features/analytics/AnalyticsSkeleton.vue'
import { useAnalyticsLocale } from '@/components/features/analytics/composables/useAnalyticsLocale'
import { useAnalyticsStore } from '@/stores/analytics.store'
import { useGoalsStore } from '@/stores/goals.store'
import { useOrganizationStore } from '@/stores/organization.store'
const { t } = useI18n()
const { pick } = useAnalyticsLocale()
const analyticsStore = useAnalyticsStore()
const goalsStore = useGoalsStore()
const orgStore = useOrganizationStore()
const route = useRoute()
const router = useRouter()
const errorMessage = computed(() => {
const e = analyticsStore.error
if (!e) return null
return t(`analytics.errors.${e.kind}`)
})
const showSkeleton = computed(() =>
analyticsStore.loading && analyticsStore.sections.length === 0 && !analyticsStore.error,
)
const showEmpty = computed(() =>
!analyticsStore.loading && !analyticsStore.error && analyticsStore.sections.length === 0,
)
const emptyMessage = computed(() => {
const period = analyticsStore.period
if (period === '7d' || period === '30d') {
return t('analytics.page.emptyState')
}
return t('analytics.page.emptyStateForPeriod')
})
const NAMED_PERIODS = ['7d', '30d', '90d', '180d', '365d'] as const
function readString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function readPositiveInt(value: unknown): number | null {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null
const n = Number(value)
return Number.isSafeInteger(n) && n > 0 ? n : null
}
function readIsoDate(value: unknown): string | null {
const s = readString(value)
if (!s) return null
const t = Date.parse(s)
return Number.isFinite(t) ? s : null
}
function applyQueryToStore() {
const period = readString(route.query.period)
if (period === 'custom') {
const from = readIsoDate(route.query.from)
const to = readIsoDate(route.query.to)
if (from && to) {
analyticsStore.period = 'custom'
analyticsStore.customFrom = from
analyticsStore.customTo = to
}
} else if (period && (NAMED_PERIODS as readonly string[]).includes(period)) {
analyticsStore.period = period as AnalyticsPeriod
analyticsStore.customFrom = null
analyticsStore.customTo = null
}
const scope = readString(route.query.scope)
if (scope === 'org') {
analyticsStore.scope = { kind: 'org' }
} else if (scope === 'project') {
const goalId = readPositiveInt(route.query.goalId)
if (goalId !== null) {
analyticsStore.scope = { kind: 'project', goalId }
}
}
}
function buildQuery(): Record<string, string> {
const q: Record<string, string> = { period: analyticsStore.period }
if (analyticsStore.period === 'custom') {
if (analyticsStore.customFrom) q.from = analyticsStore.customFrom
if (analyticsStore.customTo) q.to = analyticsStore.customTo
}
if (analyticsStore.scope.kind === 'org') {
q.scope = 'org'
} else {
q.scope = 'project'
q.goalId = String(analyticsStore.scope.goalId)
}
return q
}
onMounted(async () => {
applyQueryToStore()
if (!goalsStore.goals?.length) {
await goalsStore.fetchGoals?.()
}
await analyticsStore.fetchSections()
})
watch(
[() => analyticsStore.period, () => analyticsStore.scope],
() => {
router.replace({ query: buildQuery() }).catch(() => {})
},
{ deep: true },
)
watch(
() => orgStore.currentOrg?.id,
(newOrgId, oldOrgId) => {
if (newOrgId === oldOrgId) return
if (analyticsStore.scope.kind === 'project') {
analyticsStore.scope = { kind: 'org' }
}
analyticsStore.availableGoals = []
analyticsStore.sections = []
analyticsStore.fetchSections()
},
)
function onDrillDown(payload: { sectionId: string; datasetId: string; bucket: string; index: number; meta?: Record<string, unknown> }) {
const section = analyticsStore.sections.find(s => s.id === payload.sectionId)
if (!section) return
analyticsStore.openDrillDown({
sectionId: payload.sectionId,
sectionTitle: pick(section.title),
bucket: payload.bucket,
index: payload.index,
datasetId: payload.datasetId,
meta: payload.meta,
})
}
</script>
+204
View File
@@ -0,0 +1,204 @@
import axios from 'axios'
import { defineStore } from 'pinia'
import type { AnalyticsPeriod, AnalyticsScope, AnalyticsSection } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import type {
AnalyticsError,
AnalyticsErrorKind,
AnalyticsOpenDrillDownArgs,
AnalyticsState,
} from '@/types/analytics.types'
import { useOrganizationStore } from './organization.store'
// Kept outside Pinia state on purpose: Pinia proxies break AbortController.signal reads.
let sectionsAbortController: AbortController | null = null
let drillDownAbortController: AbortController | null = null
function isCancelled(e: unknown): boolean {
return axios.isCancel(e) || (e instanceof Error && e.name === 'CanceledError')
}
function classifyError(e: unknown): AnalyticsError {
if (isCancelled(e)) return null
if (axios.isAxiosError(e)) {
const status = e.response?.status
if (status === 403) return { kind: 'forbidden', status }
if (status && status >= 500) return { kind: 'server', status }
if (!e.response) return { kind: 'network' }
return { kind: 'unknown', status }
}
return { kind: 'unknown' }
}
export const useAnalyticsStore = defineStore('analytics', {
state: (): AnalyticsState => ({
scope: { kind: 'org' },
period: '30d',
customFrom: null,
customTo: null,
sections: [],
failedSectionIds: [],
availableGoals: [],
range: null,
loading: false,
error: null,
drillDown: {
open: false,
loading: false,
sectionId: null,
sectionTitle: null,
bucket: null,
tasks: [],
error: null,
denied: false,
},
}),
getters: {
kpiSections: (state) => state.sections.filter(s => s.payload.kind === 'kpi'),
chartSections: (state) => state.sections.filter(s => s.payload.kind === 'series'),
sectionsByGroup: (state) => {
const map = new Map<string, AnalyticsSection[]>()
for (const s of state.sections) {
if (s.payload.kind === 'kpi') continue
const arr = map.get(s.group) ?? []
arr.push(s)
map.set(s.group, arr)
}
return map
},
},
actions: {
setScope(scope: AnalyticsScope) {
this.scope = scope
return this.fetchSections()
},
setPeriod(period: AnalyticsPeriod) {
this.period = period
return this.fetchSections()
},
setCustomRange(from: string, to: string) {
this.period = 'custom'
this.customFrom = from
this.customTo = to
return this.fetchSections()
},
async fetchSections(): Promise<void> {
const orgStore = useOrganizationStore()
const organizationId = orgStore.currentOrg?.id
if (!organizationId) {
this.sections = []
this.failedSectionIds = []
return
}
sectionsAbortController?.abort()
const controller = new AbortController()
sectionsAbortController = controller
this.loading = true
this.error = null
try {
const result = await $tvApi.analytics.fetchSections({
scope: this.scope,
organizationId,
period: this.period,
from: this.customFrom ?? undefined,
to: this.customTo ?? undefined,
}, controller.signal)
if (controller.signal.aborted) return
if (result) {
this.sections = result.sections
this.failedSectionIds = result.failedSectionIds ?? []
this.availableGoals = result.availableGoals
this.range = result.range
}
} catch (e) {
if (controller.signal.aborted || isCancelled(e)) return
const err = classifyError(e)
if (!err) return
this.error = err
this.sections = []
this.failedSectionIds = []
this.availableGoals = []
// Auto-recovery: forbidden on project scope → fall back to org scope
if (err.kind === 'forbidden' && this.scope.kind === 'project') {
this.scope = { kind: 'org' }
this.error = null
return this.fetchSections()
}
} finally {
if (sectionsAbortController === controller) {
sectionsAbortController = null
this.loading = false
}
}
},
async openDrillDown(args: AnalyticsOpenDrillDownArgs) {
drillDownAbortController?.abort()
const controller = new AbortController()
drillDownAbortController = controller
this.drillDown.open = true
this.drillDown.loading = true
this.drillDown.sectionId = args.sectionId
this.drillDown.sectionTitle = args.sectionTitle
this.drillDown.bucket = args.bucket
this.drillDown.tasks = []
this.drillDown.error = null
this.drillDown.denied = false
const orgStore = useOrganizationStore()
const organizationId = orgStore.currentOrg?.id
if (!organizationId) {
this.drillDown.loading = false
return
}
try {
const result = await $tvApi.analytics.fetchDrillDown({
sectionId: args.sectionId,
scope: this.scope,
organizationId,
period: this.period,
from: this.customFrom ?? undefined,
to: this.customTo ?? undefined,
bucket: args.bucket,
index: args.index,
datasetId: args.datasetId,
meta: args.meta,
}, controller.signal)
if (controller.signal.aborted) return
if (result) {
this.drillDown.tasks = result.tasks
this.drillDown.denied = result.denied === true
}
} catch (e) {
if (controller.signal.aborted || isCancelled(e)) return
const err = classifyError(e)
if (err) this.drillDown.error = err
} finally {
if (drillDownAbortController === controller) {
drillDownAbortController = null
this.drillDown.loading = false
}
}
},
closeDrillDown() {
drillDownAbortController?.abort()
drillDownAbortController = null
this.drillDown.open = false
this.drillDown.loading = false
this.drillDown.tasks = []
this.drillDown.sectionId = null
this.drillDown.sectionTitle = null
this.drillDown.bucket = null
this.drillDown.error = null
this.drillDown.denied = false
},
},
})
export type { AnalyticsErrorKind }
+45 -19
View File
@@ -1,23 +1,49 @@
import type { GoalItem } from 'taskview-api'
import type { CollaborationUsers } from './collaboration.types'
import type { AppResponse } from './global-app.types'
import type { TaskItem } from './tasks.types'
import type {
AnalyticsAvailableGoal,
AnalyticsDrillDownTask,
AnalyticsPeriod,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionsResponse,
} from 'taskview-api'
export type AnalyticsStoreState = {
loading: boolean;
tasks: TaskItem[];
users: CollaborationUsers;
tasksForProject: TaskItem[];
};
export type AnalyticsDrillDownState = {
open: boolean
loading: boolean
sectionId: string | null
sectionTitle: string | null
bucket: string | null
tasks: AnalyticsDrillDownTask[]
error: AnalyticsError
denied: boolean
}
export type FetchAnalyticsDataResponse = AppResponse<{
tasks: AnalyticsStoreState['tasks'];
users: CollaborationUsers;
}>;
export type AnalyticsErrorKind = 'forbidden' | 'network' | 'server' | 'unknown'
export type FetchAnalyticsTasksArg = { startDate: string; endDate: string };
export type AnalyticsError = {
kind: AnalyticsErrorKind
status?: number
} | null
export type FetchAnalyticsForProject = {
goalId: GoalItem['id'];
dates: FetchAnalyticsTasksArg;
};
export type AnalyticsState = {
scope: AnalyticsScope
period: AnalyticsPeriod
customFrom: string | null
customTo: string | null
sections: AnalyticsSection[]
failedSectionIds: string[]
availableGoals: AnalyticsAvailableGoal[]
range: AnalyticsSectionsResponse['range'] | null
loading: boolean
error: AnalyticsError
drillDown: AnalyticsDrillDownState
}
export type AnalyticsOpenDrillDownArgs = {
sectionId: string
sectionTitle: string
bucket: string
index: number
datasetId: string
meta?: Record<string, unknown>
}