Compare commits

...

13 Commits

Author SHA1 Message Date
Nikolai Giman 20fc40d597 chore: version 2026-05-27 19:23:14 +02:00
Nikolai Giman 913690d230 Merge pull request #60 from Gimanh/fix/59
fix: https://github.com/Gimanh/taskview-community/issues/59
2026-05-27 19:10:46 +02:00
Nikolai Giman b950b15061 fix: https://github.com/Gimanh/taskview-community/issues/59 2026-05-27 19:10:11 +02:00
Nikolai Giman f5a76b3b2a Merge pull request #58 from Gimanh/chore/vesrion-1-44
chore: version
2026-05-25 21:36:34 +02:00
Nikolai Giman 0ba43489b4 chore: version 2026-05-25 21:34:27 +02:00
Nikolai Giman f143d536b3 Merge pull request #57 from Gimanh/fix/priority-sort
fix: #55
2026-05-24 22:04:34 +02:00
Nikolai Giman 8f091ac411 fix: #55 2026-05-24 22:00:56 +02:00
Nikolai Giman acb86d1286 Merge pull request #56 from Gimanh/feat/ui-improvements
Feat/UI improvements
2026-05-24 17:10:35 +02:00
Nikolai Giman affe83751b feat: dnd for task fields and analytics 2026-05-24 17:09:45 +02:00
Nikolai Giman c200826d31 fix: org email to lower case and ui fixes 2026-05-17 14:35:37 +02:00
Nikolai Giman 065483f701 wip: new charts 2026-05-17 14:08:19 +02:00
Nikolai Giman e323a6e06e Merge pull request #54 from Gimanh/chore/version-1-43
chore: 1.43.0
2026-05-16 21:34:33 +02:00
Nikolai Giman 8972c70700 chore: 1.43.0 2026-05-16 21:28:30 +02:00
82 changed files with 2071 additions and 213 deletions
+7
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.42.5",
"version": "1.44.1",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
+4 -2
View File
@@ -7,8 +7,10 @@ config({ override: true });
const valid = AppEnvSchema.safeParse(process.env);
if (!valid.success) {
console.log(valid);
console.log(valid.error);
const details = valid.error.issues
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('\n');
console.error(`Invalid environment variables:\n${details}`);
throw new Error('Invalid environment variables');
}
+3
View File
@@ -14,6 +14,7 @@ import { SsoManager } from '../tv-modules/sso/SsoManager';
import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
import { TasksManager } from '../tv-modules/tasks/TasksManager';
import { TimeTrackingManager } from '../tv-modules/time-tracking/TimeTrackingManager';
import { UiPreferencesManager } from '../tv-modules/ui-preferences/UiPreferencesManager';
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
@@ -41,6 +42,7 @@ export class AppUser {
public readonly ssoManager: SsoManager;
public readonly analyticsManager: AnalyticsManager;
public readonly timeTrackingManager: TimeTrackingManager;
public readonly uiPreferencesManager: UiPreferencesManager;
constructor(userData?: UserJwtPayload) {
this.userData = userData;
@@ -61,6 +63,7 @@ export class AppUser {
this.ssoManager = new SsoManager(this);
this.analyticsManager = new AnalyticsManager(this);
this.timeTrackingManager = new TimeTrackingManager(this);
this.uiPreferencesManager = new UiPreferencesManager(this);
}
getTokenId(): number | undefined {
+5
View File
@@ -3,6 +3,11 @@ import { $logger } from '../modules/logget';
export class Email {
static async send(data: Message | MessageHeaders): Promise<boolean> {
if (!process.env.SMTP_HOST) {
$logger.warn('SMTP is not configured — skipping email send');
return false;
}
try {
const client = new SMTPClient({
user: process.env.SMTP_USERNAME,
+22
View File
@@ -550,5 +550,27 @@
"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."
]
},
"45": {
"version": "1.52.0",
"name": "Release 1.52.0",
"releaseDate": "20260517",
"scripts": [
"/1.52.0/0.ui_preferences.sql"
],
"description": [
"Added tv_auth.ui_preferences table: per-user JSONB storage of UI customization choices (which analytics charts and task detail fields are shown and in what order). One row per user keyed by user_id."
]
}
}
@@ -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);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS tv_auth.ui_preferences (
user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE,
prefs JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
+2
View File
@@ -18,6 +18,7 @@ import SsoRoutes from '../tv-modules/sso/SsoRoutes';
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -42,6 +43,7 @@ const routes: Record<string, RoutableConstructor> = {
'/module/sso': SsoRoutes,
'/module/analytics': AnalyticsRoutes,
'/module/time-tracking': TimeTrackingRoutes,
'/module/ui-preferences': UiPreferencesRoutes,
'/scim/v2': ScimRoutes,
};
@@ -6,6 +6,11 @@ import { parseDrillDownMeta, resolveRange } from './helpers'
import { AnalyticsDrillDownArkType, AnalyticsFetchSectionsArkType } from './types'
export class AnalyticsController {
fetchCatalog = async (req: Request, res: Response) => {
const catalog = req.appUser.analyticsManager.getCatalog()
return res.tvJson(catalog)
}
fetchSections = async (req: Request, res: Response, next: NextFunction) => {
const out = AnalyticsFetchSectionsArkType(req.query)
if (out instanceof type.errors) {
@@ -3,6 +3,7 @@ import type {
AnalyticsDrillDownResponse,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionCatalogEntry,
AnalyticsSectionsResponse,
} from 'taskview-api'
import type { AppUser } from '../../core/AppUser'
@@ -28,6 +29,10 @@ export class AnalyticsManager {
this.registry = new SectionRegistry()
}
getCatalog(): AnalyticsSectionCatalogEntry[] {
return this.registry.catalog()
}
async getAccessibleGoalIds(organizationId: number): Promise<number[]> {
return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW])
}
@@ -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<AmountPerTagMonthSectionRow[]> {
const { goalIds, range, transactionType } = args
const result = await this.db.dbDrizzle.execute<AmountPerTagMonthSectionRow>(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<AmountPerProjectMonthSectionRow[]> {
const { goalIds, range, transactionType } = args
const result = await this.db.dbDrizzle.execute<AmountPerProjectMonthSectionRow>(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<TopProjectsByAmountSectionRow[]> {
const result = await this.db.dbDrizzle.execute<TopProjectsByAmountSectionRow>(sql`
select * from (
@@ -22,6 +22,7 @@ export default class AnalyticsRoutes implements Routable {
initRoutes() {
const guards = [IsLoggedIn, RejectApiTokenAuth, CanAccessAnalytics]
this.router.get('/sections', guards, this.controller.fetchSections)
this.router.get('/sections-catalog', [IsLoggedIn], this.controller.fetchCatalog)
this.router.get('/drilldown/:sectionId', guards, this.controller.fetchDrillDown)
}
}
@@ -2,21 +2,20 @@ 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 { 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 { 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 { 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,13 +23,54 @@ import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
import { NetProfitKpi } from './financial/NetProfitKpi'
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
import type { AnalyticsSectionCatalogEntry } from 'taskview-api'
import { parseEnabledSectionIds } from './sectionEnv'
import { sectionLocales } from './locales'
// ---------------------------------------------------------------------------
// Disabled sections — re-enable once the underlying data model can support them
// honestly. Until then they would report misleading numbers.
//
// 1. AgingOpenTasksSection (chart.aging_open_tasks)
// Measures (now - date_creation) for every open task per assignee. Inflates
// the number with backlog tasks nobody has started working on yet.
// Needs: a boolean is_in_progress column on tasks.tasks, OR a "wip" flag
// on tasks.statuses, so SQL can count only tasks that have been picked up.
//
// 2. CycleTimeKpi (kpi.cycle_time)
// 3. CycleTimeHistogramSection (chart.cycle_time_histogram)
// 4. CycleTimePerProjectSection (chart.cycle_time_per_project)
// All three measure (date_complete - date_creation) for completed tasks.
// That is actually lead time (includes time the task sat in backlog), not
// cycle time. A task that spent 6 months in backlog and was then finished
// in 2 days shows up as a 6-month "cycle".
// Needs: a started_at timestamp on tasks.tasks (set when the task moves
// into an in-progress status) so SQL can compute (date_complete - started_at).
// Trigger or app logic must set started_at on the first transition into a
// "wip" status.
//
// 5. TimeInKanbanStatusSection (chart.time_in_kanban_status)
// Misleadingly named — it averages (now - edit_date) for currently-open
// tasks grouped by their current status. It does NOT measure how long
// each task has actually been in its current status; any unrelated edit
// (description change, assignee change) resets the clock for the metric.
// Needs: a tasks.status_history table tracking (task_id, status_id,
// entered_at, exited_at). Then "time in status X" = avg(exited_at OR now
// - entered_at) for rows in that status. Without a transition log, this
// metric cannot be computed correctly.
// ---------------------------------------------------------------------------
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
const builders: SectionBuilder[] = [
// KPI
new CreatedTasksKpi(),
new CompletedTasksKpi(),
new OverdueKpi(),
new CycleTimeKpi(),
// new CycleTimeKpi(), // disabled — see top-of-file comment
new TotalIncomeKpi(),
new TotalExpenseKpi(),
new NetProfitKpi(),
@@ -39,43 +79,72 @@ 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(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
new OverdueByAgeSection(),
// new CycleTimeHistogramSection(),
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// 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<string, SectionBuilder>
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))
}
catalog(): AnalyticsSectionCatalogEntry[] {
const locales = sectionLocales as Record<string, { title?: { ru: string, en: string } }>
return this.enabledOrder.map(b => ({
id: b.id,
group: b.group,
payloadKind: b.defaultChartType === null ? 'kpi' : 'series',
title: locales[b.id]?.title ?? { ru: b.id, en: b.id },
}))
}
}
@@ -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<AnalyticsSection | null> {
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(),
}
}
}
@@ -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<AnalyticsSection | null> {
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(),
}
}
}
@@ -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<AnalyticsSection | null> {
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(),
}
}
}
@@ -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<AnalyticsSection | null> {
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(),
}
}
}
@@ -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<string>()
const projectOrder: number[] = []
const projectNameById = new Map<number, string>()
const valuesByProject = new Map<number, Map<string, number>>()
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<string, number>()
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,
}
}
@@ -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<string>()
const tagOrder: number[] = []
const tagNameById = new Map<number, LocalizedText>()
const valuesByTag = new Map<number, Map<string, number>>()
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<string, number>()
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,
}
}
@@ -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' },
@@ -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
}
@@ -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
}
@@ -4,12 +4,7 @@ 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',
}
const STATUS_ORDER: readonly StatusKey[] = ['active', 'fading', 'dead', 'empty']
export class ActiveProjectsSection implements SectionBuilder {
readonly id = 'chart.active_projects'
@@ -27,7 +22,8 @@ export class ActiveProjectsSection implements SectionBuilder {
const rows = await ctx.repository.fetchActiveProjects(ctx.accessibleGoalIds)
const loc = this.loc
const labelTexts = rows.map(r => loc.labels![r.status_key])
const countByStatus = new Map(rows.map(r => [r.status_key, Number(r.count)]))
const labelTexts = STATUS_ORDER.map(key => loc.labels![key])
const payload: AnalyticsSeriesPayload = {
kind: 'series',
@@ -38,19 +34,14 @@ export class ActiveProjectsSection implements SectionBuilder {
{
id: 'count',
label: loc.datasets!.count,
values: rows.map(r => Number(r.count)),
meta: { statusKeys: rows.map(r => r.status_key) },
values: STATUS_ORDER.map(key => countByStatus.get(key) ?? 0),
meta: { statusKeys: STATUS_ORDER },
},
],
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,
+17
View File
@@ -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
@@ -139,7 +139,7 @@ export class OrganizationRepository {
})
.from(OrganizationMembersSchema)
.innerJoin(OrganizationsSchema, eq(OrganizationMembersSchema.organizationId, OrganizationsSchema.id))
.where(eq(OrganizationMembersSchema.email, email))
.where(eq(OrganizationMembersSchema.email, email.toLowerCase()))
)
if (!result) return []
@@ -192,7 +192,7 @@ export class OrganizationRepository {
.where(
and(
eq(OrganizationMembersSchema.organizationId, orgId),
eq(OrganizationMembersSchema.email, email),
eq(OrganizationMembersSchema.email, email.toLowerCase()),
)
)
)
@@ -220,7 +220,7 @@ export class OrganizationRepository {
.where(
and(
eq(OrganizationMembersSchema.organizationId, orgId),
eq(OrganizationMembersSchema.email, email),
eq(OrganizationMembersSchema.email, email.toLowerCase()),
)
)
)
+12 -3
View File
@@ -603,7 +603,16 @@ export class TasksRepository {
}
}
const order = +data.firstNew === 1 ? desc(TasksSchema.id) : asc(TasksSchema.id);
const descending = +data.firstNew === 1;
const order =
data.sortBy === 'priority'
? [
descending
? sql`${TasksSchema.priorityId} DESC NULLS LAST`
: sql`${TasksSchema.priorityId} ASC NULLS LAST`,
desc(TasksSchema.id),
]
: [descending ? desc(TasksSchema.id) : asc(TasksSchema.id)];
const limit = 30;
const offset = (data.page ?? 0) * limit;
@@ -614,13 +623,13 @@ export class TasksRepository {
.select()
.from(TasksSchema)
.where(and(...conditions))
.orderBy(order);
.orderBy(...order);
} else {
dbv = this.db.dbDrizzle
.select()
.from(TasksSchema)
.where(and(...conditions))
.orderBy(order)
.orderBy(...order)
.limit(limit)
.offset(offset);
}
@@ -50,6 +50,7 @@ export const TaskArkTypeFetchTasksNew = type({
page: TaskArkTypeNumberFromString,
showCompleted: TaskArkTypeZeroOneToNumber,
firstNew: TaskArkTypeZeroOneToNumber,
"sortBy?": "'date' | 'priority'",
'searchText?': 'string',
'filters?': type('object | string')
.pipe((v) => {
@@ -0,0 +1,21 @@
import { type } from 'arktype'
import type { Request, Response } from 'express'
import { logError } from '../../utils/api'
import { UiPreferencesArkType } from './types'
export class UiPreferencesController {
get = async (req: Request, res: Response) => {
const prefs = await req.appUser.uiPreferencesManager.get().catch(logError)
return res.tvJson(prefs ?? {})
}
update = async (req: Request, res: Response) => {
const out = UiPreferencesArkType(req.body)
if (out instanceof type.errors) {
return res.status(400).send(out.summary)
}
const prefs = await req.appUser.uiPreferencesManager.update(out).catch(logError)
if (!prefs) return res.status(500).end()
return res.tvJson(prefs)
}
}
@@ -0,0 +1,25 @@
import type { AppUser } from '../../core/AppUser'
import { UiPreferencesRepository } from './UiPreferencesRepository'
import type { UiPreferences } from './types'
export class UiPreferencesManager {
private readonly repository: UiPreferencesRepository
private readonly user: AppUser
constructor(user: AppUser) {
this.user = user
this.repository = new UiPreferencesRepository()
}
async get(): Promise<UiPreferences> {
const userId = this.user.getUserData()?.id
if (!userId) return {}
return this.repository.getForUser(userId)
}
async update(prefs: UiPreferences): Promise<UiPreferences | null> {
const userId = this.user.getUserData()?.id
if (!userId) return null
return this.repository.upsert({ userId, prefs })
}
}
@@ -0,0 +1,40 @@
import { eq, sql } from 'drizzle-orm'
import { UiPreferencesSchema } from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type { UiPreferences, UpdateUiPreferencesArgs } from './types'
export class UiPreferencesRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async getForUser(userId: number): Promise<UiPreferences> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ prefs: UiPreferencesSchema.prefs })
.from(UiPreferencesSchema)
.where(eq(UiPreferencesSchema.userId, userId)),
)
if (!result || result.length === 0) return {}
return (result[0].prefs ?? {}) as UiPreferences
}
async upsert(args: UpdateUiPreferencesArgs): Promise<UiPreferences> {
const { userId, prefs } = args
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(UiPreferencesSchema)
.values({ userId, prefs, updatedAt: new Date() })
.onConflictDoUpdate({
target: UiPreferencesSchema.userId,
set: { prefs, updatedAt: sql`now()` },
})
.returning({ prefs: UiPreferencesSchema.prefs }),
)
if (!result || result.length === 0) return prefs
return (result[0].prefs ?? {}) as UiPreferences
}
}
@@ -0,0 +1,24 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { UiPreferencesController } from './UiPreferencesController'
export default class UiPreferencesRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: UiPreferencesController
constructor() {
this.router = Router()
this.controller = new UiPreferencesController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
this.router.get('', [IsLoggedIn], this.controller.get)
this.router.put('', [IsLoggedIn], this.controller.update)
}
}
@@ -0,0 +1,63 @@
import { type } from 'arktype'
const MAX_SECTIONS = 20
const MAX_ITEMS_PER_SECTION = 200
const ID_MAX_LENGTH = 100
const ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/
const idArkType = type('string').narrow((v, ctx) => {
if (v.length === 0 || v.length > ID_MAX_LENGTH) {
return ctx.mustBe(`a string of 1..${ID_MAX_LENGTH} chars`)
}
if (!ID_PATTERN.test(v)) {
return ctx.mustBe('a string matching [a-zA-Z0-9_.\\-:]+')
}
return true
})
export const UiPreferencesItemArkType = type({
id: idArkType,
order: 'number.integer >= 0',
hidden: 'boolean',
'width?': "'narrow' | 'wide'",
})
const UiPreferencesSectionArkType = UiPreferencesItemArkType.array().narrow((arr, ctx) => {
if (arr.length > MAX_ITEMS_PER_SECTION) {
return ctx.mustBe(`at most ${MAX_ITEMS_PER_SECTION} items per section`)
}
const seen = new Set<string>()
for (const item of arr) {
if (seen.has(item.id)) {
return ctx.mustBe(`unique item ids (duplicate: ${item.id})`)
}
seen.add(item.id)
}
return true
})
export const UiPreferencesArkType = type({
'[string]': UiPreferencesSectionArkType,
}).narrow((obj, ctx) => {
const keys = Object.keys(obj)
if (keys.length > MAX_SECTIONS) {
return ctx.mustBe(`at most ${MAX_SECTIONS} sections`)
}
for (const key of keys) {
if (key.length === 0 || key.length > ID_MAX_LENGTH) {
return ctx.mustBe(`section key length 1..${ID_MAX_LENGTH} (got "${key}")`)
}
if (!ID_PATTERN.test(key)) {
return ctx.mustBe(`section key matching [a-zA-Z0-9_.\\-:]+ (got "${key}")`)
}
}
return true
})
export type UiPreferencesItem = typeof UiPreferencesItemArkType.infer
export type UiPreferences = typeof UiPreferencesArkType.infer
export type UpdateUiPreferencesArgs = {
userId: number
prefs: UiPreferences
}
+7 -7
View File
@@ -14,13 +14,13 @@ export const AppEnvSchema = z.object({
ACCESS_LIFE_TIME: z.string(),
REFRESH_LIFE_TIME: z.string(),
SMTP_HOST: z.string(),
SMTP_PORT: z.string(),
SMTP_USERNAME: z.string(),
SMTP_PASSWORD: z.string(),
SMTP_ENCRYPTION: z.string(),
SMTP_FROM_NAME: z.string(),
SMTP_FROM_EMAIL: z.string(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USERNAME: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_ENCRYPTION: z.string().optional(),
SMTP_FROM_NAME: z.string().optional(),
SMTP_FROM_EMAIL: z.string().optional(),
APP_URL: z.string(),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.42.5",
"version": "1.44.1",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
+10 -1
View File
@@ -1,8 +1,17 @@
{
"name": "taskview-api",
"private": false,
"version": "1.42.5",
"version": "1.44.0",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/Gimanh/taskview-community.git",
"directory": "taskview-packages/taskview-api"
},
"homepage": "https://github.com/Gimanh/taskview-community/tree/main/taskview-packages/taskview-api#readme",
"bugs": {
"url": "https://github.com/Gimanh/taskview-community/issues"
},
"main": "./dist/taskview-api.umd.js",
"module": "./dist/taskview-api.es.js",
"types": "./dist/index.d.ts",
@@ -8,13 +8,13 @@ JWT_ALG=HS256
JWT_SIGN=test-jwt-sign-secret
ACCESS_LIFE_TIME=3d
REFRESH_LIFE_TIME=9d
SMTP_HOST=localhost
SMTP_PORT=465
SMTP_USERNAME=test
SMTP_PASSWORD=test
SMTP_ENCRYPTION=ssl
SMTP_FROM_NAME=Test
SMTP_FROM_EMAIL=test@test.com
# SMTP_HOST=localhost
# SMTP_PORT=465
# SMTP_USERNAME=test
# SMTP_PASSWORD=test
# SMTP_ENCRYPTION=ssl
# SMTP_FROM_NAME=Test
# SMTP_FROM_EMAIL=test@test.com
CORS_ALLOWED_ORIGINS=*
APP_URL=http://localhost:1401
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
@@ -4,6 +4,7 @@ import type {
AnalyticsFetchDrillDownArg,
AnalyticsDrillDownResponse,
AnalyticsFetchSectionsArg,
AnalyticsSectionCatalogEntry,
AnalyticsSectionsResponse,
AnalyticsScope,
} from './analytics.types'
@@ -20,6 +21,14 @@ function scopeToParams(scope: AnalyticsScope): Record<string, string> {
export default class TvAnalyticsApi extends TvApiBase {
protected moduleUrl = '/module/analytics'
public async fetchSectionsCatalog() {
return this.request(
this.$axios.get<AppResponse<AnalyticsSectionCatalogEntry[]>>(
`${this.moduleUrl}/sections-catalog`,
),
)
}
public async fetchSections(arg: AnalyticsFetchSectionsArg, signal?: AbortSignal) {
const params: Record<string, string> = {
...scopeToParams(arg.scope),
@@ -116,6 +116,13 @@ export type AnalyticsAvailableGoal = {
name: string
}
export type AnalyticsSectionCatalogEntry = {
id: string
group: AnalyticsSectionGroup
payloadKind: 'kpi' | 'series'
title: LocalizedText
}
export type AnalyticsSectionsResponse = {
scope: AnalyticsScope
period: AnalyticsPeriod
@@ -5,6 +5,7 @@ export type Organization = {
ownerId: number
logoUrl: string | null
plan: string
isPersonal: number
createdAt: string
updatedAt: string
currentUserRole: 'owner' | 'admin' | 'member'
@@ -67,12 +67,15 @@ export type TaskFilters = {
selectedTags?: Record<string, true>;
};
export type TaskSortBy = 'date' | 'priority';
export type TaskArgFetch = {
goalId: number;
componentId: number | typeof ALL_TASKS_LIST_ID;
page: number;
showCompleted: 0 | 1;
firstNew: 0 | 1;
sortBy?: TaskSortBy;
searchText?: string;
filters?: TaskFilters;
unlimited?: boolean;
@@ -0,0 +1,19 @@
import TvApiBase from './base'
import type { AppResponse } from './base.types'
import type { UiPreferences } from './ui-preferences.types'
export default class TvUiPreferencesApi extends TvApiBase {
protected moduleUrl = '/module/ui-preferences'
public async fetch() {
return this.request(
this.$axios.get<AppResponse<UiPreferences>>(this.moduleUrl),
)
}
public async update(prefs: UiPreferences) {
return this.request(
this.$axios.put<AppResponse<UiPreferences>>(this.moduleUrl, prefs),
)
}
}
@@ -0,0 +1,8 @@
export type UiPreferencesItem = {
id: string
order: number
hidden: boolean
width?: 'narrow' | 'wide'
}
export type UiPreferences = Record<string, UiPreferencesItem[]>
+2 -1
View File
@@ -17,4 +17,5 @@ export * from '@/api/sessions.types';
export * from '@/api/organizations.types';
export * from '@/api/sso.types';
export * from '@/api/analytics.types';
export * from '@/api/time-tracking.types';
export * from '@/api/time-tracking.types';
export * from '@/api/ui-preferences.types';
+5
View File
@@ -15,6 +15,7 @@ import TvOrganizationsApi from "./api/organizations";
import TvSsoApi from "./api/sso";
import TvAnalyticsApi from "./api/analytics";
import TvTimeTrackingApi from "./api/time-tracking";
import TvUiPreferencesApi from "./api/ui-preferences";
export class TvApi {
@@ -52,6 +53,8 @@ export class TvApi {
public timeTracking: TvTimeTrackingApi;
public uiPreferences: TvUiPreferencesApi;
constructor($axios: AxiosInstance) {
this.$axios = $axios;
@@ -86,6 +89,8 @@ export class TvApi {
this.analytics = new TvAnalyticsApi(this.$axios);
this.timeTracking = new TvTimeTrackingApi(this.$axios);
this.uiPreferences = new TvUiPreferencesApi(this.$axios);
}
public setBaseUrl(baseUrl: string) {
@@ -19,3 +19,4 @@ export * from './schemas/organizations.schema';
export * from './schemas/sso.schema';
export * from './schemas/time-entries.schema';
export * from './schemas/time-entries-history.schema';
export * from './schemas/ui-preferences.schema';
@@ -0,0 +1,11 @@
import { integer, jsonb, pgSchema, timestamp } from 'drizzle-orm/pg-core'
import { UsersSchema } from './users.schema'
export const UiPreferencesSchema = pgSchema('tv_auth').table('ui_preferences', {
userId: integer('user_id').primaryKey().references(() => UsersSchema.id, { onDelete: 'cascade' }),
prefs: jsonb().notNull().default({}),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export type UiPreferencesSchemaTypeForSelect = typeof UiPreferencesSchema.$inferSelect
export type UiPreferencesSchemaTypeForInsert = typeof UiPreferencesSchema.$inferInsert
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-mcp",
"version": "1.42.5",
"version": "1.44.0",
"description": "MCP (Model Context Protocol) server for TaskView — lets AI assistants (Claude Code, Claude Desktop, etc.) manage projects and tasks via the TaskView API",
"type": "module",
"bin": {
@@ -33,10 +33,10 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/Gimanh/taskview-community",
"directory": "community/taskview-packages/taskview-mcp"
"url": "git+https://github.com/Gimanh/taskview-community.git",
"directory": "taskview-packages/taskview-mcp"
},
"homepage": "https://github.com/Gimanh/taskview-community",
"homepage": "https://github.com/Gimanh/taskview-community/tree/main/taskview-packages/taskview-mcp#readme",
"bugs": {
"url": "https://github.com/Gimanh/taskview-community/issues"
},
@@ -8,15 +8,28 @@ const TASKVIEW_TOKEN_PROVIDED = process.env.TASKVIEW_TOKEN
let createdTokenId: number | null = null
let createdToken: string | null = null
async function loginWithRetry(attempts = 5) {
let lastError: unknown
for (let i = 0; i < attempts; i++) {
try {
return await axios.post(`${TASKVIEW_URL}/module/auth/login`, {
login: TASKVIEW_LOGIN,
password: TASKVIEW_PASSWORD,
})
} catch (e) {
lastError = e
await new Promise((r) => setTimeout(r, 500 * (i + 1)))
}
}
throw lastError
}
export async function setup() {
if (!TASKVIEW_URL) return
if (TASKVIEW_TOKEN_PROVIDED) return
if (!TASKVIEW_LOGIN || !TASKVIEW_PASSWORD) return
const loginRes = await axios.post(`${TASKVIEW_URL}/module/auth/login`, {
login: TASKVIEW_LOGIN,
password: TASKVIEW_PASSWORD,
})
const loginRes = await loginWithRetry()
const jwt = loginRes.data.access
if (!jwt) throw new Error('Login succeeded but no access token returned')
@@ -45,5 +58,5 @@ export async function teardown() {
data: { id: createdTokenId },
headers: { Authorization: `Bearer ${jwt}` },
})
.catch(() => {})
.catch(() => { })
}
@@ -188,6 +188,53 @@ describe('tasks integration', () => {
await call(tools, 'delete_task', { taskId: created.id }).catch(() => {})
})
it('lists tasks sorted by priority (desc highest-first, asc lowest-first)', async () => {
const sortGoalId = parse(await call(tools, 'create_goal', { name: `Sort Test ${ts()}` })).id
try {
// Create in an order where task id does NOT correlate with priority, so the
// assertions can only pass if the backend truly orders by priority (not by id).
const mid = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `mid ${ts()}`, priorityId: 2 }))
const low = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `low ${ts()}`, priorityId: 1 }))
const high = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `high ${ts()}`, priorityId: 3 }))
const descResult = await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'priority', descending: true, showCompleted: false })
if (descResult.isError) {
expect(descResult.content[0].text).toContain('403')
return
}
const descIds = (parse(descResult) as Array<{ id: number }>).map((t) => t.id)
expect(descIds).toEqual([high.id, mid.id, low.id])
const ascIds = (parse(await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'priority', descending: false, showCompleted: false })) as Array<{ id: number }>).map((t) => t.id)
expect(ascIds).toEqual([low.id, mid.id, high.id])
} finally {
await call(tools, 'delete_goal', { goalId: sortGoalId }).catch(() => {})
}
})
it('lists tasks sorted by date (desc newest-first, asc oldest-first)', async () => {
const sortGoalId = parse(await call(tools, 'create_goal', { name: `Date Sort Test ${ts()}` })).id
try {
// Created oldest -> newest, so creation order is also the ascending date order.
const first = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `first ${ts()}` }))
const second = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `second ${ts()}` }))
const third = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `third ${ts()}` }))
const descResult = await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'date', descending: true, showCompleted: false })
if (descResult.isError) {
expect(descResult.content[0].text).toContain('403')
return
}
const descIds = (parse(descResult) as Array<{ id: number }>).map((t) => t.id)
expect(descIds).toEqual([third.id, second.id, first.id])
const ascIds = (parse(await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'date', descending: false, showCompleted: false })) as Array<{ id: number }>).map((t) => t.id)
expect(ascIds).toEqual([first.id, second.id, third.id])
} finally {
await call(tools, 'delete_goal', { goalId: sortGoalId }).catch(() => {})
}
})
it('lists tasks filtered by componentId (list)', async () => {
const list = parse(await call(tools, 'create_list', { goalId, name: `Filter List ${ts()}` }))
const inList = parse(await call(tools, 'create_task', { goalId, goalListId: list.id, description: `In-list ${ts()}` }))
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { registerTasksTools } from '../tools/tasks.js'
import { mockServer, mockApi, apiReturn, apiThrow, findTool, ts } from './setup.js'
@@ -41,7 +42,7 @@ describe('tasks tools', () => {
expect(captured.searchText).toBe('test')
})
it('list_tasks defaults componentId and page', async () => {
it('list_tasks applies defaults (all-tasks list, page 0, date sort ascending)', async () => {
const { server, tools } = mockServer()
let captured: Record<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
@@ -51,9 +52,76 @@ describe('tasks tools', () => {
registerTasksTools(server, mockApi({ tasks: { fetch } }))
await findTool(tools, 'list_tasks').cb({ goalId: 1 })
expect(captured.componentId).toBe(0)
expect(captured.page).toBe(1)
expect(captured.componentId).toBe(ALL_TASKS_LIST_ID)
expect(captured.page).toBe(0)
expect(captured.showCompleted).toBe(0)
expect(captured.sortBy).toBe('date')
expect(captured.firstNew).toBe(0)
})
it('list_tasks exposes sortBy and descending in its schema', () => {
const { server, tools } = mockServer()
registerTasksTools(server, mockApi())
const schema = findTool(tools, 'list_tasks').config.inputSchema as Record<string, unknown>
expect(schema).toHaveProperty('sortBy')
expect(schema).toHaveProperty('descending')
})
it('list_tasks passes sortBy=priority with descending direction (firstNew=1)', async () => {
const { server, tools } = mockServer()
let captured: Record<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
captured = params
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
}
registerTasksTools(server, mockApi({ tasks: { fetch } }))
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'priority', descending: true })
expect(captured.sortBy).toBe('priority')
expect(captured.firstNew).toBe(1)
})
it('list_tasks maps descending=false to ascending direction (firstNew=0)', async () => {
const { server, tools } = mockServer()
let captured: Record<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
captured = params
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
}
registerTasksTools(server, mockApi({ tasks: { fetch } }))
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'priority', descending: false })
expect(captured.sortBy).toBe('priority')
expect(captured.firstNew).toBe(0)
})
it('list_tasks passes sortBy=date with descending direction (newest first, firstNew=1)', async () => {
const { server, tools } = mockServer()
let captured: Record<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
captured = params
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
}
registerTasksTools(server, mockApi({ tasks: { fetch } }))
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'date', descending: true })
expect(captured.sortBy).toBe('date')
expect(captured.firstNew).toBe(1)
})
it('list_tasks passes sortBy=date with ascending direction (oldest first, firstNew=0)', async () => {
const { server, tools } = mockServer()
let captured: Record<string, unknown> = {}
const fetch = (params: Record<string, unknown>) => {
captured = params
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
}
registerTasksTools(server, mockApi({ tasks: { fetch } }))
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'date', descending: false })
expect(captured.sortBy).toBe('date')
expect(captured.firstNew).toBe(0)
})
it('get_task returns task', async () => {
+21 -4
View File
@@ -11,11 +11,28 @@ import { registerNotificationsTools } from './tools/notifications.js'
import { registerOrganizationsTools } from './tools/organizations.js'
import { registerTimeTrackingTools } from './tools/time-tracking.js'
const INSTRUCTIONS = `TaskView is a project and task management platform.
TERMINOLOGY — IMPORTANT: a "goal" is a PROJECT. The two words are interchangeable. Every tool, parameter, and ID that mentions a "goal" (e.g. goalId, list_goals, create_goal) refers to a project — "goal" is TaskView's internal name for a project. When the user talks about "projects", use the *_goal tools.
DATA MODEL (top to bottom):
- Organization — a workspace that groups projects and members.
- Project (goal) — a project, identified by goalId. Managed with list_goals / create_goal / update_goal / delete_goal.
- List (component) — a section/list inside a project, identified by componentId. Pass it to list_tasks to scope tasks to one list; omit it to see all tasks in the project.
- Task — a unit of work inside a project (and optionally inside a list). Supports subtasks, assignees, tags, priority (1=low, 2=medium, 3=high), deadlines, and dependencies.
WORKFLOW: all IDs are numeric and must be resolved first — never guess them. Map a name to its id with the matching list_* tool (project → list_goals, list → list_lists, members → list_collaborators_for_goal, kanban columns → list_kanban_columns, tags → list_tags), then pass that id to the create/update/delete tools.
list_tasks is paginated: page is 0-based, ~30 tasks per page — request the next page until one returns fewer than 30. Completed tasks are hidden unless showCompleted is set. Use sortBy ("date" or "priority") with descending to control ordering.`
export function createMcpServer(api: TvApi) {
const server = new McpServer({
name: 'taskview',
version: '1.0.0',
})
const server = new McpServer(
{
name: 'taskview',
version: '1.0.0',
},
{ instructions: INSTRUCTIONS },
)
registerGoalsTools(server, api)
registerListsTools(server, api)
@@ -45,7 +45,7 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
{
description: 'Update a project (goal) — name, description, color',
inputSchema: {
id: z.coerce.number().describe('Goal ID'),
id: z.coerce.number().describe('Project (goal) ID'),
name: z.string().optional().describe('New name'),
description: z.string().optional().describe('New description'),
color: z.string().optional().describe('New color (hex)'),
@@ -66,7 +66,7 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
{
description: 'Delete a project (goal)',
inputSchema: {
goalId: z.coerce.number().describe('Goal ID to delete'),
goalId: z.coerce.number().describe('Project (goal) ID to delete'),
},
},
async ({ goalId }) => {
@@ -14,16 +14,21 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
page: z.coerce.number().optional().default(0).describe('Page number (0-based, default: 0)'),
showCompleted: z.boolean().optional().default(false).describe('Include completed tasks'),
searchText: z.string().optional().describe('Search text to filter tasks'),
sortBy: z.enum(['date', 'priority']).optional().default('date')
.describe('Sort field: "date" (creation order) or "priority"'),
descending: z.boolean().optional().default(false)
.describe('Sort descending: newest first for date, highest priority first for priority'),
},
},
async ({ goalId, componentId, page, showCompleted, searchText }) => {
async ({ goalId, componentId, page, showCompleted, searchText, sortBy, descending }) => {
try {
const tasks = await api.tasks.fetch({
goalId,
componentId: componentId ?? ALL_TASKS_LIST_ID,
page: page ?? 0,
showCompleted: showCompleted ? 1 : 0,
firstNew: 0,
firstNew: descending ? 1 : 0,
sortBy: sortBy ?? 'date',
searchText,
})
return ok(tasks)
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.42.5",
"version": "1.44.1",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
+7
View File
@@ -169,6 +169,13 @@ const items = computed<DropdownMenuItem[][]>(() => [
router.push({ name: 'account' })
},
},
{
label: t('userMenu.uiCustomization'),
icon: 'i-lucide-sliders-horizontal',
onSelect() {
router.push({ name: 'ui-customization' })
},
},
{
label: t('userMenu.organizations'),
icon: 'i-lucide-building-2',
@@ -144,5 +144,3 @@ watch(() => props.section, () => {
watch(currentChartType, () => render())
watch(locale, () => render())
</script>
@@ -1,5 +1,5 @@
<template>
<UCard>
<UCard :ui="{ body: 'p-2 lg:p-4' }">
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1">
@@ -22,7 +22,7 @@
</template>
<div
v-if="section.payload.kind === 'series' && section.payload.datasets.length === 0"
v-if="isEmpty"
class="py-12 text-center text-sm text-zinc-500"
>
{{ t('analytics.sectionCard.noData') }}
@@ -36,17 +36,29 @@
</template>
<script setup lang="ts">
import type { AnalyticsSection } from 'taskview-api'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AnalyticsChart from './AnalyticsChart.vue'
import AnalyticsHelpButton from './AnalyticsHelpButton.vue'
import { useAnalyticsLocale } from './composables/useAnalyticsLocale'
defineProps<{
const props = defineProps<{
section: AnalyticsSection
}>()
const { t } = useI18n()
const isEmpty = computed(() => {
const p = props.section.payload
if (p.kind !== 'series') return false
if (p.datasets.length === 0) return true
if (p.labels.length === 0) return true
const allEmpty = p.datasets.every(d =>
d.values.length === 0 || d.values.every(v => v === null || v === 0),
)
return allEmpty
})
const emit = defineEmits<{
(e: 'drill-down', payload: { sectionId: string; datasetId: string; bucket: string; index: number; meta?: Record<string, unknown> }): void
}>()
@@ -57,5 +69,3 @@ function onDrillDown(section: AnalyticsSection, payload: { datasetId: string; bu
emit('drill-down', { sectionId: section.id, ...payload })
}
</script>
@@ -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),
@@ -9,23 +9,30 @@ const palette: Record<AnalyticsColorToken, string> = {
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 {
@@ -10,15 +10,25 @@
:ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }"
>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">
{{ organization.name }}
</h3>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
@click="open = false"
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<h3 class="font-semibold">
{{ organization.name }}
</h3>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
@click="open = false"
/>
</div>
<UAlert
v-if="organization.isPersonal"
:title="t('organizations.personal')"
:description="t('organizations.personalHint')"
icon="i-lucide-user"
color="info"
variant="subtle"
/>
</div>
</template>
@@ -69,7 +79,7 @@
<template #footer>
<div class="flex justify-between">
<UButton
v-if="isOwner"
v-if="isOwner && !organization.isPersonal"
:label="t('organizations.delete')"
color="error"
variant="ghost"
@@ -40,90 +40,82 @@
<span class="truncate underline underline-offset-2">{{ task.sourceUrl }}</span>
</a>
<!-- Subtasks -->
<TaskSubtasks
:parent-task-id="task.id"
:goal-id="task.goalId"
:subtasks="task.subtasks"
class="pl-10"
/>
<!-- Note -->
<NoteEditor
:key="task.id"
:content="task.note || ''"
:content-type="task.sourceUrl ? 'markdown' : 'html'"
:placeholder="t('tasks.addNote')"
@save="updateNote"
/>
<div class="flex flex-col-reverse @lg:flex-row gap-4">
<!-- Status -->
<TaskKanbanStatusSelect
:task-id="task.id"
:current-status-id="task.statusId"
class="flex-1 w-full @lg:max-w-1/2"
/>
<!-- Priority -->
<TaskPrioritySelect
:model-value="task.priorityId"
class="flex-1 w-full @lg:max-w-1/2"
@update:model-value="updatePriority"
/>
<div class="grid grid-cols-1 @lg:grid-cols-2 gap-4">
<template
v-for="fieldId in orderedFieldIds"
:key="fieldId"
>
<TaskSubtasks
v-if="fieldId === 'subtasks'"
:parent-task-id="task.id"
:goal-id="task.goalId"
:subtasks="task.subtasks"
:class="[colClass(fieldId), 'pl-10']"
/>
<NoteEditor
v-else-if="fieldId === 'note'"
:key="task.id"
:content="task.note || ''"
:content-type="task.sourceUrl ? 'markdown' : 'html'"
:placeholder="t('tasks.addNote')"
:class="colClass(fieldId)"
@save="updateNote"
/>
<TaskKanbanStatusSelect
v-else-if="fieldId === 'status'"
:task-id="task.id"
:current-status-id="task.statusId"
:class="colClass(fieldId)"
/>
<TaskPrioritySelect
v-else-if="fieldId === 'priority'"
:model-value="task.priorityId"
:class="colClass(fieldId)"
@update:model-value="updatePriority"
/>
<TaskAssigneeSelect
v-else-if="fieldId === 'assignees'"
:task-id="task.id"
:assigned-user-ids="task.assignedUsers"
:class="colClass(fieldId)"
/>
<TaskListSelect
v-else-if="fieldId === 'list'"
:task-id="task.id"
:current-list-id="task.goalListId"
:class="colClass(fieldId)"
/>
<TaskTagsManager
v-else-if="fieldId === 'tags'"
:task-id="task.id"
:task-tag-ids="task.tags"
:goal-id="projectId"
:class="colClass(fieldId)"
/>
<TaskDeadline
v-else-if="fieldId === 'deadline'"
:task="task"
:class="[colClass(fieldId), 'h-fit']"
/>
<TaskAmountEditor
v-else-if="fieldId === 'amount'"
:task-id="task.id"
:amount="task.amount"
:transaction-type="task.transactionType"
:class="colClass(fieldId)"
/>
<TaskTimeTracking
v-else-if="fieldId === 'timeTracking'"
:task-id="task.id"
:class="colClass(fieldId)"
/>
<TaskHistory
v-else-if="fieldId === 'history'"
:task-id="task.id"
:class="colClass(fieldId)"
/>
</template>
</div>
<!-- Assignees -->
<div class="flex flex-col @lg:flex-row gap-4">
<TaskAssigneeSelect
:task-id="task.id"
:assigned-user-ids="task.assignedUsers"
class="flex-1 w-full @lg:max-w-1/2"
/>
<!-- List -->
<TaskListSelect
:task-id="task.id"
:current-list-id="task.goalListId"
class="flex-1 w-full @lg:max-w-1/2"
/>
</div>
<div class="flex flex-col @lg:flex-row gap-4">
<!-- Tags -->
<TaskTagsManager
:task-id="task.id"
:task-tag-ids="task.tags"
:goal-id="projectId"
class="flex-1 w-full @lg:max-w-1/2"
/>
<!-- Deadline -->
<TaskDeadline
:task="task"
class="flex-1 w-full @lg:max-w-1/2 h-fit"
/>
</div>
<!-- Amount -->
<TaskAmountEditor
:task-id="task.id"
:amount="task.amount"
:transaction-type="task.transactionType"
class="flex-1"
/>
<!-- Time tracking -->
<TaskTimeTracking
v-if="canViewTimeTracking || canLogTime"
:task-id="task.id"
class="flex-1"
/>
<!-- History -->
<TaskHistory
:task-id="task.id"
class="flex-1"
/>
<br />
</template>
@@ -157,15 +149,37 @@ import TaskDeadline from '@/components/features/tasks/parts/TaskDeadline.vue'
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
import type { TaskBase } from 'taskview-api'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { useUiPreferences } from '@/composables/useUiPreferences'
import { tasksSection } from '@/uiCustomization/sections/tasks'
const { t } = useI18n()
const toast = useToast()
const tasksStore = useTasksStore()
const {
canEditTaskStatus,
canViewTimeTracking,
canLogTime,
} = useGoalPermissions()
const { catalogue: taskFieldsCatalogue } = tasksSection.useSection()
const { resolved: resolvedTaskFields } = useUiPreferences(
tasksSection.id,
() => taskFieldsCatalogue.value,
)
const visibleFields = computed(() =>
resolvedTaskFields.value.filter(f => !f.hidden),
)
const orderedFieldIds = computed(() => visibleFields.value.map(f => f.id))
const fieldWidthById = computed(() => {
const map = new Map<string, 'narrow' | 'wide'>()
for (const f of visibleFields.value) {
if (f.width) map.set(f.id, f.width)
}
return map
})
function colClass(id: string): string {
return fieldWidthById.value.get(id) === 'wide' ? '@lg:col-span-2' : 'w-full'
}
const task = computed(() => tasksStore.selectedTask ?? null)
const projectId = computed(() => task.value?.goalId ?? 0)
const titleValue = ref(task.value?.description ?? '')
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-end gap-2 p-2 bg-tv-ui-bg-elevated rounded-lg">
<div class="flex flex-wrap items-center justify-end gap-2 p-2 bg-tv-ui-bg-elevated rounded-lg">
<!-- Filter -->
<UTooltip :text="t('filters.title')">
<UButton
@@ -25,18 +25,29 @@
/>
</UTooltip>
<!-- Sort Order -->
<UTooltip :text="sortTooltip">
<UButton
:icon="firstNew ? 'i-lucide-arrow-down-narrow-wide' : 'i-lucide-arrow-up-narrow-wide'"
<!-- Sort -->
<UFieldGroup size="sm">
<USelect
v-model="sortBy"
:items="sortItems"
value-key="value"
icon="i-lucide-arrow-up-down"
color="info"
variant="soft"
size="sm"
:loading="loading"
variant="subtle"
:disabled="loading"
@click="toggleSort"
:ui="{ base: 'min-w-32' }"
/>
</UTooltip>
<UTooltip :text="sortTooltip">
<UButton
:icon="firstNew ? 'i-lucide-arrow-down-wide-narrow' : 'i-lucide-arrow-down-narrow-wide'"
color="info"
variant="subtle"
:loading="loading"
:disabled="loading"
@click="toggleSort"
/>
</UTooltip>
</UFieldGroup>
<!-- Reset All -->
<UButton
@@ -107,6 +118,7 @@ import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { TaskSortBy } from 'taskview-api'
import { useTasksStore } from '@/stores/tasks.store'
import TasksFilterDrawer from './TasksFilterDrawer.vue'
@@ -133,13 +145,26 @@ const hasActiveFilters = computed(() => {
const showCompleted = computed(() => fetchRules.value.showCompleted === 1)
const firstNew = computed(() => fetchRules.value.firstNew === 1)
const sortItems = computed(() => [
{ label: t('tasks.sortByDate'), value: 'date' as const },
{ label: t('tasks.sortByPriority'), value: 'priority' as const },
])
const sortBy = computed<TaskSortBy>({
get: () => fetchRules.value.sortBy,
set: (value) => applySort({ sortBy: value }),
})
const showCompletedTooltip = computed(() =>
showCompleted.value ? t('tasks.hideCompleted') : t('tasks.showCompleted'),
)
const sortTooltip = computed(() =>
firstNew.value ? t('tasks.sortNewestFirst') : t('tasks.sortOldestFirst'),
)
const sortTooltip = computed(() => {
if (fetchRules.value.sortBy === 'priority') {
return firstNew.value ? t('tasks.sortHighestFirst') : t('tasks.sortLowestFirst')
}
return firstNew.value ? t('tasks.sortNewestFirst') : t('tasks.sortOldestFirst')
})
async function toggleCompleted() {
if (loading.value) return
@@ -162,14 +187,14 @@ async function toggleCompleted() {
loading.value = false
}
async function toggleSort() {
async function applySort(rules: Partial<typeof fetchRules.value>) {
if (loading.value) return
loading.value = true
tasksStore.resetTasks()
tasksStore.updateFetchRules({
firstNew: firstNew.value ? 0 : 1,
...rules,
currentPage: 0,
endOfTasks: false,
})
@@ -178,6 +203,10 @@ async function toggleSort() {
loading.value = false
}
function toggleSort() {
return applySort({ firstNew: firstNew.value ? 0 : 1 })
}
async function resetSearch() {
loading.value = true
tasksStore.resetTasks()
@@ -0,0 +1,49 @@
<template>
<div class="flex items-center gap-3 p-3 bg-elevated rounded-lg">
<UIcon
name="i-lucide-grip-vertical"
class="size-4 text-muted shrink-0 cursor-grab drag-handle"
/>
<span
class="flex-1 truncate"
:class="{ 'text-muted': hidden }"
>
{{ label }}
</span>
<UButton
v-if="width"
:icon="width === 'wide' ? 'i-lucide-rectangle-horizontal' : 'i-lucide-square'"
:label="width === 'wide' ? t('uiCustomization.wide') : t('uiCustomization.narrow')"
color="neutral"
variant="soft"
size="sm"
@click="$emit('toggleWidth')"
/>
<UButton
:icon="hidden ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:label="hidden ? t('uiCustomization.show') : t('uiCustomization.hide')"
color="neutral"
variant="soft"
size="sm"
@click="$emit('toggle')"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ItemWidth } from '@/composables/useUiPreferences'
defineProps<{
label: string
hidden: boolean
width?: ItemWidth
}>()
defineEmits<{
toggle: []
toggleWidth: []
}>()
const { t } = useI18n()
</script>
@@ -0,0 +1,89 @@
<template>
<UCard>
<template #header>
<h3 class="font-semibold">
{{ title }}
</h3>
</template>
<div
v-if="resolved.length === 0"
class="py-6 text-center text-sm text-muted"
>
{{ t('uiCustomization.empty') }}
</div>
<draggable
v-else
:list="localItems"
:animation="150"
handle=".drag-handle"
item-key="id"
class="flex flex-col gap-2"
@end="emitChange"
>
<template #item="{ element }">
<UiCustomizationItem
:label="element.label"
:hidden="element.hidden"
:width="element.width"
@toggle="toggleHidden(element.id)"
@toggle-width="toggleWidth(element.id)"
/>
</template>
</draggable>
</UCard>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable'
import type { UiPreferencesItem } from 'taskview-api'
import type { ResolvedItem } from '@/composables/useUiPreferences'
import UiCustomizationItem from './UiCustomizationItem.vue'
const props = defineProps<{
title: string
resolved: ResolvedItem[]
}>()
const emit = defineEmits<{
change: [items: UiPreferencesItem[]]
}>()
const { t } = useI18n()
const localItems = ref<ResolvedItem[]>([])
watch(() => props.resolved, (next) => {
localItems.value = next.map(r => ({ ...r }))
}, { immediate: true, deep: true })
const serialized = computed<UiPreferencesItem[]>(() =>
localItems.value.map((item, idx) => ({
id: item.id,
order: idx,
hidden: item.hidden,
...(item.width !== undefined ? { width: item.width } : {}),
})),
)
function toggleHidden(id: string) {
const target = localItems.value.find(item => item.id === id)
if (!target) return
target.hidden = !target.hidden
emitChange()
}
function toggleWidth(id: string) {
const target = localItems.value.find(item => item.id === id)
if (!target || !target.width) return
target.width = target.width === 'wide' ? 'narrow' : 'wide'
emitChange()
}
function emitChange() {
emit('change', serialized.value)
}
</script>
+82
View File
@@ -0,0 +1,82 @@
import { computed } from 'vue'
import type { UiPreferencesItem } from 'taskview-api'
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
export type ItemWidth = 'narrow' | 'wide'
export type CatalogueEntry<T extends string = string> = {
id: T
// Visible to user — must be passed in by the caller (it usually depends on i18n).
label: string
// Default width for the item. If set, the customization UI will render a
// width toggle for this item. If undefined, width customization is disabled
// for that item.
width?: ItemWidth
}
export type ResolvedItem<T extends string = string> = {
id: T
label: string
hidden: boolean
order: number
width?: ItemWidth
}
// Merges a catalogue of allowed items with stored user prefs.
// Order: items present in prefs keep their saved order; items new to the catalogue
// (never seen by this user) are appended in their catalogue order with hidden=false.
// Width: user-saved width wins; otherwise catalogue default is used.
export function useUiPreferences(section: string, catalogue: () => CatalogueEntry[]) {
const store = useUiPreferencesStore()
const resolved = computed<ResolvedItem[]>(() => {
const entries = catalogue()
const entryById = new Map(entries.map(e => [e.id, e]))
const allowedIds = new Set(entries.map(e => e.id))
const saved: UiPreferencesItem[] = store.getSection(section)
const used = new Set<string>()
const fromSaved: ResolvedItem[] = []
for (const item of [...saved].sort((a, b) => a.order - b.order)) {
const entry = entryById.get(item.id)
if (!entry) continue
if (!allowedIds.has(item.id)) continue
used.add(item.id)
fromSaved.push({
id: item.id,
label: entry.label,
hidden: item.hidden,
order: fromSaved.length,
width: entry.width !== undefined ? (item.width ?? entry.width) : undefined,
})
}
for (const entry of entries) {
if (used.has(entry.id)) continue
fromSaved.push({
id: entry.id,
label: entry.label,
hidden: false,
order: fromSaved.length,
width: entry.width,
})
}
return fromSaved
})
const visibleIds = computed(() =>
resolved.value.filter(r => !r.hidden).map(r => r.id),
)
function isVisible(id: string): boolean {
return visibleIds.value.includes(id)
}
function orderOf(id: string): number {
const idx = resolved.value.findIndex(r => r.id === id)
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
}
return { resolved, visibleIds, isVisible, orderOf }
}
+3
View File
@@ -59,6 +59,7 @@ import { usePushNotifications } from '@/composables/usePushNotifications'
import { useGoalsStore } from '@/stores/goals.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useTimeTrackingStore } from '@/stores/time-tracking.store'
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
const { isSidebarOpen, isSidebarCollapsed } = useDashboard()
const { connect: connectCentrifugo } = useCentrifugo()
@@ -69,6 +70,7 @@ const appStore = useAppStore()
const goalsStore = useGoalsStore()
const orgStore = useOrganizationStore()
const timeTrackingStore = useTimeTrackingStore()
const uiPrefsStore = useUiPreferencesStore()
watch(
() => timeTrackingStore.lastError,
@@ -139,6 +141,7 @@ onMounted(async () => {
await goalsStore.fetchGoals()
timeTrackingStore.fetchActive()
if (!uiPrefsStore.loaded) uiPrefsStore.fetch()
useEventListener(document, 'visibilitychange', () => {
if (!document.hidden) timeTrackingStore.fetchActive()
+35
View File
@@ -372,6 +372,11 @@ export default {
hideCompleted: 'Hide completed',
sortNewestFirst: 'Newest first',
sortOldestFirst: 'Oldest first',
sortBy: 'Sort by',
sortByDate: 'Creation date',
sortByPriority: 'Priority',
sortHighestFirst: 'Highest first',
sortLowestFirst: 'Lowest first',
deleteConfirm: 'Delete Task',
deleteMessage: 'Are you sure you want to delete "{name}"? This action cannot be undone.',
},
@@ -437,6 +442,7 @@ export default {
github: 'GitHub repository',
docker: 'Docker Images',
accountSettings: 'Account settings',
uiCustomization: 'UI customization',
organizations: 'Organizations',
analytics: 'Analytics',
timeReports: 'Time reports',
@@ -550,6 +556,35 @@ export default {
roleUpdated: 'Role updated',
close: 'Close',
slugTaken: 'This slug is already taken',
personal: 'This is your personal workspace',
personalHint: 'Auto-created together with your account. It cannot be deleted.',
},
uiCustomization: {
title: 'UI customization',
description: 'Toggle visibility and reorder items across app sections. Drag by the handle on the left to reorder. All available items are shown here — permission checks apply at the render site and may still hide an item even when enabled.',
show: 'Show',
hide: 'Hide',
narrow: 'Narrow',
wide: 'Wide',
empty: 'No items to customize',
sections: {
analyticsIndicators: 'Analytics — Indicators',
analyticsCharts: 'Analytics — Charts',
tasks: 'Tasks',
},
taskFields: {
subtasks: 'Subtasks',
note: 'Description',
status: 'Status',
priority: 'Priority',
assignees: 'Assignees',
list: 'List',
tags: 'Tags',
deadline: 'Deadline',
amount: 'Amount',
timeTracking: 'Time tracking',
history: 'History',
},
},
sso: {
noConfig: 'SSO is not configured for this organization',
+35
View File
@@ -345,6 +345,11 @@ export default {
hideCompleted: 'Скрыть выполненные',
sortNewestFirst: 'Сначала новые',
sortOldestFirst: 'Сначала старые',
sortBy: 'Сортировка',
sortByDate: 'Дате создания',
sortByPriority: 'Приоритету',
sortHighestFirst: 'Сначала высокий',
sortLowestFirst: 'Сначала низкий',
deleteConfirm: 'Удалить задачу',
deleteMessage: 'Вы уверены, что хотите удалить "{name}"? Это действие нельзя отменить.',
},
@@ -410,6 +415,7 @@ export default {
github: 'GitHub репозиторий',
docker: 'Docker образы',
accountSettings: 'Настройки аккаунта',
uiCustomization: 'Настройка интерфейса',
organizations: 'Организации',
analytics: 'Аналитика',
timeReports: 'Отчёты по времени',
@@ -523,6 +529,35 @@ export default {
roleUpdated: 'Роль обновлена',
close: 'Закрыть',
slugTaken: 'Этот slug уже занят',
personal: 'Это персональный проект',
personalHint: 'Создан автоматически вместе с аккаунтом. Удалить его нельзя.',
},
uiCustomization: {
title: 'Настройка интерфейса',
description: 'Управляйте видимостью и порядком элементов в разделах приложения. Перетаскивайте за иконку слева, чтобы изменить порядок. Здесь показаны все доступные элементы — права доступа проверяются на месте отображения и могут скрыть элемент даже если он включён.',
show: 'Показывать',
hide: 'Скрывать',
narrow: 'Узкий',
wide: 'Широкий',
empty: 'Нет элементов для настройки',
sections: {
analyticsIndicators: 'Аналитика — показатели',
analyticsCharts: 'Аналитика — графики',
tasks: 'Задачи',
},
taskFields: {
subtasks: 'Подзадачи',
note: 'Описание',
status: 'Статус',
priority: 'Приоритет',
assignees: 'Исполнители',
list: 'Список',
tags: 'Теги',
deadline: 'Дедлайн',
amount: 'Сумма',
timeTracking: 'Учёт времени',
history: 'История',
},
},
sso: {
noConfig: 'SSO не настроен для этой организации',
+5
View File
@@ -84,6 +84,11 @@ const router = createRouter({
name: 'analytics',
component: () => import('./pages/user/analytics.vue'),
},
{
path: 'ui-customization',
name: 'ui-customization',
component: () => import('./pages/user/ui-customization.vue'),
},
{
path: 'time-reports',
name: 'time-reports',
+26 -3
View File
@@ -31,11 +31,11 @@
<template v-else-if="analyticsStore.sections.length > 0">
<div
v-if="analyticsStore.kpiSections.length > 0"
v-if="visibleKpis.length > 0"
class="grid grid-cols-2 gap-3 lg:grid-cols-4"
>
<AnalyticsKpiCard
v-for="kpi in analyticsStore.kpiSections"
v-for="kpi in visibleKpis"
:key="kpi.id"
:section="kpi"
@drill-down="onDrillDown"
@@ -44,7 +44,7 @@
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<AnalyticsSectionCard
v-for="section in analyticsStore.chartSections"
v-for="section in visibleCharts"
:key="section.id"
:section="section"
@drill-down="onDrillDown"
@@ -83,6 +83,7 @@ import { useAnalyticsLocale } from '@/components/features/analytics/composables/
import { useAnalyticsStore } from '@/stores/analytics.store'
import { useGoalsStore } from '@/stores/goals.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useUiPreferences } from '@/composables/useUiPreferences'
const { t } = useI18n()
const { pick } = useAnalyticsLocale()
@@ -92,6 +93,28 @@ const orgStore = useOrganizationStore()
const route = useRoute()
const router = useRouter()
const kpiCatalogue = computed(() =>
analyticsStore.kpiSections.map(s => ({ id: s.id, label: pick(s.title) })),
)
const chartsCatalogue = computed(() =>
analyticsStore.chartSections.map(s => ({ id: s.id, label: pick(s.title) })),
)
const { isVisible: isKpiVisible, orderOf: kpiOrder } =
useUiPreferences('analyticsIndicators', () => kpiCatalogue.value)
const { isVisible: isChartVisible, orderOf: chartOrder } =
useUiPreferences('analyticsCharts', () => chartsCatalogue.value)
const visibleKpis = computed(() =>
analyticsStore.kpiSections
.filter(s => isKpiVisible(s.id))
.sort((a, b) => kpiOrder(a.id) - kpiOrder(b.id)),
)
const visibleCharts = computed(() =>
analyticsStore.chartSections
.filter(s => isChartVisible(s.id))
.sort((a, b) => chartOrder(a.id) - chartOrder(b.id)),
)
const errorMessage = computed(() => {
const e = analyticsStore.error
if (!e) return null
+87
View File
@@ -0,0 +1,87 @@
<template>
<UDashboardPanel id="ui-customization">
<template #header>
<UDashboardNavbar :title="t('uiCustomization.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-6 p-2 lg:p-6 max-w-3xl mx-auto w-full">
<p class="text-sm text-muted">
{{ t('uiCustomization.description') }}
</p>
<UTabs
v-model="activeTab"
:items="tabs"
:orientation="isMobile ? 'vertical' : 'horizontal'"
class="w-full"
:ui="{ root: 'flex-col', list: 'w-full' }"
>
<template
v-for="s in sections"
:key="s.id"
#[s.id]
>
<UiCustomizationSection
:title="t(s.labelKey)"
:resolved="s.resolved"
@change="items => prefsStore.setSection(s.id, items)"
/>
</template>
</UTabs>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import UiCustomizationSection from '@/components/features/ui-customization/UiCustomizationSection.vue'
import { useUiPreferences } from '@/composables/useUiPreferences'
import { uiCustomizationSections } from '@/uiCustomization/registry'
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
import { useTaskView } from '@/composables/useTaskView'
const { t } = useI18n()
const { isMobile } = useTaskView()
const prefsStore = useUiPreferencesStore()
const route = useRoute()
const router = useRouter()
const sections = reactive(
uiCustomizationSections.map((def) => {
const used = def.useSection()
const prefs = useUiPreferences(def.id, () => used.catalogue.value)
return {
id: def.id,
labelKey: def.labelKey,
resolved: prefs.resolved,
init: used.init,
}
}),
)
const tabs = computed(() =>
sections.map(s => ({ value: s.id, label: t(s.labelKey), slot: s.id })),
)
const activeTab = computed({
get: () => {
const q = route.query.tab
return typeof q === 'string' && sections.some(s => s.id === q) ? q : sections[0].id
},
set: (next: string) => {
router.replace({ query: { ...route.query, tab: next } }).catch(() => { })
},
})
onMounted(async () => {
if (!prefsStore.loaded) await prefsStore.fetch()
await Promise.all(sections.map(s => s.init?.()))
})
</script>
+10
View File
@@ -37,6 +37,7 @@ export const useAnalyticsStore = defineStore('analytics', {
customFrom: null,
customTo: null,
sections: [],
sectionsCatalog: [],
failedSectionIds: [],
availableGoals: [],
range: null,
@@ -68,6 +69,15 @@ export const useAnalyticsStore = defineStore('analytics', {
},
},
actions: {
async fetchSectionsCatalog(): Promise<void> {
try {
const result = await $tvApi.analytics.fetchSectionsCatalog()
this.sectionsCatalog = result ?? []
} catch {
this.sectionsCatalog = []
}
},
setScope(scope: AnalyticsScope) {
this.scope = scope
return this.fetchSections()
+3
View File
@@ -27,6 +27,7 @@ export const useTasksStore = defineStore('tasks', {
currentPage: 0,
searchText: '',
firstNew: 1,
sortBy: 'date',
filters: {},
goalId: -1,
},
@@ -135,6 +136,7 @@ export const useTasksStore = defineStore('tasks', {
showCompleted: +showCompleted as 0 | 1,
goalId: +goalId,
firstNew: +firstNew as 0 | 1,
sortBy: this.fetchRules.sortBy,
componentId: ALL_TASKS_LIST_ID,
page: +this.fetchRules.currentPage,
searchText: this.fetchRules.searchText,
@@ -162,6 +164,7 @@ export const useTasksStore = defineStore('tasks', {
showCompleted: +this.fetchRules.showCompleted as 0 | 1,
goalId: +this.fetchRules.goalId,
firstNew: +this.fetchRules.firstNew as 0 | 1,
sortBy: this.fetchRules.sortBy,
componentId: +this.fetchRules.currentListId,
page: +this.fetchRules.currentPage,
searchText: this.fetchRules.searchText,
+55
View File
@@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { UiPreferences, UiPreferencesItem } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
const SAVE_DEBOUNCE_MS = 250
export const useUiPreferencesStore = defineStore('uiPreferences', () => {
const prefs = ref<UiPreferences>({})
const loaded = ref(false)
const loading = ref(false)
const saving = ref(false)
async function fetch() {
if (loading.value) return
loading.value = true
try {
const result = await $tvApi.uiPreferences.fetch()
prefs.value = result ?? {}
loaded.value = true
} finally {
loading.value = false
}
}
const flush = useDebounceFn(async () => {
saving.value = true
try {
const result = await $tvApi.uiPreferences.update(prefs.value)
if (result) prefs.value = result
} finally {
saving.value = false
}
}, SAVE_DEBOUNCE_MS)
function setSection(section: string, items: UiPreferencesItem[]) {
prefs.value = { ...prefs.value, [section]: items }
flush()
}
function getSection(section: string): UiPreferencesItem[] {
return prefs.value[section] ?? []
}
return {
prefs,
loaded,
loading,
saving,
fetch,
setSection,
getSection,
}
})
+2
View File
@@ -4,6 +4,7 @@ import type {
AnalyticsPeriod,
AnalyticsScope,
AnalyticsSection,
AnalyticsSectionCatalogEntry,
AnalyticsSectionsResponse,
} from 'taskview-api'
@@ -31,6 +32,7 @@ export type AnalyticsState = {
customFrom: string | null
customTo: string | null
sections: AnalyticsSection[]
sectionsCatalog: AnalyticsSectionCatalogEntry[]
failedSectionIds: string[]
availableGoals: AnalyticsAvailableGoal[]
range: AnalyticsSectionsResponse['range'] | null
+2 -1
View File
@@ -1,4 +1,4 @@
import type { GoalItem, GoalListItem, Task, TaskFilters } from 'taskview-api'
import type { GoalItem, GoalListItem, Task, TaskFilters, TaskSortBy } from 'taskview-api'
export type TaskItem = Task;
@@ -19,6 +19,7 @@ export type TasksStoreState = {
currentPage: number;
searchText: string;
firstNew: 1 | 0;
sortBy: TaskSortBy;
filters: TaskFilters;
};
loading: boolean;
+9
View File
@@ -0,0 +1,9 @@
import { analyticsIndicatorsSection, analyticsChartsSection } from './sections/analytics'
import { tasksSection } from './sections/tasks'
import type { UiCustomizationSectionDef } from './types'
export const uiCustomizationSections: UiCustomizationSectionDef[] = [
analyticsIndicatorsSection,
analyticsChartsSection,
tasksSection,
]
@@ -0,0 +1,49 @@
import { computed } from 'vue'
import { useAnalyticsLocale } from '@/components/features/analytics/composables/useAnalyticsLocale'
import { useAnalyticsStore } from '@/stores/analytics.store'
import type { UiCustomizationSectionDef } from '../types'
type AnalyticsSubsectionArgs = {
id: string
labelKey: string
payloadKind: 'kpi' | 'series'
}
function buildAnalyticsSection(args: AnalyticsSubsectionArgs): UiCustomizationSectionDef {
const { id, labelKey, payloadKind } = args
return {
id,
labelKey,
useSection() {
const store = useAnalyticsStore()
const { pick } = useAnalyticsLocale()
return {
catalogue: computed(() =>
store.sectionsCatalog
.filter(entry => entry.payloadKind === payloadKind)
.map(entry => ({
id: entry.id,
label: pick(entry.title),
})),
),
init: async () => {
if (store.sectionsCatalog.length === 0) {
await store.fetchSectionsCatalog()
}
},
}
},
}
}
export const analyticsIndicatorsSection = buildAnalyticsSection({
id: 'analyticsIndicators',
labelKey: 'uiCustomization.sections.analyticsIndicators',
payloadKind: 'kpi',
})
export const analyticsChartsSection = buildAnalyticsSection({
id: 'analyticsCharts',
labelKey: 'uiCustomization.sections.analyticsCharts',
payloadKind: 'series',
})
+21
View File
@@ -0,0 +1,21 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { UiCustomizationSectionDef } from '../types'
import { TASK_DETAIL_FIELDS } from './tasks.types'
export const tasksSection: UiCustomizationSectionDef = {
id: 'tasks',
labelKey: 'uiCustomization.sections.tasks',
useSection() {
const { t } = useI18n()
return {
catalogue: computed(() =>
TASK_DETAIL_FIELDS.map(f => ({
id: f.id,
label: t(`uiCustomization.taskFields.${f.id}`),
width: f.width,
})),
),
}
},
}
@@ -0,0 +1,30 @@
export type TaskDetailFieldWidth = 'narrow' | 'wide'
export type TaskDetailField = {
id: string
width: TaskDetailFieldWidth
}
export const TASK_DETAIL_FIELDS = [
{ id: 'subtasks', width: 'wide' },
{ id: 'note', width: 'wide' },
{ id: 'status', width: 'narrow' },
{ id: 'priority', width: 'narrow' },
{ id: 'assignees', width: 'narrow' },
{ id: 'list', width: 'narrow' },
{ id: 'tags', width: 'narrow' },
{ id: 'deadline', width: 'narrow' },
{ id: 'amount', width: 'wide' },
{ id: 'timeTracking', width: 'wide' },
{ id: 'history', width: 'wide' },
] as const satisfies readonly TaskDetailField[]
export type TaskDetailFieldId = typeof TASK_DETAIL_FIELDS[number]['id']
const widthById = new Map<string, TaskDetailFieldWidth>(
TASK_DETAIL_FIELDS.map(f => [f.id, f.width]),
)
export function taskFieldWidth(id: string): TaskDetailFieldWidth {
return widthById.get(id) ?? 'narrow'
}
+13
View File
@@ -0,0 +1,13 @@
import type { ComputedRef } from 'vue'
import type { CatalogueEntry } from '@/composables/useUiPreferences'
export type UiCustomizationSectionUseReturn = {
catalogue: ComputedRef<CatalogueEntry[]>
init?: () => Promise<void> | void
}
export type UiCustomizationSectionDef = {
id: string
labelKey: string
useSection: () => UiCustomizationSectionUseReturn
}