diff --git a/api/.env.example b/api/.env.example index 6b586d8..bbb732c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -52,6 +52,13 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/ # Path to Firebase service account JSON file # FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json +# Analytics (optional) +# Comma-separated list of section IDs to enable in the analytics page. +# When unset, all sections are available. When set, only the listed IDs are +# available — in the order they appear here. +# Example: +# ANALYTICS_SECTIONS=kpi.total_income,kpi.total_expense,chart.income_expense_month,chart.income_expense_per_project + # Centrifugo (real-time notifications, optional) # CENTRIFUGO_API_URL=http://localhost:8000 # CENTRIFUGO_API_KEY=your_centrifugo_api_key_here diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 0ac18b9..d20ec1c 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -550,5 +550,16 @@ "Updated descriptions of timetracking_can_view and timetracking_can_manage_all to document that these permissions also expose contributor emails through the time-entry log", "Tightened timetracking_can_log description: it grants only start/stop/createManual, NOT edit/delete (including own entries). Edit/delete now requires timetracking_can_manage_all — see can-access-time-entry middleware change." ] + }, + "44": { + "version": "1.51.0", + "name": "Release 1.51.0", + "releaseDate": "20260517", + "scripts": [ + "/1.51.0/0.tasks_to_tags_indexes.sql" + ], + "description": [ + "Added btree indexes on tasks.tasks_to_tags(task_id) and (tag_id) — FK constraints do not create indexes in Postgres, so anti-joins (NOT EXISTS for untagged tasks) and inner joins from tasks were seq-scanning. Needed for tag-financial analytics charts." + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.51.0/0.tasks_to_tags_indexes.sql b/api/src/migrations/taskview/sql/1.51.0/0.tasks_to_tags_indexes.sql new file mode 100644 index 0000000..6ee8ed6 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.51.0/0.tasks_to_tags_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_task_id + ON tasks.tasks_to_tags(task_id); + +CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_tag_id + ON tasks.tasks_to_tags(tag_id); diff --git a/api/src/tv-modules/analytics/AnalyticsRepository.ts b/api/src/tv-modules/analytics/AnalyticsRepository.ts index 909e089..2a85ecd 100644 --- a/api/src/tv-modules/analytics/AnalyticsRepository.ts +++ b/api/src/tv-modules/analytics/AnalyticsRepository.ts @@ -7,6 +7,8 @@ import type { ActiveProjectsSectionRow, AgingOpenTasksSectionRow, AmountCoverageKpiRow, + AmountPerProjectMonthSectionRow, + AmountPerTagMonthSectionRow, BlockedByDependenciesSectionRow, CompletedTasksKpiRow, CreatedTasksKpiRow, @@ -30,7 +32,8 @@ import type { TotalIncomeKpiRow, WorkloadByAssigneeSectionRow, } from './sections/row.types' -import type { AnalyticsRange, DrillDownTaskRow } from './types' +import { UNTAGGED_TAG_ID } from './types' +import type { AnalyticsRange, DrillDownTaskRow, FetchAmountPerProjectMonthArgs, FetchAmountPerTagMonthArgs } from './types' type Bucket = 'day' | 'week' | 'month' @@ -619,6 +622,118 @@ export class AnalyticsRepository { return result.rows as IncomeExpensePerProjectSectionRow[] } + async fetchAmountPerTagMonth(args: FetchAmountPerTagMonthArgs): Promise { + const { goalIds, range, transactionType } = args + const result = await this.db.dbDrizzle.execute(sql` + with months as ( + select generate_series( + date_trunc('month', ${range.from.toISOString()}::timestamp), + date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'), + '1 month'::interval + ) as month + ), + period_tasks as ( + select t.id, + coalesce(t.amount, 0)::float as amount, + date_trunc('month', t.date_complete) as month + from tasks.tasks t + where t.goal_id = any(${toIntArraySql(goalIds)}) + and t.complete = true + and t.date_complete is not null + and t.amount is not null + and t.transaction_type = ${transactionType} + and t.date_complete >= ${range.from.toISOString()} + and t.date_complete < ${range.to.toISOString()} + ), + task_buckets as ( + select pt.id, pt.amount, pt.month, tg.id as tag_id, tg.name as tag_name + from period_tasks pt + join tasks.tasks_to_tags tt on tt.task_id = pt.id + join tasks.tags tg on tg.id = tt.tag_id + union all + select pt.id, pt.amount, pt.month, ${UNTAGGED_TAG_ID} as tag_id, '' as tag_name + from period_tasks pt + where not exists ( + select 1 from tasks.tasks_to_tags tt where tt.task_id = pt.id + ) + ), + tag_totals as ( + select tag_id, max(tag_name) as tag_name, sum(amount) as total + from task_buckets + group by tag_id + having sum(amount) > 0 + order by total desc + ), + monthly as ( + select tb.month, tb.tag_id, sum(tb.amount)::float as amount + from task_buckets tb + join tag_totals tt on tt.tag_id = tb.tag_id + group by tb.month, tb.tag_id + ) + select + to_char(m.month, 'YYYY-MM') as month, + tt.tag_id::int as tag_id, + tt.tag_name as tag_name, + coalesce(mn.amount, 0)::float as amount + from months m + cross join tag_totals tt + left join monthly mn on mn.month = m.month and mn.tag_id = tt.tag_id + order by tt.total desc, m.month asc + `) + return result.rows as AmountPerTagMonthSectionRow[] + } + + async fetchAmountPerProjectMonth(args: FetchAmountPerProjectMonthArgs): Promise { + const { goalIds, range, transactionType } = args + const result = await this.db.dbDrizzle.execute(sql` + with months as ( + select generate_series( + date_trunc('month', ${range.from.toISOString()}::timestamp), + date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'), + '1 month'::interval + ) as month + ), + filtered as ( + select date_trunc('month', t.date_complete) as month, + g.id as goal_id, + g.name as goal_name, + t.amount::float as amount + from tasks.tasks t + join tasks.goals g on g.id = t.goal_id + where g.id = any(${toIntArraySql(goalIds)}) + and t.complete = true + and t.date_complete is not null + and t.amount is not null + and t.transaction_type = ${transactionType} + and t.date_complete >= ${range.from.toISOString()} + and t.date_complete < ${range.to.toISOString()} + ), + project_totals as ( + select goal_id, max(goal_name) as goal_name, sum(amount) as total + from filtered + group by goal_id + having sum(amount) > 0 + order by total desc + ), + monthly as ( + select f.month, f.goal_id, sum(f.amount)::float as amount + from filtered f + join project_totals pt on pt.goal_id = f.goal_id + group by f.month, f.goal_id + ) + select + to_char(m.month, 'YYYY-MM') as month, + pt.goal_id::int as goal_id, + pt.goal_name as goal_name, + coalesce(mn.amount, 0)::float as amount + from months m + cross join project_totals pt + left join monthly mn on mn.month = m.month and mn.goal_id = pt.goal_id + order by pt.total desc, m.month asc + `) + return result.rows as AmountPerProjectMonthSectionRow[] + } + async fetchTopProjectsByAmount(goalIds: number[]): Promise { const result = await this.db.dbDrizzle.execute(sql` select * from ( diff --git a/api/src/tv-modules/analytics/sections/SectionRegistry.ts b/api/src/tv-modules/analytics/sections/SectionRegistry.ts index badc24d..12a9a08 100644 --- a/api/src/tv-modules/analytics/sections/SectionRegistry.ts +++ b/api/src/tv-modules/analytics/sections/SectionRegistry.ts @@ -4,19 +4,23 @@ 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 { 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 { 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 { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection' import { StaleTasksSection } from './quality/StaleTasksSection' -// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection' -// import { StatusDistributionSection } from './usage/StatusDistributionSection' +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 { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection' +import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection' +import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection' +import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection' import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection' import { AmountCoverageKpi } from './financial/AmountCoverageKpi' import { TotalIncomeKpi } from './financial/TotalIncomeKpi' @@ -24,6 +28,7 @@ import { TotalExpenseKpi } from './financial/TotalExpenseKpi' import { NetProfitKpi } from './financial/NetProfitKpi' import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi' import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi' +import { parseEnabledSectionIds } from './sectionEnv' const builders: SectionBuilder[] = [ // KPI @@ -39,43 +44,62 @@ const builders: SectionBuilder[] = [ new AmountCoverageKpi(), // Productivity new ThroughputSection(), - // new PriorityMixOverTimeSection(), + new PriorityMixOverTimeSection(), // Workload new WorkloadByAssigneeSection(), - // new BlockedByDependenciesSection(), - // new TimeInKanbanStatusSection(), - // new AgingOpenTasksSection(), + new BlockedByDependenciesSection(), + new TimeInKanbanStatusSection(), + new AgingOpenTasksSection(), // Quality new OverdueByAgeSection(), - // new CycleTimeHistogramSection(), + new CycleTimeHistogramSection(), new StaleTasksSection(), - // new CycleTimePerProjectSection(), + new CycleTimePerProjectSection(), // Usage - // new StatusDistributionSection(), + new StatusDistributionSection(), new ActiveProjectsSection(), // Financial new IncomeExpenseMonthSection(), new IncomeExpensePerProjectSection(), + new IncomePerProjectMonthSection(), + new ExpensePerProjectMonthSection(), + new IncomePerTagMonthSection(), + new ExpensePerTagMonthSection(), new TopProjectsByAmountSection(), ] export class SectionRegistry { private readonly byId: Map + private readonly enabledOrder: SectionBuilder[] constructor() { this.byId = new Map(builders.map(b => [b.id, b])) + + const enabledIds = parseEnabledSectionIds(process.env.ANALYTICS_SECTIONS) + if (enabledIds && enabledIds.length > 0) { + this.enabledOrder = enabledIds + .map((id: string) => this.byId.get(id)) + .filter((b: SectionBuilder | undefined): b is SectionBuilder => !!b) + } else { + this.enabledOrder = [...builders] + } } all(): SectionBuilder[] { - return [...this.byId.values()] + return [...this.enabledOrder] } get(id: string): SectionBuilder | undefined { - return this.byId.get(id) + const builder = this.byId.get(id) + if (!builder) return undefined + return this.enabledOrder.includes(builder) ? builder : undefined } 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) + const enabledSet = new Set(this.enabledOrder) + return ids + .map(id => this.byId.get(id)) + .filter((b): b is SectionBuilder => !!b && enabledSet.has(b)) } } diff --git a/api/src/tv-modules/analytics/sections/financial/ExpensePerProjectMonthSection.ts b/api/src/tv-modules/analytics/sections/financial/ExpensePerProjectMonthSection.ts new file mode 100644 index 0000000..989b507 --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/ExpensePerProjectMonthSection.ts @@ -0,0 +1,56 @@ +import type { AnalyticsSection } from 'taskview-api' +import type { BuilderContext, SectionBuilder } from '../../types' +import { sectionLocales } from '../locales' +import { buildProjectAmountPayload } from './projectAmountPayload' + +export class ExpensePerProjectMonthSection implements SectionBuilder { + readonly id = 'chart.expense_per_project_month' + readonly group = 'financial' as const + readonly allowedChartTypes = ['line', 'bar'] as const + readonly defaultChartType = 'line' as const + readonly cacheTtlSec = 900 + + private get loc() { + return sectionLocales[this.id] + } + + async build(ctx: BuilderContext): Promise { + if (ctx.accessibleGoalIds.length === 0) return this.empty() + + const rows = await ctx.repository.fetchAmountPerProjectMonth({ + goalIds: ctx.accessibleGoalIds, + range: ctx.range, + transactionType: 0, + }) + const loc = this.loc + const payload = buildProjectAmountPayload({ + rows, + 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: 'date', datasets: [], unit: 'currency' }, + generatedAt: new Date().toISOString(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/ExpensePerTagMonthSection.ts b/api/src/tv-modules/analytics/sections/financial/ExpensePerTagMonthSection.ts new file mode 100644 index 0000000..41b776c --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/ExpensePerTagMonthSection.ts @@ -0,0 +1,56 @@ +import type { AnalyticsSection } from 'taskview-api' +import type { BuilderContext, SectionBuilder } from '../../types' +import { sectionLocales } from '../locales' +import { buildTagAmountPayload } from './tagAmountPayload' + +export class ExpensePerTagMonthSection implements SectionBuilder { + readonly id = 'chart.expense_per_tag_month' + readonly group = 'financial' as const + readonly allowedChartTypes = ['line', 'bar'] as const + readonly defaultChartType = 'line' as const + readonly cacheTtlSec = 900 + + private get loc() { + return sectionLocales[this.id] + } + + async build(ctx: BuilderContext): Promise { + if (ctx.accessibleGoalIds.length === 0) return this.empty() + + const rows = await ctx.repository.fetchAmountPerTagMonth({ + goalIds: ctx.accessibleGoalIds, + range: ctx.range, + transactionType: 0, + }) + const loc = this.loc + const payload = buildTagAmountPayload({ + rows, + 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: 'date', datasets: [], unit: 'currency' }, + generatedAt: new Date().toISOString(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/IncomePerProjectMonthSection.ts b/api/src/tv-modules/analytics/sections/financial/IncomePerProjectMonthSection.ts new file mode 100644 index 0000000..ebf09dc --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/IncomePerProjectMonthSection.ts @@ -0,0 +1,56 @@ +import type { AnalyticsSection } from 'taskview-api' +import type { BuilderContext, SectionBuilder } from '../../types' +import { sectionLocales } from '../locales' +import { buildProjectAmountPayload } from './projectAmountPayload' + +export class IncomePerProjectMonthSection implements SectionBuilder { + readonly id = 'chart.income_per_project_month' + readonly group = 'financial' as const + readonly allowedChartTypes = ['line', 'bar'] as const + readonly defaultChartType = 'line' as const + readonly cacheTtlSec = 900 + + private get loc() { + return sectionLocales[this.id] + } + + async build(ctx: BuilderContext): Promise { + if (ctx.accessibleGoalIds.length === 0) return this.empty() + + const rows = await ctx.repository.fetchAmountPerProjectMonth({ + goalIds: ctx.accessibleGoalIds, + range: ctx.range, + transactionType: 1, + }) + const loc = this.loc + const payload = buildProjectAmountPayload({ + rows, + 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: 'date', datasets: [], unit: 'currency' }, + generatedAt: new Date().toISOString(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/IncomePerTagMonthSection.ts b/api/src/tv-modules/analytics/sections/financial/IncomePerTagMonthSection.ts new file mode 100644 index 0000000..ad2782c --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/IncomePerTagMonthSection.ts @@ -0,0 +1,56 @@ +import type { AnalyticsSection } from 'taskview-api' +import type { BuilderContext, SectionBuilder } from '../../types' +import { sectionLocales } from '../locales' +import { buildTagAmountPayload } from './tagAmountPayload' + +export class IncomePerTagMonthSection implements SectionBuilder { + readonly id = 'chart.income_per_tag_month' + readonly group = 'financial' as const + readonly allowedChartTypes = ['line', 'bar'] as const + readonly defaultChartType = 'line' as const + readonly cacheTtlSec = 900 + + private get loc() { + return sectionLocales[this.id] + } + + async build(ctx: BuilderContext): Promise { + if (ctx.accessibleGoalIds.length === 0) return this.empty() + + const rows = await ctx.repository.fetchAmountPerTagMonth({ + goalIds: ctx.accessibleGoalIds, + range: ctx.range, + transactionType: 1, + }) + const loc = this.loc + const payload = buildTagAmountPayload({ + rows, + 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: 'date', datasets: [], unit: 'currency' }, + generatedAt: new Date().toISOString(), + } + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/projectAmountPayload.ts b/api/src/tv-modules/analytics/sections/financial/projectAmountPayload.ts new file mode 100644 index 0000000..5cc496d --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/projectAmountPayload.ts @@ -0,0 +1,54 @@ +import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api' +import type { AmountPerProjectMonthSectionRow } from '../row.types' + +export type BuildProjectAmountPayloadArgs = { + rows: AmountPerProjectMonthSectionRow[] + xAxisLabel?: LocalizedText + yAxisLabel?: LocalizedText +} + +export function buildProjectAmountPayload(args: BuildProjectAmountPayloadArgs): AnalyticsSeriesPayload { + const { rows, xAxisLabel, yAxisLabel } = args + + const monthSet = new Set() + const projectOrder: number[] = [] + const projectNameById = new Map() + const valuesByProject = new Map>() + + for (const row of rows) { + monthSet.add(row.month) + if (!projectNameById.has(row.goal_id)) { + projectNameById.set(row.goal_id, row.goal_name) + projectOrder.push(row.goal_id) + } + let perMonth = valuesByProject.get(row.goal_id) + if (!perMonth) { + perMonth = new Map() + valuesByProject.set(row.goal_id, perMonth) + } + perMonth.set(row.month, Number(row.amount)) + } + + const labels = [...monthSet].sort() + + const datasets: AnalyticsDataset[] = projectOrder.map((goalId) => { + const perMonth = valuesByProject.get(goalId) ?? new Map() + const name = projectNameById.get(goalId) ?? `#${goalId}` + return { + id: `project_${goalId}`, + label: { ru: name, en: name }, + values: labels.map(month => perMonth.get(month) ?? 0), + meta: { goalIds: [goalId] }, + } + }) + + return { + kind: 'series', + labels, + labelKind: 'date', + datasets, + unit: 'currency', + xAxisLabel, + yAxisLabel, + } +} diff --git a/api/src/tv-modules/analytics/sections/financial/tagAmountPayload.ts b/api/src/tv-modules/analytics/sections/financial/tagAmountPayload.ts new file mode 100644 index 0000000..5fffd9f --- /dev/null +++ b/api/src/tv-modules/analytics/sections/financial/tagAmountPayload.ts @@ -0,0 +1,61 @@ +import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api' +import type { AmountPerTagMonthSectionRow } from '../row.types' +import { UNTAGGED_TAG_ID } from '../../types' + +const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' } + +export type BuildTagAmountPayloadArgs = { + rows: AmountPerTagMonthSectionRow[] + xAxisLabel?: LocalizedText + yAxisLabel?: LocalizedText +} + +export function buildTagAmountPayload(args: BuildTagAmountPayloadArgs): AnalyticsSeriesPayload { + const { rows, xAxisLabel, yAxisLabel } = args + + const monthSet = new Set() + const tagOrder: number[] = [] + const tagNameById = new Map() + const valuesByTag = new Map>() + + for (const row of rows) { + monthSet.add(row.month) + if (!tagNameById.has(row.tag_id)) { + tagNameById.set( + row.tag_id, + row.tag_id === UNTAGGED_TAG_ID ? UNTAGGED_LABEL : { ru: row.tag_name, en: row.tag_name }, + ) + tagOrder.push(row.tag_id) + } + let perMonth = valuesByTag.get(row.tag_id) + if (!perMonth) { + perMonth = new Map() + valuesByTag.set(row.tag_id, perMonth) + } + perMonth.set(row.month, Number(row.amount)) + } + + const labels = [...monthSet].sort() + + const datasets: AnalyticsDataset[] = tagOrder.map((tagId) => { + const perMonth = valuesByTag.get(tagId) ?? new Map() + const isUntagged = tagId === UNTAGGED_TAG_ID + return { + id: isUntagged ? 'untagged' : `tag_${tagId}`, + label: tagNameById.get(tagId) ?? { ru: '', en: '' }, + values: labels.map(month => perMonth.get(month) ?? 0), + colorToken: isUntagged ? 'neutral' : undefined, + meta: isUntagged ? { untagged: true } : { tagIds: [tagId] }, + } + }) + + return { + kind: 'series', + labels, + labelKind: 'date', + datasets, + unit: 'currency', + xAxisLabel, + yAxisLabel, + } +} diff --git a/api/src/tv-modules/analytics/sections/locales.ts b/api/src/tv-modules/analytics/sections/locales.ts index 6a3f457..f4f6fe2 100644 --- a/api/src/tv-modules/analytics/sections/locales.ts +++ b/api/src/tv-modules/analytics/sections/locales.ts @@ -658,6 +658,166 @@ export const sectionLocales = { yAxisLabel: { ru: 'Сумма', en: 'Amount' }, }, + 'chart.income_per_project_month': { + title: { ru: 'Доходы по проектам по месяцам', en: 'Income per project by month' }, + description: { + ru: 'Линия дохода по каждому проекту в разрезе месяцев', + en: 'Income line per project broken down by month', + }, + help: { + summary: { + ru: 'Доходы по проектам в каждом месяце периода', + en: 'Income per project in each month of the period', + }, + details: { + ru: join([ + 'Что показывает:', + 'Для каждого проекта — линия (или столбцы) с суммой доходов по месяцам. Берутся только завершённые задачи с типом «доход» и заполненной суммой.', + '', + 'В отличие от «Доходы и расходы по проектам» (агрегат за весь период), здесь видно динамику: какой проект растёт по доходам, какой стагнирует, где был просадок.', + '', + 'Что не попадает:', + '• Задачи без указанной суммы', + '• Задачи без типа транзакции', + '• Незавершённые задачи (плановые доходы)', + '• Проекты без единого доходного завершения за период', + ]), + en: join([ + 'What it shows:', + 'For each project — a line (or bars) of monthly income totals. Only completed tasks with type "income" and a filled-in amount are counted.', + '', + 'Unlike "Income and expense per project" (total over the whole period), this surfaces the trend: which project grows in revenue, which stagnates, where the dips were.', + '', + 'What is NOT included:', + '• Tasks without an amount', + '• Tasks without a transaction type', + '• Open tasks (planned income)', + '• Projects with no completed income tasks in the period', + ]), + }, + }, + xAxisLabel: { ru: 'Месяц', en: 'Month' }, + yAxisLabel: { ru: 'Доход', en: 'Income' }, + }, + + 'chart.expense_per_project_month': { + title: { ru: 'Расходы по проектам по месяцам', en: 'Expense per project by month' }, + description: { + ru: 'Линия расхода по каждому проекту в разрезе месяцев', + en: 'Expense line per project broken down by month', + }, + help: { + summary: { + ru: 'Расходы по проектам в каждом месяце периода', + en: 'Expense per project in each month of the period', + }, + details: { + ru: join([ + 'Что показывает:', + 'Для каждого проекта — линия (или столбцы) с суммой расходов по месяцам. Берутся только завершённые задачи с типом «расход» и заполненной суммой.', + '', + 'В отличие от «Доходы и расходы по проектам» (агрегат за весь период), здесь видно динамику: какой проект разгоняет траты, в каком месяце был пик расходов, какой проект «остыл».', + '', + 'Что не попадает:', + '• Задачи без указанной суммы', + '• Задачи без типа транзакции', + '• Незавершённые задачи (плановые расходы)', + '• Проекты без единого расходного завершения за период', + ]), + en: join([ + 'What it shows:', + 'For each project — a line (or bars) of monthly expense totals. Only completed tasks with type "expense" and a filled-in amount are counted.', + '', + 'Unlike "Income and expense per project" (total over the whole period), this surfaces the trend: which project is ramping up spend, where the peak month was, which project has cooled off.', + '', + 'What is NOT included:', + '• Tasks without an amount', + '• Tasks without a transaction type', + '• Open tasks (planned expenses)', + '• Projects with no completed expense tasks in the period', + ]), + }, + }, + xAxisLabel: { ru: 'Месяц', en: 'Month' }, + yAxisLabel: { ru: 'Расход', en: 'Expense' }, + }, + + 'chart.income_per_tag_month': { + title: { ru: 'Доходы по тегам по месяцам', en: 'Income per tag by month' }, + description: { + ru: 'Линия дохода по каждому тегу в разрезе месяцев', + en: 'Income line per tag broken down by month', + }, + help: { + summary: { + ru: 'Доходы по топ-тегам в каждом месяце периода', + en: 'Income for the top tags in each month of the period', + }, + details: { + ru: join([ + 'Что показывает:', + 'Для каждого тега — линия (или столбцы) с суммой доходов по месяцам. Берутся только завершённые задачи с типом «доход» и заполненной суммой.', + '', + 'Как считаются задачи с несколькими тегами:', + 'Сумма задачи учитывается полностью для каждого её тега. Поэтому суммирование по всем тегам обычно больше реальной выручки — это нормально.', + '', + 'Категория «Без тегов»:', + 'Задачи без единого тега показываются отдельной серией. Большая доля «Без тегов» = слабая категоризация финансов.', + ]), + en: join([ + 'What it shows:', + 'For each tag — a line (or bars) of monthly income totals. Only completed tasks with type "income" and a filled-in amount are counted.', + '', + 'How tasks with multiple tags are counted:', + 'Each tag receives the full task amount. Summing across tags usually exceeds real revenue — that is expected.', + '', + 'The "Untagged" category:', + 'Tasks without any tag appear as a separate series. A large untagged share means weak financial categorization.', + ]), + }, + }, + xAxisLabel: { ru: 'Месяц', en: 'Month' }, + yAxisLabel: { ru: 'Доход', en: 'Income' }, + }, + + 'chart.expense_per_tag_month': { + title: { ru: 'Расходы по тегам по месяцам', en: 'Expense per tag by month' }, + description: { + ru: 'Линия расхода по каждому тегу в разрезе месяцев', + en: 'Expense line per tag broken down by month', + }, + help: { + summary: { + ru: 'Расходы по топ-тегам в каждом месяце периода', + en: 'Expense for the top tags in each month of the period', + }, + details: { + ru: join([ + 'Что показывает:', + 'Для каждого тега — линия (или столбцы) с суммой расходов по месяцам. Берутся только завершённые задачи с типом «расход» и заполненной суммой.', + '', + 'Как считаются задачи с несколькими тегами:', + 'Сумма задачи учитывается полностью для каждого её тега. Поэтому суммирование по всем тегам обычно больше реальных затрат — это нормально.', + '', + 'Категория «Без тегов»:', + 'Задачи без единого тега показываются отдельной серией. Большая доля «Без тегов» = слабая категоризация расходов.', + ]), + en: join([ + 'What it shows:', + 'For each tag — a line (or bars) of monthly expense totals. Only completed tasks with type "expense" and a filled-in amount are counted.', + '', + 'How tasks with multiple tags are counted:', + 'Each tag receives the full task amount. Summing across tags usually exceeds real spend — that is expected.', + '', + 'The "Untagged" category:', + 'Tasks without any tag appear as a separate series. A large untagged share means weak expense categorization.', + ]), + }, + }, + xAxisLabel: { ru: 'Месяц', en: 'Month' }, + yAxisLabel: { ru: 'Расход', en: 'Expense' }, + }, + 'chart.top_projects_by_amount': { title: { ru: 'Топ проектов по сумме', en: 'Top projects by amount' }, description: { ru: 'Суммарный доход и расход в каждом проекте', en: 'Total income and expense per project' }, diff --git a/api/src/tv-modules/analytics/sections/row.types.ts b/api/src/tv-modules/analytics/sections/row.types.ts index ea1c6eb..22227d5 100644 --- a/api/src/tv-modules/analytics/sections/row.types.ts +++ b/api/src/tv-modules/analytics/sections/row.types.ts @@ -128,3 +128,17 @@ export type TopProjectsByAmountSectionRow = { income: number expense: number } + +export type AmountPerTagMonthSectionRow = { + month: string + tag_id: number + tag_name: string + amount: number +} + +export type AmountPerProjectMonthSectionRow = { + month: string + goal_id: number + goal_name: string + amount: number +} diff --git a/api/src/tv-modules/analytics/sections/sectionEnv.ts b/api/src/tv-modules/analytics/sections/sectionEnv.ts new file mode 100644 index 0000000..4d63d7c --- /dev/null +++ b/api/src/tv-modules/analytics/sections/sectionEnv.ts @@ -0,0 +1,8 @@ +export function parseEnabledSectionIds(raw: string | undefined): string[] | null { + if (!raw) return null + const ids = raw + .split(',') + .map(s => s.trim()) + .filter(Boolean) + return ids.length > 0 ? ids : null +} diff --git a/api/src/tv-modules/analytics/types.ts b/api/src/tv-modules/analytics/types.ts index ef00d89..2e6620c 100644 --- a/api/src/tv-modules/analytics/types.ts +++ b/api/src/tv-modules/analytics/types.ts @@ -108,3 +108,20 @@ export type AnalyticsArgDrillDown = { range: AnalyticsRange arg: SectionDrillDownArg } + +export type FetchAmountPerTagMonthArgs = { + goalIds: number[] + range: AnalyticsRange + transactionType: 0 | 1 +} + +export type FetchAmountPerProjectMonthArgs = { + goalIds: number[] + range: AnalyticsRange + transactionType: 0 | 1 +} + +// Sentinel id used in tag-grouped analytics queries to represent tasks that +// have no tags assigned. tasks.tags.id is GENERATED ALWAYS AS IDENTITY (positive +// integers only), so -1 cannot collide with a real tag. +export const UNTAGGED_TAG_ID = -1 diff --git a/web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts b/web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts index c08d483..326be0a 100644 --- a/web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts +++ b/web/src/components/features/analytics/composables/useAnalyticsChartConfig.ts @@ -138,7 +138,7 @@ export function useAnalyticsChartConfig() { stacked: boolean, ): AnyChartConfig { const labels = displayLabels(payload) - const useMultiColor = payload.datasets.length === 1 && labels.length > 1 + const useMultiColor = payload.datasets.length === 1 && labels.length > 1 && payload.labelKind !== 'date' return { type: 'bar', @@ -240,7 +240,7 @@ export function useAnalyticsChartConfig() { interaction: { mode: 'index' as const, intersect: false }, plugins: { legend: { - display: payload.datasets.length > 1, + display: payload.datasets.length > 1 || payload.labelKind === 'date', position: 'bottom' as const, }, tooltip: tooltipConfig(payload), diff --git a/web/src/components/features/analytics/composables/useAnalyticsTheme.ts b/web/src/components/features/analytics/composables/useAnalyticsTheme.ts index 0fc5c43..299f160 100644 --- a/web/src/components/features/analytics/composables/useAnalyticsTheme.ts +++ b/web/src/components/features/analytics/composables/useAnalyticsTheme.ts @@ -9,23 +9,30 @@ const palette: Record = { info: '#3b82f6', } -const fallbackOrder: AnalyticsColorToken[] = [ - 'primary', - 'info', - 'warning', - 'success', - 'danger', - 'neutral', +// Distinct hues for datasets that have no semantic colorToken. Excludes green +// and red so they don't visually collide with the success/danger semantics +// used on other charts. +const fallbackPalette: string[] = [ + '#3b82f6', // blue + '#a855f7', // purple + '#ec4899', // pink + '#f59e0b', // amber + '#14b8a6', // teal + '#6366f1', // indigo + '#06b6d4', // cyan + '#f97316', // orange + '#8b5cf6', // violet + '#d946ef', // fuchsia ] export function useAnalyticsTheme() { function colorFor(token: AnalyticsColorToken | undefined, index = 0): string { if (token) return palette[token] - return palette[fallbackOrder[index % fallbackOrder.length]] + return fallbackPalette[index % fallbackPalette.length] } function paletteForCount(count: number): string[] { - return Array.from({ length: count }, (_, i) => palette[fallbackOrder[i % fallbackOrder.length]]) + return Array.from({ length: count }, (_, i) => fallbackPalette[i % fallbackPalette.length]) } function transparentize(hex: string, alpha: number): string {