diff --git a/api/src/core/AppUser.ts b/api/src/core/AppUser.ts index 89ef2e9..fb8964c 100644 --- a/api/src/core/AppUser.ts +++ b/api/src/core/AppUser.ts @@ -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 { diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index d20ec1c..21f4624 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -561,5 +561,16 @@ "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." + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.52.0/0.ui_preferences.sql b/api/src/migrations/taskview/sql/1.52.0/0.ui_preferences.sql new file mode 100644 index 0000000..1725fb9 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.52.0/0.ui_preferences.sql @@ -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() +); diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 147ecee..ccbe4e5 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -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 = { '/module/sso': SsoRoutes, '/module/analytics': AnalyticsRoutes, '/module/time-tracking': TimeTrackingRoutes, + '/module/ui-preferences': UiPreferencesRoutes, '/scim/v2': ScimRoutes, }; diff --git a/api/src/tv-modules/analytics/AnalyticsController.ts b/api/src/tv-modules/analytics/AnalyticsController.ts index 0a61dd4..d1347eb 100644 --- a/api/src/tv-modules/analytics/AnalyticsController.ts +++ b/api/src/tv-modules/analytics/AnalyticsController.ts @@ -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) { diff --git a/api/src/tv-modules/analytics/AnalyticsManager.ts b/api/src/tv-modules/analytics/AnalyticsManager.ts index 73f74af..ba623cd 100644 --- a/api/src/tv-modules/analytics/AnalyticsManager.ts +++ b/api/src/tv-modules/analytics/AnalyticsManager.ts @@ -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 { return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW]) } diff --git a/api/src/tv-modules/analytics/AnalyticsRoutes.ts b/api/src/tv-modules/analytics/AnalyticsRoutes.ts index ed2650c..916c318 100644 --- a/api/src/tv-modules/analytics/AnalyticsRoutes.ts +++ b/api/src/tv-modules/analytics/AnalyticsRoutes.ts @@ -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) } } diff --git a/api/src/tv-modules/analytics/sections/SectionRegistry.ts b/api/src/tv-modules/analytics/sections/SectionRegistry.ts index 12a9a08..b373da7 100644 --- a/api/src/tv-modules/analytics/sections/SectionRegistry.ts +++ b/api/src/tv-modules/analytics/sections/SectionRegistry.ts @@ -2,17 +2,12 @@ import type { SectionBuilder } from '../types' import { CreatedTasksKpi } from './kpi/CreatedTasksKpi' import { CompletedTasksKpi } from './kpi/CompletedTasksKpi' import { OverdueKpi } from './kpi/OverdueKpi' -import { CycleTimeKpi } from './kpi/CycleTimeKpi' import { ThroughputSection } from './productivity/ThroughputSection' import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection' import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection' import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection' -import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection' -import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection' import { OverdueByAgeSection } from './quality/OverdueByAgeSection' -import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection' import { StaleTasksSection } from './quality/StaleTasksSection' -import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection' import { StatusDistributionSection } from './usage/StatusDistributionSection' import { ActiveProjectsSection } from './usage/ActiveProjectsSection' import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection' @@ -28,14 +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(), @@ -48,13 +83,13 @@ const builders: SectionBuilder[] = [ // Workload new WorkloadByAssigneeSection(), new BlockedByDependenciesSection(), - new TimeInKanbanStatusSection(), - new AgingOpenTasksSection(), + // 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 ActiveProjectsSection(), @@ -102,4 +137,14 @@ export class SectionRegistry { .map(id => this.byId.get(id)) .filter((b): b is SectionBuilder => !!b && enabledSet.has(b)) } + + catalog(): AnalyticsSectionCatalogEntry[] { + const locales = sectionLocales as Record + 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 }, + })) + } } diff --git a/api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts b/api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts index 2400c66..bd9bbfc 100644 --- a/api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts +++ b/api/src/tv-modules/analytics/sections/usage/ActiveProjectsSection.ts @@ -4,12 +4,7 @@ import { sectionLocales } from '../locales' type StatusKey = 'active' | 'fading' | 'dead' | 'empty' -const COLOR_BY_STATUS: Record = { - 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, diff --git a/api/src/tv-modules/ui-preferences/UiPreferencesController.ts b/api/src/tv-modules/ui-preferences/UiPreferencesController.ts new file mode 100644 index 0000000..43bab0f --- /dev/null +++ b/api/src/tv-modules/ui-preferences/UiPreferencesController.ts @@ -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) + } +} diff --git a/api/src/tv-modules/ui-preferences/UiPreferencesManager.ts b/api/src/tv-modules/ui-preferences/UiPreferencesManager.ts new file mode 100644 index 0000000..9ff2978 --- /dev/null +++ b/api/src/tv-modules/ui-preferences/UiPreferencesManager.ts @@ -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 { + const userId = this.user.getUserData()?.id + if (!userId) return {} + return this.repository.getForUser(userId) + } + + async update(prefs: UiPreferences): Promise { + const userId = this.user.getUserData()?.id + if (!userId) return null + return this.repository.upsert({ userId, prefs }) + } +} diff --git a/api/src/tv-modules/ui-preferences/UiPreferencesRepository.ts b/api/src/tv-modules/ui-preferences/UiPreferencesRepository.ts new file mode 100644 index 0000000..e75d117 --- /dev/null +++ b/api/src/tv-modules/ui-preferences/UiPreferencesRepository.ts @@ -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 { + 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 { + 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 + } +} diff --git a/api/src/tv-modules/ui-preferences/UiPreferencesRoutes.ts b/api/src/tv-modules/ui-preferences/UiPreferencesRoutes.ts new file mode 100644 index 0000000..46b8354 --- /dev/null +++ b/api/src/tv-modules/ui-preferences/UiPreferencesRoutes.ts @@ -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 + 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) + } +} diff --git a/api/src/tv-modules/ui-preferences/types.ts b/api/src/tv-modules/ui-preferences/types.ts new file mode 100644 index 0000000..ddfb4fd --- /dev/null +++ b/api/src/tv-modules/ui-preferences/types.ts @@ -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() + 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 +} diff --git a/taskview-packages/taskview-api/src/api/analytics.ts b/taskview-packages/taskview-api/src/api/analytics.ts index c7b28b5..0a0cbd7 100644 --- a/taskview-packages/taskview-api/src/api/analytics.ts +++ b/taskview-packages/taskview-api/src/api/analytics.ts @@ -4,6 +4,7 @@ import type { AnalyticsFetchDrillDownArg, AnalyticsDrillDownResponse, AnalyticsFetchSectionsArg, + AnalyticsSectionCatalogEntry, AnalyticsSectionsResponse, AnalyticsScope, } from './analytics.types' @@ -20,6 +21,14 @@ function scopeToParams(scope: AnalyticsScope): Record { export default class TvAnalyticsApi extends TvApiBase { protected moduleUrl = '/module/analytics' + public async fetchSectionsCatalog() { + return this.request( + this.$axios.get>( + `${this.moduleUrl}/sections-catalog`, + ), + ) + } + public async fetchSections(arg: AnalyticsFetchSectionsArg, signal?: AbortSignal) { const params: Record = { ...scopeToParams(arg.scope), diff --git a/taskview-packages/taskview-api/src/api/analytics.types.ts b/taskview-packages/taskview-api/src/api/analytics.types.ts index 0b00e0b..6a35ce6 100644 --- a/taskview-packages/taskview-api/src/api/analytics.types.ts +++ b/taskview-packages/taskview-api/src/api/analytics.types.ts @@ -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 diff --git a/taskview-packages/taskview-api/src/api/ui-preferences.ts b/taskview-packages/taskview-api/src/api/ui-preferences.ts new file mode 100644 index 0000000..b77ec8d --- /dev/null +++ b/taskview-packages/taskview-api/src/api/ui-preferences.ts @@ -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>(this.moduleUrl), + ) + } + + public async update(prefs: UiPreferences) { + return this.request( + this.$axios.put>(this.moduleUrl, prefs), + ) + } +} diff --git a/taskview-packages/taskview-api/src/api/ui-preferences.types.ts b/taskview-packages/taskview-api/src/api/ui-preferences.types.ts new file mode 100644 index 0000000..0f99526 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/ui-preferences.types.ts @@ -0,0 +1,8 @@ +export type UiPreferencesItem = { + id: string + order: number + hidden: boolean + width?: 'narrow' | 'wide' +} + +export type UiPreferences = Record diff --git a/taskview-packages/taskview-api/src/index.ts b/taskview-packages/taskview-api/src/index.ts index 8c5973e..01687a3 100644 --- a/taskview-packages/taskview-api/src/index.ts +++ b/taskview-packages/taskview-api/src/index.ts @@ -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'; \ No newline at end of file +export * from '@/api/time-tracking.types'; +export * from '@/api/ui-preferences.types'; \ No newline at end of file diff --git a/taskview-packages/taskview-api/src/tv.ts b/taskview-packages/taskview-api/src/tv.ts index 0a317a5..4ac5801 100644 --- a/taskview-packages/taskview-api/src/tv.ts +++ b/taskview-packages/taskview-api/src/tv.ts @@ -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) { diff --git a/taskview-packages/taskview-db-schemas/src/index.ts b/taskview-packages/taskview-db-schemas/src/index.ts index 326a750..6fcc40e 100644 --- a/taskview-packages/taskview-db-schemas/src/index.ts +++ b/taskview-packages/taskview-db-schemas/src/index.ts @@ -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'; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/ui-preferences.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/ui-preferences.schema.ts new file mode 100644 index 0000000..630993d --- /dev/null +++ b/taskview-packages/taskview-db-schemas/src/schemas/ui-preferences.schema.ts @@ -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 diff --git a/web/src/components/UserMenu.vue b/web/src/components/UserMenu.vue index cd772d6..83dc121 100644 --- a/web/src/components/UserMenu.vue +++ b/web/src/components/UserMenu.vue @@ -169,6 +169,13 @@ const items = computed(() => [ 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', diff --git a/web/src/components/features/analytics/AnalyticsChart.vue b/web/src/components/features/analytics/AnalyticsChart.vue index bc13b20..6a6d9ab 100644 --- a/web/src/components/features/analytics/AnalyticsChart.vue +++ b/web/src/components/features/analytics/AnalyticsChart.vue @@ -144,5 +144,3 @@ watch(() => props.section, () => { watch(currentChartType, () => render()) watch(locale, () => render()) - - diff --git a/web/src/components/features/analytics/AnalyticsSectionCard.vue b/web/src/components/features/analytics/AnalyticsSectionCard.vue index 07beb6a..9729d03 100644 --- a/web/src/components/features/analytics/AnalyticsSectionCard.vue +++ b/web/src/components/features/analytics/AnalyticsSectionCard.vue @@ -1,5 +1,5 @@ - - diff --git a/web/src/components/features/tasks/TaskDetailPanel.vue b/web/src/components/features/tasks/TaskDetailPanel.vue index 2c4a3e5..ba6a989 100644 --- a/web/src/components/features/tasks/TaskDetailPanel.vue +++ b/web/src/components/features/tasks/TaskDetailPanel.vue @@ -40,90 +40,82 @@ {{ task.sourceUrl }} - - - - - - -
- - - - - +
+
- - -
- - - - -
- -
- - - - -
- - - - - - - - -
@@ -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() + 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 ?? '') diff --git a/web/src/components/features/ui-customization/UiCustomizationItem.vue b/web/src/components/features/ui-customization/UiCustomizationItem.vue new file mode 100644 index 0000000..83ef2f8 --- /dev/null +++ b/web/src/components/features/ui-customization/UiCustomizationItem.vue @@ -0,0 +1,49 @@ + + + diff --git a/web/src/components/features/ui-customization/UiCustomizationSection.vue b/web/src/components/features/ui-customization/UiCustomizationSection.vue new file mode 100644 index 0000000..ec0f073 --- /dev/null +++ b/web/src/components/features/ui-customization/UiCustomizationSection.vue @@ -0,0 +1,89 @@ + + + diff --git a/web/src/composables/useUiPreferences.ts b/web/src/composables/useUiPreferences.ts new file mode 100644 index 0000000..505f747 --- /dev/null +++ b/web/src/composables/useUiPreferences.ts @@ -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 = { + 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 = { + 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(() => { + 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() + 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 } +} diff --git a/web/src/layouts/UserLayout.vue b/web/src/layouts/UserLayout.vue index a876365..6d9adeb 100644 --- a/web/src/layouts/UserLayout.vue +++ b/web/src/layouts/UserLayout.vue @@ -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() diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index c3edd28..1a8002d 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -437,6 +437,7 @@ export default { github: 'GitHub repository', docker: 'Docker Images', accountSettings: 'Account settings', + uiCustomization: 'UI customization', organizations: 'Organizations', analytics: 'Analytics', timeReports: 'Time reports', @@ -553,6 +554,33 @@ export default { 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', configure: 'Configure SSO', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index e985ab3..c1f18bf 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -410,6 +410,7 @@ export default { github: 'GitHub репозиторий', docker: 'Docker образы', accountSettings: 'Настройки аккаунта', + uiCustomization: 'Настройка интерфейса', organizations: 'Организации', analytics: 'Аналитика', timeReports: 'Отчёты по времени', @@ -526,6 +527,33 @@ export default { 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 не настроен для этой организации', configure: 'Настроить SSO', diff --git a/web/src/main.ts b/web/src/main.ts index 962e344..c8ee5bb 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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', diff --git a/web/src/pages/user/analytics.vue b/web/src/pages/user/analytics.vue index fb7a1fe..b0a4fcc 100644 --- a/web/src/pages/user/analytics.vue +++ b/web/src/pages/user/analytics.vue @@ -31,11 +31,11 @@