1 parent
6674a6fa2a
commit
4caaf74b9f
5 files changed
+213
-6
No files matched your search
@@ -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>
|
||||
@@ -109,7 +109,7 @@ const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||
&__canvas {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
height: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user