chart bar
Gitea Actions Demo / Deploy (push) Successful in 25s

This commit is contained in:
vadim committed 2026-10-08 14:06:15 +03:00
1 parent 6674a6fa2a
commit 4caaf74b9f
5 files changed
+213 -6

No files matched your search

+26
View File
@@ -47,6 +47,27 @@ export interface IssueStat {
byStatus: IssueStatByStatus[]
}
/** Count per issue status; statuses without issues are 0 */
export type IssueStatusCounts = Record<Issue['status'], number>
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<IssueStat>(`/api/issues/stats${query}`)
}
export async function itemDailyStat(params: { projectId?: string; from?: string; to?: string; timeZone?: string }) {
const query = toQuery(params)
return apiClient.get<DailyIssueStats>(`/api/issues/stats/daily${query}`)
}
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useTheme } from 'vuetify'
import {
BarElement,
CategoryScale,
Chart as ChartJS,
type ChartData,
type ChartOptions,
Legend,
LinearScale,
Tooltip,
} from 'chart.js'
import { Bar } from 'vue-chartjs'
import { useStatusStore } from '@/stores/status.ts'
import type { DailyIssueStats, IssueStatusCounts } from '@/api/issue.api.ts'
ChartJS.register(BarElement, CategoryScale, LinearScale, Legend, Tooltip)
const FALLBACK_COLOR = '#9E9E9E'
interface Props {
stats?: DailyIssueStats | null
}
const props = defineProps<Props>()
const theme = useTheme()
const statusStore = useStatusStore()
const { statuses } = storeToRefs(statusStore)
function themeColor(name: string, fallback = FALLBACK_COLOR): string {
const color = theme.current.value.colors[name]
return typeof color === 'string' ? color : fallback
}
// Vuetify generates on-* colors as short hex (#FFF / #000), so appending an alpha suffix is not enough
function withAlpha(hex: string, alpha: number): string {
const value = hex.replace('#', '')
const full = value.length === 3 ? [...value].map((c) => c + c).join('') : value.slice(0, 6)
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16))
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// Dates come as YYYY-MM-DD already in the user's time zone, so format them as UTC to avoid a day shift
const dateFormat = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
function formatDate(date: string) {
return dateFormat.format(new Date(`${date}T00:00:00Z`))
}
const days = computed(() => props.stats?.days ?? [])
// One stacked bar per day: created issues at the bottom, transitions into each status on top.
// Status order comes from the API so a status always lands on the same place in the stack
const chartData = computed<ChartData<'bar'>>(() => ({
labels: days.value.map((day) => formatDate(day.date)),
datasets: [
{
label: 'Created',
data: days.value.map((day) => day.created),
backgroundColor: themeColor('primary'),
},
...(statuses.value ?? []).map((status) => ({
label: `→ ${status.name}`,
data: days.value.map((day) => day.transitions[status.alias as keyof IssueStatusCounts] ?? 0),
backgroundColor: themeColor(status.color),
})),
],
}))
const chartOptions = computed<ChartOptions<'bar'>>(() => {
// Muted axis text and grid lines derived from on-surface, so they follow the theme
const onSurface = themeColor('on-surface', '#000000')
const textColor = withAlpha(onSurface, 0.6)
const gridColor = withAlpha(onSurface, 0.08)
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: { color: textColor, boxWidth: 10, boxHeight: 10 },
},
tooltip: {
mode: 'index',
intersect: false,
// A day usually touches only a couple of statuses — hide the empty rows
filter: (item) => (item.parsed.y ?? 0) > 0,
},
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: { color: textColor, maxRotation: 0, autoSkip: true, autoSkipPadding: 12 },
},
y: {
stacked: true,
beginAtZero: true,
grid: { color: gridColor },
border: { display: false },
// Counts are integers — no 0.5 ticks on quiet periods
ticks: { color: textColor, precision: 0 },
},
},
}
})
</script>
<template>
<div class="activity-chart">
<bar :data="chartData" :options="chartOptions" />
</div>
</template>
<style scoped lang="scss">
.activity-chart {
position: relative;
height: 200px;
}
</style>
+1 -1
View File
@@ -109,7 +109,7 @@ const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
&__canvas {
position: relative;
width: 180px;
height: 180px;
height: 200px;
flex-shrink: 0;
}
+34 -1
View File
@@ -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<Issue[]>([])
const stats = ref<IssueStat>()
const dailyStats = ref<DailyIssueStats>()
const error = ref<string | null>(null)
const lastMeta = ref<Meta>({
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<DailyIssueStats | undefined> {
// 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())}`
}
+26 -4
View File
@@ -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<number, string> = {
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) {
</v-card>
</v-col>
<v-col cols="12" md="6">
<!-- <v-card border>-->
<!-- <v-card-text>text</v-card-text>-->
<!-- </v-card>-->
<v-card border>
<v-card-title class="d-flex align-center justify-space-between">
<span>Issues activity</span>
<v-btn-toggle border density="comfortable" size="small" v-model="activityDays" mandatory>
<v-btn border="0" v-for="days in ACTIVITY_PERIODS" :key="days" :value="days">{{ days }}d</v-btn>
</v-btn-toggle>
</v-card-title>
<v-divider />
<v-card-text>
<issue-activity-chart :stats="dailyStats" />
</v-card-text>
</v-card>
</v-col>
</v-row>