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