From fd8ea2e3281e439feab1a20540d8c22301962e87 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Mon, 27 Apr 2026 00:39:30 +0200 Subject: [PATCH 1/7] feat: analytics --- api/package.json | 1 + api/src/core/AppUser.ts | 3 + api/src/core/GoalPermissionsRepository.ts | 4 +- api/src/migrations/taskview/migrate.json | 22 + .../taskview/restore-permissions.sql | 7 + .../sql/1.45.0/0.analytics_indexes.sql | 14 + .../sql/1.46.0/0.add-analytics-permission.sql | 8 + api/src/modules/db.ts | 13 +- api/src/routes/index.ts | 2 + .../analytics/AnalyticsController.ts | 106 +++ .../tv-modules/analytics/AnalyticsManager.ts | 152 ++++ .../analytics/AnalyticsRepository.ts | 802 ++++++++++++++++++ .../tv-modules/analytics/AnalyticsRoutes.ts | 28 + api/src/tv-modules/analytics/helpers.ts | 54 ++ .../middlewares/CanAccessAnalytics.ts | 41 + .../analytics/sections/SectionRegistry.ts | 81 ++ .../sections/financial/AmountCoverageKpi.ts | 52 ++ .../financial/IncomeExpenseMonthSection.ts | 68 ++ .../IncomeExpensePerProjectSection.ts | 71 ++ .../sections/financial/NetProfitKpi.ts | 78 ++ .../sections/financial/PlannedExpenseKpi.ts | 57 ++ .../sections/financial/PlannedIncomeKpi.ts | 57 ++ .../financial/TopProjectsByAmountSection.ts | 73 ++ .../sections/financial/TotalExpenseKpi.ts | 72 ++ .../sections/financial/TotalIncomeKpi.ts | 72 ++ .../sections/kpi/CompletedTasksKpi.ts | 71 ++ .../analytics/sections/kpi/CreatedTasksKpi.ts | 71 ++ .../analytics/sections/kpi/CycleTimeKpi.ts | 52 ++ .../analytics/sections/kpi/OverdueKpi.ts | 56 ++ .../tv-modules/analytics/sections/locales.ts | 669 +++++++++++++++ .../PriorityMixOverTimeSection.ts | 68 ++ .../productivity/ThroughputSection.ts | 82 ++ .../quality/CycleTimeHistogramSection.ts | 73 ++ .../quality/CycleTimePerProjectSection.ts | 63 ++ .../sections/quality/OverdueByAgeSection.ts | 89 ++ .../sections/quality/StaleTasksSection.ts | 72 ++ .../analytics/sections/row.types.ts | 130 +++ .../sections/usage/ActiveProjectsSection.ts | 94 ++ .../usage/StatusDistributionSection.ts | 64 ++ .../workload/AgingOpenTasksSection.ts | 80 ++ .../workload/BlockedByDependenciesSection.ts | 72 ++ .../workload/TimeInKanbanStatusSection.ts | 53 ++ .../workload/WorkloadByAssigneeSection.ts | 116 +++ api/src/tv-modules/analytics/types.ts | 110 +++ .../organizations/OrganizationManager.ts | 23 +- api/src/types/auth.types.ts | 2 + api/src/utils/helpers.ts | 11 + pnpm-lock.yaml | 46 + .../taskview-api/src/api/analytics.ts | 61 ++ .../taskview-api/src/api/analytics.types.ts | 165 ++++ .../taskview-api/src/api/permissions.ts | 7 + taskview-packages/taskview-api/src/index.ts | 3 +- taskview-packages/taskview-api/src/tv.ts | 5 + web/package.json | 3 + web/src/components/UserMenu.vue | 7 + .../features/analytics/AnalyticsChart.vue | 132 +++ .../analytics/AnalyticsDrillDownSlideover.vue | 111 +++ .../features/analytics/AnalyticsFilters.vue | 74 ++ .../analytics/AnalyticsHelpButton.vue | 45 + .../features/analytics/AnalyticsKpiCard.vue | 101 +++ .../analytics/AnalyticsSectionCard.vue | 55 ++ .../features/analytics/AnalyticsSkeleton.vue | 10 + .../features/analytics/chart-setup.ts | 47 + .../composables/useAnalyticsChartConfig.ts | 287 +++++++ .../composables/useAnalyticsLocale.ts | 14 + .../composables/useAnalyticsTheme.ts | 39 + web/src/locales/en.ts | 59 ++ web/src/locales/ru.ts | 59 ++ web/src/main.ts | 5 + web/src/pages/user/analytics.vue | 216 +++++ web/src/stores/analytics.store.ts | 196 +++++ web/src/types/analytics.types.ts | 62 +- 72 files changed, 5843 insertions(+), 25 deletions(-) create mode 100644 api/src/migrations/taskview/sql/1.45.0/0.analytics_indexes.sql create mode 100644 api/src/migrations/taskview/sql/1.46.0/0.add-analytics-permission.sql create mode 100644 api/src/tv-modules/analytics/AnalyticsController.ts create mode 100644 api/src/tv-modules/analytics/AnalyticsManager.ts create mode 100644 api/src/tv-modules/analytics/AnalyticsRepository.ts create mode 100644 api/src/tv-modules/analytics/AnalyticsRoutes.ts create mode 100644 api/src/tv-modules/analytics/helpers.ts create mode 100644 api/src/tv-modules/analytics/middlewares/CanAccessAnalytics.ts create mode 100644 api/src/tv-modules/analytics/sections/SectionRegistry.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/AmountCoverageKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/IncomeExpenseMonthSection.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/IncomeExpensePerProjectSection.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/NetProfitKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/PlannedExpenseKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/PlannedIncomeKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/TopProjectsByAmountSection.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/TotalExpenseKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/financial/TotalIncomeKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/kpi/CompletedTasksKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/kpi/CreatedTasksKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/kpi/CycleTimeKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/kpi/OverdueKpi.ts create mode 100644 api/src/tv-modules/analytics/sections/locales.ts create mode 100644 api/src/tv-modules/analytics/sections/productivity/PriorityMixOverTimeSection.ts create mode 100644 api/src/tv-modules/analytics/sections/productivity/ThroughputSection.ts create mode 100644 api/src/tv-modules/analytics/sections/quality/CycleTimeHistogramSection.ts create mode 100644 api/src/tv-modules/analytics/sections/quality/CycleTimePerProjectSection.ts create mode 100644 api/src/tv-modules/analytics/sections/quality/OverdueByAgeSection.ts create mode 100644 api/src/tv-modules/analytics/sections/quality/StaleTasksSection.ts create mode 100644 api/src/tv-modules/analytics/sections/row.types.ts create mode 100644 api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts create mode 100644 api/src/tv-modules/analytics/sections/usage/StatusDistributionSection.ts create mode 100644 api/src/tv-modules/analytics/sections/workload/AgingOpenTasksSection.ts create mode 100644 api/src/tv-modules/analytics/sections/workload/BlockedByDependenciesSection.ts create mode 100644 api/src/tv-modules/analytics/sections/workload/TimeInKanbanStatusSection.ts create mode 100644 api/src/tv-modules/analytics/sections/workload/WorkloadByAssigneeSection.ts create mode 100644 api/src/tv-modules/analytics/types.ts create mode 100644 taskview-packages/taskview-api/src/api/analytics.ts create mode 100644 taskview-packages/taskview-api/src/api/analytics.types.ts create mode 100644 web/src/components/features/analytics/AnalyticsChart.vue create mode 100644 web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue create mode 100644 web/src/components/features/analytics/AnalyticsFilters.vue create mode 100644 web/src/components/features/analytics/AnalyticsHelpButton.vue create mode 100644 web/src/components/features/analytics/AnalyticsKpiCard.vue create mode 100644 web/src/components/features/analytics/AnalyticsSectionCard.vue create mode 100644 web/src/components/features/analytics/AnalyticsSkeleton.vue create mode 100644 web/src/components/features/analytics/chart-setup.ts create mode 100644 web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts create mode 100644 web/src/components/features/analytics/composables/useAnalyticsLocale.ts create mode 100644 web/src/components/features/analytics/composables/useAnalyticsTheme.ts create mode 100644 web/src/pages/user/analytics.vue create mode 100644 web/src/stores/analytics.store.ts diff --git a/api/package.json b/api/package.json index 5e470bb..a263f51 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/core/AppUser.ts b/api/src/core/AppUser.ts index 8a31098..07e03c1 100644 --- a/api/src/core/AppUser.ts +++ b/api/src/core/AppUser.ts @@ -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 { diff --git a/api/src/core/GoalPermissionsRepository.ts b/api/src/core/GoalPermissionsRepository.ts index 2187b35..9512963 100644 --- a/api/src/core/GoalPermissionsRepository.ts +++ b/api/src/core/GoalPermissionsRepository.ts @@ -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;`; diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 9668e26..6549230 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -472,5 +472,27 @@ "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" + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/restore-permissions.sql b/api/src/migrations/taskview/restore-permissions.sql index 8c36599..1a0ad4b 100644 --- a/api/src/migrations/taskview/restore-permissions.sql +++ b/api/src/migrations/taskview/restore-permissions.sql @@ -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; diff --git a/api/src/migrations/taskview/sql/1.45.0/0.analytics_indexes.sql b/api/src/migrations/taskview/sql/1.45.0/0.analytics_indexes.sql new file mode 100644 index 0000000..42e155a --- /dev/null +++ b/api/src/migrations/taskview/sql/1.45.0/0.analytics_indexes.sql @@ -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; diff --git a/api/src/migrations/taskview/sql/1.46.0/0.add-analytics-permission.sql b/api/src/migrations/taskview/sql/1.46.0/0.add-analytics-permission.sql new file mode 100644 index 0000000..c300241 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.46.0/0.add-analytics-permission.sql @@ -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; diff --git a/api/src/modules/db.ts b/api/src/modules/db.ts index 873add5..6ac2501 100644 --- a/api/src/modules/db.ts +++ b/api/src/modules/db.ts @@ -8,13 +8,24 @@ export class Database { public dbDrizzle: ReturnType; 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) { diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 7715588..578623d 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -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 = { '/module/sessions': SessionsRoutes, '/module/organizations': OrganizationRoutes, '/module/sso': SsoRoutes, + '/module/analytics': AnalyticsRoutes, '/scim/v2': ScimRoutes, }; diff --git a/api/src/tv-modules/analytics/AnalyticsController.ts b/api/src/tv-modules/analytics/AnalyticsController.ts new file mode 100644 index 0000000..0a61dd4 --- /dev/null +++ b/api/src/tv-modules/analytics/AnalyticsController.ts @@ -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) + } + } +} diff --git a/api/src/tv-modules/analytics/AnalyticsManager.ts b/api/src/tv-modules/analytics/AnalyticsManager.ts new file mode 100644 index 0000000..bca3ce0 --- /dev/null +++ b/api/src/tv-modules/analytics/AnalyticsManager.ts @@ -0,0 +1,152 @@ +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 { + const userData = this.user.getUserData() + if (!userData?.id || !userData?.email) return [] + + const ids = await this.user.organizationManager.isCurrentUserOrgAdmin(organizationId) + ? await this.repository.fetchAllGoalIdsInOrg(organizationId) + : await this.fetchMemberAccessibleGoalIds(userData.id, userData.email, organizationId) + + return this.applyTokenFilter(ids) + } + + async buildSections(params: AnalyticsArgBuildSections): Promise { + 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 { + const { sectionId, scope, organizationId, period, range, arg } = params + + const builder = this.registry.get(sectionId) + if (!builder || !builder.drillDown) { + return { sectionId, tasks: [], total: 0 } + } + + 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 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 fetchMemberAccessibleGoalIds(userId: number, email: string, organizationId: number): Promise { + return this.repository.fetchGoalIdsWithPermission( + userId, + email, + organizationId, + GoalPermissions.ANALYTICS_CAN_VIEW, + ) + } + + 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 { + if (goalIds.length === 0) return [] + return await this.repository.getGoalsForIds(goalIds) + } +} diff --git a/api/src/tv-modules/analytics/AnalyticsRepository.ts b/api/src/tv-modules/analytics/AnalyticsRepository.ts new file mode 100644 index 0000000..84068c8 --- /dev/null +++ b/api/src/tv-modules/analytics/AnalyticsRepository.ts @@ -0,0 +1,802 @@ +import { 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 = { + 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 { + const result = await callWithCatch(() => + this.db.dbDrizzle + .select({ id: GoalsSchema.id }) + .from(GoalsSchema) + .where(eq(GoalsSchema.organizationId, organizationId)), + ) + return (result ?? []).map(r => r.id).filter((id): id is number => id !== null) + } + + async fetchGoalIdsWithPermission( + userId: number, + email: string, + organizationId: number, + permissionName: string, + ): Promise { + const result = await this.db.dbDrizzle.execute<{ id: number }>(sql` + select g.id from tasks.goals g + where g.organization_id = ${organizationId} + and ( + g.owner = ${userId} + or exists ( + select 1 + 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 = ${permissionName} + ) + ) + `) + 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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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(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 { + const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket) + const goalIdsSql = toIntArraySql(goalIds) + + const result = await this.db.dbDrizzle.execute(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 { + const { trunc: bucketSql, interval: intervalSql } = bucketLiterals(bucket) + const goalIdsSql = toIntArraySql(goalIds) + + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const maxClause = maxDays !== null + ? sql`and (current_date - t.end_date) <= ${maxDays}` + : sql`` + + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + const result = await this.db.dbDrizzle.execute(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 { + 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(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 { + 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(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 { + const result = await this.db.dbDrizzle.execute(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[] + } +} diff --git a/api/src/tv-modules/analytics/AnalyticsRoutes.ts b/api/src/tv-modules/analytics/AnalyticsRoutes.ts new file mode 100644 index 0000000..688ef9e --- /dev/null +++ b/api/src/tv-modules/analytics/AnalyticsRoutes.ts @@ -0,0 +1,28 @@ +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 + 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) + this.router.get('/drill-down/:sectionId', guards, this.controller.fetchDrillDown) + } +} diff --git a/api/src/tv-modules/analytics/helpers.ts b/api/src/tv-modules/analytics/helpers.ts new file mode 100644 index 0000000..dd578ee --- /dev/null +++ b/api/src/tv-modules/analytics/helpers.ts @@ -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): 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, 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 } +} diff --git a/api/src/tv-modules/analytics/middlewares/CanAccessAnalytics.ts b/api/src/tv-modules/analytics/middlewares/CanAccessAnalytics.ts new file mode 100644 index 0000000..a851472 --- /dev/null +++ b/api/src/tv-modules/analytics/middlewares/CanAccessAnalytics.ts @@ -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.isCurrentUserOrgAdmin(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() +} diff --git a/api/src/tv-modules/analytics/sections/SectionRegistry.ts b/api/src/tv-modules/analytics/sections/SectionRegistry.ts new file mode 100644 index 0000000..3fb74df --- /dev/null +++ b/api/src/tv-modules/analytics/sections/SectionRegistry.ts @@ -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 + + 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) + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/AmountCoverageKpi.ts b/api/src/tv-modules/analytics/sections/financial/AmountCoverageKpi.ts new file mode 100644 index 0000000..78de065 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/AmountCoverageKpi.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/IncomeExpenseMonthSection.ts b/api/src/tv-modules/analytics/sections/financial/IncomeExpenseMonthSection.ts new file mode 100644 index 0000000..461e725 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/IncomeExpenseMonthSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/IncomeExpensePerProjectSection.ts b/api/src/tv-modules/analytics/sections/financial/IncomeExpensePerProjectSection.ts new file mode 100644 index 0000000..0c0eb15 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/IncomeExpensePerProjectSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/NetProfitKpi.ts b/api/src/tv-modules/analytics/sections/financial/NetProfitKpi.ts new file mode 100644 index 0000000..506e67d --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/NetProfitKpi.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/PlannedExpenseKpi.ts b/api/src/tv-modules/analytics/sections/financial/PlannedExpenseKpi.ts new file mode 100644 index 0000000..e9c2eaf --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/PlannedExpenseKpi.ts @@ -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 { + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/PlannedIncomeKpi.ts b/api/src/tv-modules/analytics/sections/financial/PlannedIncomeKpi.ts new file mode 100644 index 0000000..270dc58 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/PlannedIncomeKpi.ts @@ -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 { + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/TopProjectsByAmountSection.ts b/api/src/tv-modules/analytics/sections/financial/TopProjectsByAmountSection.ts new file mode 100644 index 0000000..eedb526 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/TopProjectsByAmountSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/TotalExpenseKpi.ts b/api/src/tv-modules/analytics/sections/financial/TotalExpenseKpi.ts new file mode 100644 index 0000000..08321a6 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/TotalExpenseKpi.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/TotalIncomeKpi.ts b/api/src/tv-modules/analytics/sections/financial/TotalIncomeKpi.ts new file mode 100644 index 0000000..e4cabcb --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/TotalIncomeKpi.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/kpi/CompletedTasksKpi.ts b/api/src/tv-modules/analytics/sections/kpi/CompletedTasksKpi.ts new file mode 100644 index 0000000..d5d1acb --- /dev/null +++ b/api/src/tv-modules/analytics/sections/kpi/CompletedTasksKpi.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/kpi/CreatedTasksKpi.ts b/api/src/tv-modules/analytics/sections/kpi/CreatedTasksKpi.ts new file mode 100644 index 0000000..6e597c3 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/kpi/CreatedTasksKpi.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/kpi/CycleTimeKpi.ts b/api/src/tv-modules/analytics/sections/kpi/CycleTimeKpi.ts new file mode 100644 index 0000000..239df1f --- /dev/null +++ b/api/src/tv-modules/analytics/sections/kpi/CycleTimeKpi.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/kpi/OverdueKpi.ts b/api/src/tv-modules/analytics/sections/kpi/OverdueKpi.ts new file mode 100644 index 0000000..052e331 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/kpi/OverdueKpi.ts @@ -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 { + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/locales.ts b/api/src/tv-modules/analytics/sections/locales.ts new file mode 100644 index 0000000..2c0f433 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/locales.ts @@ -0,0 +1,669 @@ +import type { LocalizedText } from 'taskview-api' + +export type SectionLocale = { + title: LocalizedText + description?: LocalizedText + help?: { + summary: LocalizedText + details: LocalizedText + } + datasets?: Record + 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 1–3 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' }, + }, + 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 2–3× 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' }, + }, + 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 + +export type SectionLocaleId = keyof typeof sectionLocales diff --git a/api/src/tv-modules/analytics/sections/productivity/PriorityMixOverTimeSection.ts b/api/src/tv-modules/analytics/sections/productivity/PriorityMixOverTimeSection.ts new file mode 100644 index 0000000..b4bf6c8 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/productivity/PriorityMixOverTimeSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/productivity/ThroughputSection.ts b/api/src/tv-modules/analytics/sections/productivity/ThroughputSection.ts new file mode 100644 index 0000000..caeb51f --- /dev/null +++ b/api/src/tv-modules/analytics/sections/productivity/ThroughputSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/quality/CycleTimeHistogramSection.ts b/api/src/tv-modules/analytics/sections/quality/CycleTimeHistogramSection.ts new file mode 100644 index 0000000..87349f3 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/quality/CycleTimeHistogramSection.ts @@ -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 { + 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д', '1–3д', '3–7д', '7–14д', '14–30д', '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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/quality/CycleTimePerProjectSection.ts b/api/src/tv-modules/analytics/sections/quality/CycleTimePerProjectSection.ts new file mode 100644 index 0000000..57b212a --- /dev/null +++ b/api/src/tv-modules/analytics/sections/quality/CycleTimePerProjectSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/quality/OverdueByAgeSection.ts b/api/src/tv-modules/analytics/sections/quality/OverdueByAgeSection.ts new file mode 100644 index 0000000..5018785 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/quality/OverdueByAgeSection.ts @@ -0,0 +1,89 @@ +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 { + if (ctx.accessibleGoalIds.length === 0) return this.empty() + + const row = await ctx.repository.fetchOverdueByAge(ctx.accessibleGoalIds) + const loc = this.loc + + const payload: AnalyticsSeriesPayload = { + kind: 'series', + labels: ['1–3д', '4–7д', '8–14д', '15+'], + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/quality/StaleTasksSection.ts b/api/src/tv-modules/analytics/sections/quality/StaleTasksSection.ts new file mode 100644 index 0000000..2f5be27 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/quality/StaleTasksSection.ts @@ -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 { + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/row.types.ts b/api/src/tv-modules/analytics/sections/row.types.ts new file mode 100644 index 0000000..ea1c6eb --- /dev/null +++ b/api/src/tv-modules/analytics/sections/row.types.ts @@ -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 +} diff --git a/api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts b/api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts new file mode 100644 index 0000000..e06a1f5 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts @@ -0,0 +1,94 @@ +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 STATUS_LABEL_RU: Record = { + active: 'Активен', + fading: 'Затухает', + dead: 'Мёртв', + empty: 'Без задач', +} + +const COLOR_BY_STATUS: Record = { + 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 { + if (ctx.accessibleGoalIds.length === 0) return this.empty() + + const rows = await ctx.repository.fetchActiveProjects(ctx.accessibleGoalIds) + const loc = this.loc + + const payload: AnalyticsSeriesPayload = { + kind: 'series', + labels: rows.map(r => STATUS_LABEL_RU[r.status_key]), + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/usage/StatusDistributionSection.ts b/api/src/tv-modules/analytics/sections/usage/StatusDistributionSection.ts new file mode 100644 index 0000000..b9f75dd --- /dev/null +++ b/api/src/tv-modules/analytics/sections/usage/StatusDistributionSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/workload/AgingOpenTasksSection.ts b/api/src/tv-modules/analytics/sections/workload/AgingOpenTasksSection.ts new file mode 100644 index 0000000..f6c168a --- /dev/null +++ b/api/src/tv-modules/analytics/sections/workload/AgingOpenTasksSection.ts @@ -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 { + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/workload/BlockedByDependenciesSection.ts b/api/src/tv-modules/analytics/sections/workload/BlockedByDependenciesSection.ts new file mode 100644 index 0000000..022cdec --- /dev/null +++ b/api/src/tv-modules/analytics/sections/workload/BlockedByDependenciesSection.ts @@ -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 { + 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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/workload/TimeInKanbanStatusSection.ts b/api/src/tv-modules/analytics/sections/workload/TimeInKanbanStatusSection.ts new file mode 100644 index 0000000..cf1d12c --- /dev/null +++ b/api/src/tv-modules/analytics/sections/workload/TimeInKanbanStatusSection.ts @@ -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 { + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/workload/WorkloadByAssigneeSection.ts b/api/src/tv-modules/analytics/sections/workload/WorkloadByAssigneeSection.ts new file mode 100644 index 0000000..c9a2400 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/workload/WorkloadByAssigneeSection.ts @@ -0,0 +1,116 @@ +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 { + 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 { + const userIds = arg.meta?.userIds ?? [] + const userId = userIds[arg.index] + if (!userId || ctx.accessibleGoalIds.length === 0) return [] + + const priorityByDataset: Record = { + high: 3, + medium: 2, + low: 1, + no_priority: 'null', + } + const priorityFilter = priorityByDataset[arg.datasetId] + + 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(), + } + } +} diff --git a/api/src/tv-modules/analytics/types.ts b/api/src/tv-modules/analytics/types.ts new file mode 100644 index 0000000..ef00d89 --- /dev/null +++ b/api/src/tv-modules/analytics/types.ts @@ -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 + drillDown?(ctx: BuilderContext, arg: SectionDrillDownArg): Promise +} + +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 +} diff --git a/api/src/tv-modules/organizations/OrganizationManager.ts b/api/src/tv-modules/organizations/OrganizationManager.ts index 977fb51..8c12751 100644 --- a/api/src/tv-modules/organizations/OrganizationManager.ts +++ b/api/src/tv-modules/organizations/OrganizationManager.ts @@ -1,11 +1,19 @@ import type { AppUser } from '../../core/AppUser' import { isNotNullable } from '../../utils/helpers' import { OrganizationRepository } from './OrganizationRepository' -import type { OrganizationArgCreate, OrganizationArgUpdate } from './types' +import { + ORG_ADMIN_ROLES, + type OrganizationArgCreate, + type OrganizationArgUpdate, + type OrgRole, +} from './types' + +type OrgMember = Awaited> export class OrganizationManager { public readonly repository: OrganizationRepository private readonly user: AppUser + private readonly memberCache: Map = new Map() constructor(user: AppUser) { this.user = user @@ -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 isCurrentUserOrgAdmin(orgId: number): Promise { + const member = await this.getCurrentUserMember(orgId) + if (!member) return false + return ORG_ADMIN_ROLES.includes(member.role as OrgRole) } private generateSlug(): string { diff --git a/api/src/types/auth.types.ts b/api/src/types/auth.types.ts index 32e8793..f8165c1 100644 --- a/api/src/types/auth.types.ts +++ b/api/src/types/auth.types.ts @@ -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 = diff --git a/api/src/utils/helpers.ts b/api/src/utils/helpers.ts index f8c1f21..7e7052f 100644 --- a/api/src/utils/helpers.ts +++ b/api/src/utils/helpers.ts @@ -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 = (array: T[], size: number): T[][] => { if (!Array.isArray(array)) { throw new TypeError('Expected array'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6264ef..330b194 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)): diff --git a/taskview-packages/taskview-api/src/api/analytics.ts b/taskview-packages/taskview-api/src/api/analytics.ts new file mode 100644 index 0000000..c7b28b5 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/analytics.ts @@ -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 { + 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 = { + ...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>( + `${this.moduleUrl}/sections`, + { params, signal }, + ), + ) + } + + public async fetchDrillDown(arg: AnalyticsFetchDrillDownArg, signal?: AbortSignal) { + const params: Record = { + ...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>( + `${this.moduleUrl}/drilldown/${arg.sectionId}`, + { params, signal }, + ), + ) + } +} diff --git a/taskview-packages/taskview-api/src/api/analytics.types.ts b/taskview-packages/taskview-api/src/api/analytics.types.ts new file mode 100644 index 0000000..0820974 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/analytics.types.ts @@ -0,0 +1,165 @@ +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 +} + +export type AnalyticsReferenceLine = { + id: string + label: LocalizedText + value: number + axis: 'x' | 'y' + colorToken?: AnalyticsColorToken +} + +export type AnalyticsSeriesPayload = { + kind: 'series' + labels: string[] + 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 +} + +export type AnalyticsFetchDrillDownArg = { + sectionId: string + scope: AnalyticsScope + organizationId: number + period: AnalyticsPeriod + from?: string + to?: string + bucket?: string + index?: number + datasetId?: string + meta?: Record +} diff --git a/taskview-packages/taskview-api/src/api/permissions.ts b/taskview-packages/taskview-api/src/api/permissions.ts index 3dea854..9c4e90a 100644 --- a/taskview-packages/taskview-api/src/api/permissions.ts +++ b/taskview-packages/taskview-api/src/api/permissions.ts @@ -125,6 +125,11 @@ export const TvPermissions: Record, 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; }; \ No newline at end of file diff --git a/taskview-packages/taskview-api/src/index.ts b/taskview-packages/taskview-api/src/index.ts index fecb569..51e81c8 100644 --- a/taskview-packages/taskview-api/src/index.ts +++ b/taskview-packages/taskview-api/src/index.ts @@ -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'; \ No newline at end of file +export * from '@/api/sso.types'; +export * from '@/api/analytics.types'; \ No newline at end of file diff --git a/taskview-packages/taskview-api/src/tv.ts b/taskview-packages/taskview-api/src/tv.ts index 41437c8..d4053cc 100644 --- a/taskview-packages/taskview-api/src/tv.ts +++ b/taskview-packages/taskview-api/src/tv.ts @@ -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) { diff --git a/web/package.json b/web/package.json index 7f6b677..03340cf 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/UserMenu.vue b/web/src/components/UserMenu.vue index 973df49..be10128 100644 --- a/web/src/components/UserMenu.vue +++ b/web/src/components/UserMenu.vue @@ -176,6 +176,13 @@ const items = computed(() => [ router.push({ name: 'organizations' }) }, }, + { + label: t('userMenu.analytics'), + icon: 'i-lucide-bar-chart-3', + onSelect() { + router.push({ name: 'analytics' }) + }, + }, ], [ { diff --git a/web/src/components/features/analytics/AnalyticsChart.vue b/web/src/components/features/analytics/AnalyticsChart.vue new file mode 100644 index 0000000..a27026e --- /dev/null +++ b/web/src/components/features/analytics/AnalyticsChart.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue b/web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue new file mode 100644 index 0000000..1ac4595 --- /dev/null +++ b/web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue @@ -0,0 +1,111 @@ + + + + diff --git a/web/src/components/features/analytics/AnalyticsFilters.vue b/web/src/components/features/analytics/AnalyticsFilters.vue new file mode 100644 index 0000000..0fc7c9a --- /dev/null +++ b/web/src/components/features/analytics/AnalyticsFilters.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/web/src/components/features/analytics/AnalyticsHelpButton.vue b/web/src/components/features/analytics/AnalyticsHelpButton.vue new file mode 100644 index 0000000..fec984c --- /dev/null +++ b/web/src/components/features/analytics/AnalyticsHelpButton.vue @@ -0,0 +1,45 @@ + + + + diff --git a/web/src/components/features/analytics/AnalyticsKpiCard.vue b/web/src/components/features/analytics/AnalyticsKpiCard.vue new file mode 100644 index 0000000..f064ab1 --- /dev/null +++ b/web/src/components/features/analytics/AnalyticsKpiCard.vue @@ -0,0 +1,101 @@ + + + + diff --git a/web/src/components/features/analytics/AnalyticsSectionCard.vue b/web/src/components/features/analytics/AnalyticsSectionCard.vue new file mode 100644 index 0000000..49e0a5a --- /dev/null +++ b/web/src/components/features/analytics/AnalyticsSectionCard.vue @@ -0,0 +1,55 @@ + + + + diff --git a/web/src/components/features/analytics/AnalyticsSkeleton.vue b/web/src/components/features/analytics/AnalyticsSkeleton.vue new file mode 100644 index 0000000..9d78892 --- /dev/null +++ b/web/src/components/features/analytics/AnalyticsSkeleton.vue @@ -0,0 +1,10 @@ + diff --git a/web/src/components/features/analytics/chart-setup.ts b/web/src/components/features/analytics/chart-setup.ts new file mode 100644 index 0000000..702c52f --- /dev/null +++ b/web/src/components/features/analytics/chart-setup.ts @@ -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, + ) +} diff --git a/web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts b/web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts new file mode 100644 index 0000000..d5ab1ff --- /dev/null +++ b/web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts @@ -0,0 +1,287 @@ +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 + +export function useAnalyticsChartConfig() { + const { pick } = useAnalyticsLocale() + const { colorFor, paletteForCount, transparentize } = useAnalyticsTheme() + const { t } = useI18n() + + 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 = payload.labels.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: payload.labels, + 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 useMultiColor = payload.datasets.length === 1 && payload.labels.length > 1 + + return { + type: 'bar', + data: { + labels: payload.labels, + datasets: payload.datasets.map((ds, i) => { + const singleColor = colorFor(ds.colorToken, i) + const backgroundColor = useMultiColor + ? paletteForCount(payload.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 colors = paletteForCount(payload.labels.length) + return { + type: 'doughnut', + data: { + labels: payload.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 = {} + 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 } +} diff --git a/web/src/components/features/analytics/composables/useAnalyticsLocale.ts b/web/src/components/features/analytics/composables/useAnalyticsLocale.ts new file mode 100644 index 0000000..d3adbb9 --- /dev/null +++ b/web/src/components/features/analytics/composables/useAnalyticsLocale.ts @@ -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 } +} diff --git a/web/src/components/features/analytics/composables/useAnalyticsTheme.ts b/web/src/components/features/analytics/composables/useAnalyticsTheme.ts new file mode 100644 index 0000000..0fc5c43 --- /dev/null +++ b/web/src/components/features/analytics/composables/useAnalyticsTheme.ts @@ -0,0 +1,39 @@ +import type { AnalyticsColorToken } from 'taskview-api' + +const palette: Record = { + 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 } +} diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index b4cc961..1831a9d 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -436,11 +436,70 @@ 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.', + }, + 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', + noTaskTitle: 'Untitled', + due: 'due', + created: 'created', + closed: 'closed', + }, + sectionCard: { + noData: 'No data for the selected period', + }, + }, account: { title: 'Account settings', management: 'Account management', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index a8a7b48..027ef91 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -408,11 +408,70 @@ export default { docker: 'Docker образы', accountSettings: 'Настройки аккаунта', organizations: 'Организации', + analytics: 'Аналитика', switchOrganization: 'Переключить организацию', logout: 'Выйти', logoutFailed: 'Не удалось выйти', logoutFailedDescription: 'Попробуйте очистить кэш браузера, чтобы удалить токен авторизации', }, + analytics: { + page: { + title: 'Аналитика', + subtitle: 'Показатели продуктивности и здоровья проектов', + emptyState: 'Нет данных. Создайте задачи, чтобы увидеть аналитику.', + emptyStateForPeriod: 'За выбранный период данных нет. Попробуйте увеличить период.', + partialFailure: 'Некоторые секции не удалось загрузить. Попробуйте обновить.', + }, + errors: { + forbidden: 'У вас нет доступа к этой аналитике.', + network: 'Не удалось связаться с сервером. Проверьте подключение.', + server: 'Сервер вернул ошибку. Попробуйте обновить страницу.', + unknown: 'Не удалось загрузить аналитику.', + }, + 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: 'Нет задач в этой выборке', + noTaskTitle: 'Без названия', + due: 'до', + created: 'создано', + closed: 'закрыта', + }, + sectionCard: { + noData: 'Нет данных за выбранный период', + }, + }, account: { title: 'Настройки аккаунта', management: 'Управление аккаунтом', diff --git a/web/src/main.ts b/web/src/main.ts index 834bf84..bedc115 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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', diff --git a/web/src/pages/user/analytics.vue b/web/src/pages/user/analytics.vue new file mode 100644 index 0000000..b52a9d2 --- /dev/null +++ b/web/src/pages/user/analytics.vue @@ -0,0 +1,216 @@ + + + + diff --git a/web/src/stores/analytics.store.ts b/web/src/stores/analytics.store.ts new file mode 100644 index 0000000..a52b175 --- /dev/null +++ b/web/src/stores/analytics.store.ts @@ -0,0 +1,196 @@ +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: [], + }, + }), + 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() + 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 { + 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 = [] + + 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 + } + } catch (e) { + if (controller.signal.aborted || isCancelled(e)) return + throw e + } 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 + }, + }, +}) + +export type { AnalyticsErrorKind } diff --git a/web/src/types/analytics.types.ts b/web/src/types/analytics.types.ts index e16dc49..e3c4250 100644 --- a/web/src/types/analytics.types.ts +++ b/web/src/types/analytics.types.ts @@ -1,23 +1,47 @@ -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[] +} -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 +} From 95f024275ca2ac250a4e96dd264872a2cdbd5270 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Mon, 27 Apr 2026 00:55:54 +0200 Subject: [PATCH 2/7] fix: ui issues --- .../features/analytics/AnalyticsChart.vue | 3 +++ .../analytics/AnalyticsDrillDownSlideover.vue | 21 +++++++++++++++++-- .../analytics/AnalyticsHelpButton.vue | 4 +++- web/src/locales/en.ts | 3 +++ web/src/locales/ru.ts | 3 +++ web/src/pages/user/analytics.vue | 2 ++ web/src/stores/analytics.store.ts | 6 +++++- web/src/types/analytics.types.ts | 1 + 8 files changed, 39 insertions(+), 4 deletions(-) diff --git a/web/src/components/features/analytics/AnalyticsChart.vue b/web/src/components/features/analytics/AnalyticsChart.vue index a27026e..d87dc30 100644 --- a/web/src/components/features/analytics/AnalyticsChart.vue +++ b/web/src/components/features/analytics/AnalyticsChart.vue @@ -120,7 +120,10 @@ onBeforeUnmount(() => { 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 }) diff --git a/web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue b/web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue index 1ac4595..88ad715 100644 --- a/web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue +++ b/web/src/components/features/analytics/AnalyticsDrillDownSlideover.vue @@ -3,19 +3,28 @@ v-model:open="open" :title="analyticsStore.drillDown.sectionTitle ?? t('analytics.drillDown.defaultTitle')" :description="analyticsStore.drillDown.bucket ?? ''" + :fullscreen="isMobile" >