1 parent
6674a6fa2a
commit
4caaf74b9f
5 files changed
+213
-6
No files matched your search
@@ -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}`)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+34
-1
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user