mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
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:
@@ -114,6 +114,7 @@ export interface OverviewDashboardSummary {
|
||||
stats_since: string
|
||||
generated_at: string
|
||||
timezone: string
|
||||
activity_timezone?: string
|
||||
today_from: string
|
||||
window_seconds: number
|
||||
today: {
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="day"
|
||||
class="cursor-pointer cell-emerge rounded-[4px]"
|
||||
class="cursor-pointer cell-emerge rounded-[6px]"
|
||||
:class="compact
|
||||
? 'transition-[filter] duration-150 hover:brightness-90'
|
||||
: 'transition-all duration-200 hover:shadow-lg'"
|
||||
@@ -144,7 +144,7 @@
|
||||
<div
|
||||
v-else
|
||||
:style="cellStyle"
|
||||
class="bg-transparent rounded-[4px]"
|
||||
class="bg-transparent rounded-[6px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +198,8 @@ const heatmapScroller = ref<HTMLElement | null>(null)
|
||||
const heatmapWrapper = ref<HTMLElement | null>(null)
|
||||
const heatmapWidth = ref(0)
|
||||
const cellSize = ref(10)
|
||||
const cellGap = ref(props.compact ? 2 : 4)
|
||||
const wideScreen = ref(true)
|
||||
const cellGap = computed(() => !props.compact && wideScreen.value ? 4 : 2)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({
|
||||
day: null,
|
||||
@@ -236,16 +237,20 @@ const verticalGapStyle = computed(() => ({
|
||||
}))
|
||||
|
||||
const weekColumns = computed(() => {
|
||||
if (!props.data || !props.data.days || props.data.days.length === 0) {
|
||||
if (!props.data) {
|
||||
return []
|
||||
}
|
||||
|
||||
const dayEntries: DayWithMeta[] = props.data.days.map(day => ({
|
||||
const dayEntries: DayWithMeta[] = [...props.data.days].sort((a, b) => a.date.localeCompare(b.date)).map(day => ({
|
||||
...day,
|
||||
dateObj: new Date(`${day.date}T00:00:00Z`)
|
||||
}))
|
||||
|
||||
const firstDay = dayEntries[0]?.dateObj
|
||||
const firstDay = new Date(`${props.data.start_date}T00:00:00Z`)
|
||||
const lastDay = new Date(`${props.data.end_date}T00:00:00Z`)
|
||||
if (!Number.isFinite(firstDay.getTime()) || !Number.isFinite(lastDay.getTime()) || firstDay > lastDay) {
|
||||
return []
|
||||
}
|
||||
const padding: (DayWithMeta | null)[] = []
|
||||
if (firstDay) {
|
||||
const weekday = firstDay.getUTCDay() // 周日=0, 周一=1, ..., 周六=6
|
||||
@@ -254,7 +259,18 @@ const weekColumns = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const paddedDays: (DayWithMeta | null)[] = [...padding, ...dayEntries]
|
||||
// Keep every date in the declared range, including inactive dates before the
|
||||
// first request and after the latest one.
|
||||
const daysByDate = new Map(dayEntries.map(day => [day.date, day]))
|
||||
const paddedDays: (DayWithMeta | null)[] = [...padding]
|
||||
if (firstDay && lastDay) {
|
||||
const cursor = new Date(firstDay)
|
||||
while (cursor <= lastDay) {
|
||||
const date = cursor.toISOString().slice(0, 10)
|
||||
paddedDays.push(daysByDate.get(date) ?? { date, requests: 0, dateObj: new Date(cursor) })
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
}
|
||||
const remainder = paddedDays.length % 7
|
||||
if (remainder !== 0) {
|
||||
for (let i = remainder; i < 7; i++) {
|
||||
@@ -356,12 +372,15 @@ const recalcCellSize = () => {
|
||||
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
||||
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
||||
const rawSize = availableSpace / columnCount
|
||||
// 自适应尺寸,最小 6px
|
||||
cellSize.value = Math.max(6, rawSize)
|
||||
// Let a complete year fill the card, while short histories never grow cells
|
||||
// beyond the size they would have in a full calendar.
|
||||
const yearCellSize = (heatmapWidth.value - 52 * cellGap.value) / 53
|
||||
const maxSize = Math.max(props.compact ? 12 : 16, yearCellSize)
|
||||
cellSize.value = Math.min(maxSize, Math.max(6, rawSize))
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
|
||||
[heatmapWidth, () => weekColumns.value.length, cellGap, () => props.compact],
|
||||
() => {
|
||||
recalcCellSize()
|
||||
},
|
||||
@@ -394,8 +413,7 @@ onMounted(() => {
|
||||
}
|
||||
mediaQuery = window.matchMedia('(min-width: 640px)')
|
||||
const updateGap = () => {
|
||||
cellGap.value = !props.compact && mediaQuery && mediaQuery.matches ? 4 : 2
|
||||
recalcCellSize()
|
||||
wideScreen.value = mediaQuery?.matches ?? false
|
||||
}
|
||||
mediaQueryHandler = () => updateGap()
|
||||
updateGap()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||
import ActivityHeatmap from '../ActivityHeatmap.vue'
|
||||
import type { ActivityHeatmap as HeatmapData } from '@/types/activity'
|
||||
|
||||
const mounted: Array<{ app: App; root: HTMLElement }> = []
|
||||
const observers: ResizeObserverStub[] = []
|
||||
|
||||
class ResizeObserverStub {
|
||||
target: Element | null = null
|
||||
constructor(private callback: ResizeObserverCallback) { observers.push(this) }
|
||||
observe(target: Element) { this.target = target }
|
||||
unobserve() {}
|
||||
disconnect() { this.target = null }
|
||||
resize(width: number) {
|
||||
if (!this.target) throw new Error('Heatmap has not registered its resize observer')
|
||||
this.callback([{ target: this.target, contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() }
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function heatmap(startDate: string, length: number): HeatmapData {
|
||||
const start = new Date(`${startDate}T00:00:00Z`).getTime()
|
||||
const days = Array.from({ length }, (_, index) => ({
|
||||
date: new Date(start + index * 86400000).toISOString().slice(0, 10),
|
||||
requests: index === length - 1 ? 250 : 0,
|
||||
}))
|
||||
return { start_date: startDate, end_date: days[days.length - 1]!.date, total_days: length, max_requests: 250, days }
|
||||
}
|
||||
|
||||
async function settle() { await nextTick(); await nextTick() }
|
||||
|
||||
async function mount(data: HeatmapData, compact = true) {
|
||||
const model = ref(data)
|
||||
const isCompact = ref(compact)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(defineComponent({
|
||||
setup: () => () => h(ActivityHeatmap, { data: model.value, compact: isCompact.value, showHeader: false }),
|
||||
}))
|
||||
mounted.push({ app, root })
|
||||
app.mount(root)
|
||||
await settle()
|
||||
return { root, model, isCompact, observer: observers[observers.length - 1]! }
|
||||
}
|
||||
|
||||
function cells(root: HTMLElement) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('.cell-emerge'))
|
||||
}
|
||||
|
||||
function size(root: HTMLElement) { return Number.parseFloat(cells(root)[0]!.style.width) }
|
||||
|
||||
describe('activity heatmap layout', () => {
|
||||
it.each([
|
||||
{ compact: true, length: 1, maximum: 18 },
|
||||
{ compact: false, length: 1, maximum: 16 },
|
||||
{ compact: true, length: 31, maximum: 18 },
|
||||
{ compact: false, length: 31, maximum: 16 },
|
||||
])('keeps $length days within $maximum px cells (compact: $compact)', async ({ compact, length, maximum }) => {
|
||||
const { root, observer } = await mount(heatmap('2026-10-05', length), compact)
|
||||
observer.resize(1040)
|
||||
await settle()
|
||||
expect(cells(root)).toHaveLength(length)
|
||||
for (const cell of cells(root)) {
|
||||
expect(Number.parseFloat(cell.style.width)).toBeLessThanOrEqual(maximum)
|
||||
expect(Number.parseFloat(cell.style.width)).toBeGreaterThanOrEqual(6)
|
||||
expect(cell.style.height).toBe(cell.style.width)
|
||||
}
|
||||
const firstWeek = cells(root)[0]!.parentElement!.parentElement!
|
||||
expect(firstWeek.children).toHaveLength(7)
|
||||
expect(firstWeek.children[0]!.querySelector('.cell-emerge')).toBeNull()
|
||||
expect(firstWeek.children[1]!.querySelector('.cell-emerge')).toBe(cells(root)[0])
|
||||
})
|
||||
|
||||
it('keeps a full year legible in a narrow container and resizes when its width or data changes', async () => {
|
||||
const { root, observer, model } = await mount(heatmap('2025-10-06', 365))
|
||||
observer.resize(280)
|
||||
await settle()
|
||||
expect(cells(root)).toHaveLength(365)
|
||||
expect(size(root)).toBe(6)
|
||||
observer.resize(640)
|
||||
await settle()
|
||||
expect(size(root)).toBeGreaterThan(6)
|
||||
expect(size(root)).toBeLessThanOrEqual(12)
|
||||
const yearCellSize = size(root)
|
||||
model.value = heatmap('2026-10-05', 1)
|
||||
await settle()
|
||||
expect(cells(root)).toHaveLength(1)
|
||||
expect(size(root)).toBeGreaterThan(yearCellSize)
|
||||
expect(size(root)).toBeLessThanOrEqual(12)
|
||||
model.value = heatmap('2025-10-06', 365)
|
||||
await settle()
|
||||
expect(size(root)).toBe(yearCellSize)
|
||||
observer.resize(1400)
|
||||
await settle()
|
||||
expect(size(root) * 53 + 52 * 2).toBeCloseTo(1400)
|
||||
expect(cells(root)[0]!.style.height).toBe(cells(root)[0]!.style.width)
|
||||
})
|
||||
|
||||
it('updates the cell size and spacing when compact mode changes', async () => {
|
||||
const { root, observer, isCompact } = await mount(heatmap('2026-10-05', 1), false)
|
||||
observer.resize(1040)
|
||||
await settle()
|
||||
expect(size(root)).toBeLessThanOrEqual(16)
|
||||
expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('4px')
|
||||
isCompact.value = true
|
||||
await settle()
|
||||
expect(size(root)).toBeLessThanOrEqual(18)
|
||||
expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('2px')
|
||||
})
|
||||
|
||||
it('preserves calendar gaps between activity dates without inventing token or cost data', async () => {
|
||||
const data = heatmap('2026-10-05', 4)
|
||||
data.days = [
|
||||
{ date: '2026-10-05', requests: 250, total_tokens: 1000, total_cost: 1.25 },
|
||||
{ date: '2026-10-08', requests: 650 },
|
||||
]
|
||||
data.max_requests = 650
|
||||
const { root } = await mount(data)
|
||||
const days = cells(root)
|
||||
expect(days).toHaveLength(4)
|
||||
expect(days[0]!.title).toContain('250')
|
||||
expect(days[0]!.title).toContain('$1.25')
|
||||
expect(days[3]!.title).toContain('650')
|
||||
for (const [index, date] of ['2026-10-06', '2026-10-07'].entries()) {
|
||||
const cell = days[index + 1]!
|
||||
expect(cell.title).toContain(new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`)))
|
||||
expect(cell.title.split(' · ')[1]).toMatch(/^0\D/)
|
||||
expect(cell.title).not.toContain('tokens')
|
||||
expect(cell.title).not.toContain('$')
|
||||
}
|
||||
const week = days[0]!.parentElement!.parentElement!
|
||||
expect(week.children[1]!.querySelector('.cell-emerge')).toBe(days[0])
|
||||
expect(week.children[4]!.querySelector('.cell-emerge')).toBe(days[3])
|
||||
})
|
||||
|
||||
it.each([false, true])('renders the full declared year with sparse or empty activity (empty: %s)', async empty => {
|
||||
const data = heatmap('2025-10-06', 365)
|
||||
data.days = empty ? [] : [{ date: '2026-05-17', requests: 250 }]
|
||||
const { root, observer } = await mount(data)
|
||||
observer.resize(1040)
|
||||
await settle()
|
||||
const days = cells(root)
|
||||
expect(days).toHaveLength(365)
|
||||
expect(days[0]!.title).toContain('2025年10月6日')
|
||||
expect(days[364]!.title).toContain('2026年10月5日')
|
||||
expect(days[0]!.title.split(' · ')[1]).toMatch(/^0\D/)
|
||||
expect(days[364]!.title.split(' · ')[1]).toMatch(/^0\D/)
|
||||
expect(days.filter(day => day.title.split(' · ')[1]?.startsWith('250'))).toHaveLength(empty ? 0 : 1)
|
||||
expect(size(root) * 53 + 52 * 2).toBeCloseTo(1040)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user