fix: restore dashboard history and complete activity calendar

Combine retained daily aggregates with recent usage without replaying historical records or changing the activation boundary. Complete activation-day metrics and expose the UTC calendar used by legacy history.

Render all 365 calendar dates, size heatmap cells responsively, and soften cell corners. Add regression coverage for history cutoffs, retention, sparse dates, and timezone boundaries.
This commit is contained in:
elky
2026-10-05 19:03:33 +08:00
parent 2539439656
commit 082348a5d1
15 changed files with 746 additions and 28 deletions
+20 -5
View File
@@ -341,7 +341,7 @@
:data="activityHeatmap"
:consecutive-active-days="dashboardSnapshot?.consecutive_active_days ?? null"
:active-days="dashboardSnapshot?.active_days ?? null"
:scope-hint="statisticsScope + ' · ' + t('展示近365天', 'Last 365 days shown')"
:scope-hint="activityScope"
:loading="loading"
:error="false"
:timeline-data="isDemo ? demoTimeline : undefined"
@@ -843,14 +843,29 @@ const todayStats = ref<{
const dashboardSnapshot = ref<OverviewDashboardSummary | null>(null);
const statsSince = computed(() => timestamp(dashboardSnapshot.value?.stats_since, dashboardSnapshot.value?.timezone));
const statisticsScope = computed(() => `${t('统计自', 'Statistics since')} ${statsSince.value} · ${t('更新于', 'Updated at')} ${timestamp(dashboardSnapshot.value?.generated_at, dashboardSnapshot.value?.timezone)}`);
const activityScope = computed(() => [
statisticsScope.value,
t('展示近365天', 'Last 365 days shown'),
dashboardSnapshot.value?.activity_timezone ?? dashboardSnapshot.value?.timezone,
].filter(Boolean).join(' · '));
const activityHeatmap = computed<ActivityHeatmap | null>(() => {
const snapshot = dashboardSnapshot.value;
if (!snapshot) return null;
const days = snapshot.activity_days;
const dateParts = new Intl.DateTimeFormat('en', {
timeZone: snapshot.activity_timezone ?? snapshot.timezone,
year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(new Date(snapshot.generated_at));
const datePart = (type: Intl.DateTimeFormatPartTypes) => Number(dateParts.find(part => part.type === type)?.value);
const today = Date.UTC(datePart('year'), datePart('month') - 1, datePart('day'));
const requestsByDate = new Map(snapshot.activity_days.map(day => [day.date, day.requests]));
const days = Array.from({ length: 365 }, (_, index) => {
const date = new Date(today - (364 - index) * 86_400_000).toISOString().slice(0, 10);
return { date, requests: requestsByDate.get(date) ?? 0 };
});
return {
start_date: days[0]?.date ?? snapshot.stats_since.slice(0, 10),
end_date: days[days.length - 1]?.date ?? snapshot.generated_at.slice(0, 10),
total_days: days.length,
start_date: new Date(today - 364 * 86_400_000).toISOString().slice(0, 10),
end_date: new Date(today).toISOString().slice(0, 10),
total_days: 365,
max_requests: days.reduce((max, day) => Math.max(max, day.requests), 0),
days,
};
@@ -8,7 +8,25 @@ const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), das
vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }) }))
vi.mock('@/api/overview', () => ({ overviewApi: api }))
vi.mock('@/api/dashboard', () => ({ dashboardApi: { getDailyStats: api.daily } }))
vi.mock('@/features/overview/dashboard/DashboardActivity.vue', () => ({ default: { render: () => null } }))
vi.mock('@/features/overview/dashboard/DashboardActivity.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
props: { data: Object, scopeHint: String, consecutiveActiveDays: Number, activeDays: Number },
setup: props => () => h('section', {
'data-dashboard-activity': '',
title: props.scopeHint,
'data-start-date': props.data?.start_date,
'data-end-date': props.data?.end_date,
'data-total-days': props.data?.total_days,
}, [
h('span', `${props.consecutiveActiveDays} / ${props.activeDays}`),
...((props.data?.days ?? []) as { date: string; requests: number }[]).map(day =>
h('span', { 'data-activity-date': day.date }, String(day.requests))),
]),
}),
}
})
vi.mock('@/features/overview/dashboard/DashboardAnnouncements.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/DoughnutChart.vue', () => ({ default: { render: () => null } }))
@@ -55,6 +73,99 @@ describe('dashboard snapshot loading', () => {
expect(api.daily).toHaveBeenCalledTimes(1)
})
it('labels restored history with its UTC activity dates independently of the dashboard timezone', async () => {
const snapshot = dashboardSummary()
snapshot.timezone = 'Asia/Shanghai'
snapshot.activity_timezone = 'UTC'
snapshot.stats_since = '2026-05-20T00:00:00Z'
snapshot.activity_days = [{ date: '2026-05-20', requests: 1234 }, ...snapshot.activity_days]
api.dashboardSummary.mockResolvedValue(snapshot)
const root = mount()
await settle()
const activity = root.querySelector('[data-dashboard-activity]')
expect(activity?.getAttribute('title')).toContain('展示近365天 · UTC')
expect(activity?.getAttribute('title')).toContain('统计自 2026/5/20 08:00')
expect(activity?.textContent).toContain('2 / 413')
expect(activity?.querySelector('[data-activity-date="2026-05-20"]')?.textContent).toBe('1234')
})
it('uses the dashboard timezone for activity when the optional activity timezone is absent', async () => {
const snapshot = dashboardSummary()
snapshot.timezone = 'Asia/Shanghai'
api.dashboardSummary.mockResolvedValue(snapshot)
const root = mount()
await settle()
expect(root.querySelector('[data-dashboard-activity]')?.getAttribute('title'))
.toContain('展示近365天 · Asia/Shanghai')
})
it.each([
{ activityTimezone: 'UTC', start: '2025-09-20', end: '2026-09-19' },
{ activityTimezone: 'Asia/Shanghai', start: '2025-09-21', end: '2026-09-20' },
{ activityTimezone: undefined, start: '2025-09-21', end: '2026-09-20' },
])('ends the 365-day calendar on $end for activity timezone $activityTimezone', async ({ activityTimezone, start, end }) => {
const snapshot = dashboardSummary()
snapshot.timezone = 'Asia/Shanghai'
snapshot.activity_timezone = activityTimezone
snapshot.generated_at = '2026-09-19T16:30:00Z'
api.dashboardSummary.mockResolvedValue(snapshot)
const root = mount()
await settle()
const activity = root.querySelector('[data-dashboard-activity]')
const days = activity?.querySelectorAll('[data-activity-date]')
expect(activity?.getAttribute('data-start-date')).toBe(start)
expect(activity?.getAttribute('data-end-date')).toBe(end)
expect(activity?.getAttribute('data-total-days')).toBe('365')
expect(days).toHaveLength(365)
expect(days?.[0]?.getAttribute('data-activity-date')).toBe(start)
expect(days?.[364]?.getAttribute('data-activity-date')).toBe(end)
})
it('includes empty days at both ends and between requests without changing lifetime active counts', async () => {
const snapshot = dashboardSummary()
snapshot.activity_days = [
{ date: '2025-09-19', requests: 9999 },
{ date: '2026-09-16', requests: 50 },
{ date: '2026-09-18', requests: 10 },
{ date: '2026-09-20', requests: 9999 },
]
api.dashboardSummary.mockResolvedValue(snapshot)
const root = mount()
await settle()
const activity = root.querySelector('[data-dashboard-activity]')
expect(activity?.querySelectorAll('[data-activity-date]')).toHaveLength(365)
for (const date of ['2025-09-20', '2026-09-17', '2026-09-19']) {
expect(activity?.querySelector(`[data-activity-date="${date}"]`)?.textContent).toBe('0')
}
expect(activity?.querySelector('[data-activity-date="2026-09-16"]')?.textContent).toBe('50')
expect(activity?.querySelector('[data-activity-date="2026-09-18"]')?.textContent).toBe('10')
expect(activity?.querySelector('[data-activity-date="2025-09-19"]')).toBeNull()
expect(activity?.querySelector('[data-activity-date="2026-09-20"]')).toBeNull()
expect(activity?.textContent).toContain('2 / 413')
})
it('shows all 365 zero-request days when no usage history exists', async () => {
const snapshot = dashboardSummary()
snapshot.activity_days = []
snapshot.active_days = 0
snapshot.consecutive_active_days = 0
api.dashboardSummary.mockResolvedValue(snapshot)
const root = mount()
await settle()
const activity = root.querySelector('[data-dashboard-activity]')
const days = activity?.querySelectorAll('[data-activity-date]')
expect(days).toHaveLength(365)
expect(Array.from(days ?? []).every(day => day.textContent === '0')).toBe(true)
expect(activity?.getAttribute('data-start-date')).toBe('2025-09-20')
expect(activity?.getAttribute('data-end-date')).toBe('2026-09-19')
expect(activity?.textContent).toContain('0 / 0')
})
it('does not loop requests with the real time picker when summary fails', async () => {
const snapshot = deferred<OverviewDashboardSummary>()
api.dashboardSummary.mockReturnValue(snapshot.promise)