From 4caaf74b9fa7131d940cc35822b0154603050e25 Mon Sep 17 00:00:00 2001 From: Vadim Date: Thu, 8 Oct 2026 14:06:15 +0300 Subject: [PATCH] chart bar --- src/api/issue.api.ts | 26 ++++++ src/components/IssueActivityChart.vue | 126 ++++++++++++++++++++++++++ src/components/IssueStatusChart.vue | 2 +- src/stores/issue.ts | 35 ++++++- src/views/HomeView.vue | 30 +++++- 5 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 src/components/IssueActivityChart.vue diff --git a/src/api/issue.api.ts b/src/api/issue.api.ts index d885b6a..12e7e8c 100644 --- a/src/api/issue.api.ts +++ b/src/api/issue.api.ts @@ -47,6 +47,27 @@ export interface IssueStat { byStatus: IssueStatByStatus[] } +/** Count per issue status; statuses without issues are 0 */ +export type IssueStatusCounts = Record + +export interface DailyIssueStat { + /** YYYY-MM-DD in the requested time zone */ + date: string + /** Issues created during the day */ + created: number + /** Transitions into each status during the day (creation into OPEN is not included) */ + transitions: IssueStatusCounts + /** Issues in each status at the end of the day */ + byStatus: IssueStatusCounts +} + +export interface DailyIssueStats { + from: string + to: string + timeZone: string + days: DailyIssueStat[] +} + // CLIENT REQUEST export async function itemsList(params: { @@ -89,3 +110,8 @@ export async function itemStat(projectId?: string) { const query = toQuery({ projectId: projectId }) return apiClient.get(`/api/issues/stats${query}`) } + +export async function itemDailyStat(params: { projectId?: string; from?: string; to?: string; timeZone?: string }) { + const query = toQuery(params) + return apiClient.get(`/api/issues/stats/daily${query}`) +} diff --git a/src/components/IssueActivityChart.vue b/src/components/IssueActivityChart.vue new file mode 100644 index 0000000..90c43d4 --- /dev/null +++ b/src/components/IssueActivityChart.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/components/IssueStatusChart.vue b/src/components/IssueStatusChart.vue index f35ac5d..36b659e 100644 --- a/src/components/IssueStatusChart.vue +++ b/src/components/IssueStatusChart.vue @@ -109,7 +109,7 @@ const chartOptions = computed>(() => ({ &__canvas { position: relative; width: 180px; - height: 180px; + height: 200px; flex-shrink: 0; } diff --git a/src/stores/issue.ts b/src/stores/issue.ts index f7b0f5c..d7d0f13 100644 --- a/src/stores/issue.ts +++ b/src/stores/issue.ts @@ -3,7 +3,9 @@ import { ref } from 'vue' import { type CreateIssueInput, type Issue, + type DailyIssueStats, itemCreate, + itemDailyStat, itemDelete, itemGetByKey, itemsList, @@ -17,6 +19,7 @@ import { export const useIssueStore = defineStore('item', () => { const issues = ref([]) const stats = ref() + const dailyStats = ref() const error = ref(null) const lastMeta = ref({ page: 1, @@ -85,5 +88,35 @@ export const useIssueStore = defineStore('item', () => { } } - return { stats, list, getByKey, create, update, remove, getStat } + let dailyStatsRequestId = 0 + + /** Stats for the last `days` days including today, in the user's local time zone */ + async function getDailyStat(projectId?: string | null, days = 30): Promise { + // Ignore responses that arrive after the project or period has changed again + const requestId = ++dailyStatsRequestId + try { + const res = await itemDailyStat({ + projectId: projectId ?? undefined, + from: localDateDaysAgo(days - 1), + // Day boundaries are computed on the backend in the user's local time zone; `to` defaults to today there + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + if (requestId !== dailyStatsRequestId) return + dailyStats.value = res + + return dailyStats.value + } catch (e) { + error.value = e instanceof Error ? e.message : 'error getting daily issue stat' + } + } + + return { stats, dailyStats, list, getByKey, create, update, remove, getStat, getDailyStat } }) + +/** Local YYYY-MM-DD `days` days before today */ +function localDateDaysAgo(days: number): string { + const date = new Date() + date.setDate(date.getDate() - days) + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index dd9b42b..8b96060 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -12,9 +12,12 @@ import DashboardCountCard from '@/components/DashboardCountCard.vue' import LabelText from '@/components/LabelText.vue' import ProjectMenu from '@/components/ProjectMenu.vue' import IssueStatusChart from '@/components/IssueStatusChart.vue' +import IssueActivityChart from '@/components/IssueActivityChart.vue' const ACTIVE_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS'] +const ACTIVITY_PERIODS = [7, 30, 90] + const PRIORITY_ICONS: Record = { 4: 'mdi-chevron-double-up', 3: 'mdi-chevron-up', @@ -30,7 +33,7 @@ const issueStore = useIssueStore() const projectStore = useProjectStore() const statusStore = useStatusStore() -const { stats } = storeToRefs(issueStore) +const { stats, dailyStats } = storeToRefs(issueStore) const { projects, current, currentProjectId } = storeToRefs(projectStore) const currentUserId = computed(() => session.value.data?.user.id ?? null) @@ -76,6 +79,16 @@ watch( { immediate: true }, ) +const activityDays = ref(30) + +watch( + [currentProjectId, activityDays], + ([id, days]) => { + void issueStore.getDailyStat(id, days) + }, + { immediate: true }, +) + function isOverdue(issue: Issue) { return !!issue.dueDate && new Date(issue.dueDate).getTime() < Date.now() } @@ -153,9 +166,18 @@ function onClickCard(status?: string) { - - - + + + Issues activity + + {{ days }}d + + + + + + +