1 parent
6674a6fa2a
commit
4caaf74b9f
5 files changed
+213
-6
No files matched your search
@@ -47,6 +47,27 @@ export interface IssueStat {
|
|||||||
byStatus: IssueStatByStatus[]
|
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
|
// CLIENT REQUEST
|
||||||
|
|
||||||
export async function itemsList(params: {
|
export async function itemsList(params: {
|
||||||
@@ -89,3 +110,8 @@ export async function itemStat(projectId?: string) {
|
|||||||
const query = toQuery({ projectId: projectId })
|
const query = toQuery({ projectId: projectId })
|
||||||
return apiClient.get<IssueStat>(`/api/issues/stats${query}`)
|
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 {
|
&__canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
height: 180px;
|
height: 200px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -3,7 +3,9 @@ import { ref } from 'vue'
|
|||||||
import {
|
import {
|
||||||
type CreateIssueInput,
|
type CreateIssueInput,
|
||||||
type Issue,
|
type Issue,
|
||||||
|
type DailyIssueStats,
|
||||||
itemCreate,
|
itemCreate,
|
||||||
|
itemDailyStat,
|
||||||
itemDelete,
|
itemDelete,
|
||||||
itemGetByKey,
|
itemGetByKey,
|
||||||
itemsList,
|
itemsList,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
export const useIssueStore = defineStore('item', () => {
|
export const useIssueStore = defineStore('item', () => {
|
||||||
const issues = ref<Issue[]>([])
|
const issues = ref<Issue[]>([])
|
||||||
const stats = ref<IssueStat>()
|
const stats = ref<IssueStat>()
|
||||||
|
const dailyStats = ref<DailyIssueStats>()
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const lastMeta = ref<Meta>({
|
const lastMeta = ref<Meta>({
|
||||||
page: 1,
|
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 LabelText from '@/components/LabelText.vue'
|
||||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
import ProjectMenu from '@/components/ProjectMenu.vue'
|
||||||
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
||||||
|
import IssueActivityChart from '@/components/IssueActivityChart.vue'
|
||||||
|
|
||||||
const ACTIVE_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
|
const ACTIVE_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
|
||||||
|
|
||||||
|
const ACTIVITY_PERIODS = [7, 30, 90]
|
||||||
|
|
||||||
const PRIORITY_ICONS: Record<number, string> = {
|
const PRIORITY_ICONS: Record<number, string> = {
|
||||||
4: 'mdi-chevron-double-up',
|
4: 'mdi-chevron-double-up',
|
||||||
3: 'mdi-chevron-up',
|
3: 'mdi-chevron-up',
|
||||||
@@ -30,7 +33,7 @@ const issueStore = useIssueStore()
|
|||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const statusStore = useStatusStore()
|
const statusStore = useStatusStore()
|
||||||
|
|
||||||
const { stats } = storeToRefs(issueStore)
|
const { stats, dailyStats } = storeToRefs(issueStore)
|
||||||
const { projects, current, currentProjectId } = storeToRefs(projectStore)
|
const { projects, current, currentProjectId } = storeToRefs(projectStore)
|
||||||
|
|
||||||
const currentUserId = computed(() => session.value.data?.user.id ?? null)
|
const currentUserId = computed(() => session.value.data?.user.id ?? null)
|
||||||
@@ -76,6 +79,16 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const activityDays = ref(30)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[currentProjectId, activityDays],
|
||||||
|
([id, days]) => {
|
||||||
|
void issueStore.getDailyStat(id, days)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
function isOverdue(issue: Issue) {
|
function isOverdue(issue: Issue) {
|
||||||
return !!issue.dueDate && new Date(issue.dueDate).getTime() < Date.now()
|
return !!issue.dueDate && new Date(issue.dueDate).getTime() < Date.now()
|
||||||
}
|
}
|
||||||
@@ -153,9 +166,18 @@ function onClickCard(status?: string) {
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<!-- <v-card border>-->
|
<v-card border>
|
||||||
<!-- <v-card-text>text</v-card-text>-->
|
<v-card-title class="d-flex align-center justify-space-between">
|
||||||
<!-- </v-card>-->
|
<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-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user