+
{{ loadErrorText }}
{{ t('common.back') }}
diff --git a/apps/web-wasm/src/pages/session-detail.test.ts b/apps/web-wasm/src/pages/session-detail.test.ts
deleted file mode 100644
index 920862d0..00000000
--- a/apps/web-wasm/src/pages/session-detail.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import assert from 'node:assert/strict'
-import test from 'node:test'
-import { buildHourlyChartData, buildMemberRankItems, buildMessageTypeChartData } from './session-detail'
-
-test('builds a complete 24-hour chart and fills missing hours with zero', () => {
- const result = buildHourlyChartData([
- { hour: 8, messageCount: 2 },
- { hour: 23, messageCount: 1 },
- ])
-
- assert.equal(result.labels.length, 24)
- assert.equal(result.values.length, 24)
- assert.equal(result.labels[0], '0')
- assert.equal(result.labels[23], '23')
- assert.equal(result.values[8], 2)
- assert.equal(result.values[23], 1)
- assert.equal(result.values[7], 0)
-})
-
-test('maps member activity into the shared ranking component contract', () => {
- assert.deepEqual(
- buildMemberRankItems([
- {
- memberId: 7,
- platformId: 'alice',
- name: 'Alice',
- avatar: null,
- messageCount: 12,
- percentage: 75,
- },
- ]),
- [{ id: '7', name: 'Alice', value: 12, percentage: 75 }]
- )
-})
-
-test('maps message type stats into the shared pie chart contract in descending order', () => {
- const source = [
- { type: 0, count: 1 },
- { type: 1, count: 3 },
- ]
-
- assert.deepEqual(
- buildMessageTypeChartData(source, (type) => `type-${type}`),
- {
- labels: ['type-1', 'type-0'],
- values: [3, 1],
- }
- )
- assert.deepEqual(source, [
- { type: 0, count: 1 },
- { type: 1, count: 3 },
- ])
-})
diff --git a/apps/web-wasm/src/pages/session-detail.ts b/apps/web-wasm/src/pages/session-detail.ts
deleted file mode 100644
index 66557ea5..00000000
--- a/apps/web-wasm/src/pages/session-detail.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { HourlyActivity, MemberActivity } from '@/types/analysis'
-import type { MessageTypeStats } from '@openchatlab/core'
-import type { EChartBarData, EChartPieData, RankItem } from '@/components/charts'
-
-export function buildHourlyChartData(hourlyActivity: readonly HourlyActivity[]): EChartBarData {
- const hourMap = new Map(hourlyActivity.map((item) => [item.hour, item.messageCount]))
-
- return {
- labels: Array.from({ length: 24 }, (_, hour) => String(hour)),
- values: Array.from({ length: 24 }, (_, hour) => hourMap.get(hour) ?? 0),
- }
-}
-
-export function buildMemberRankItems(memberActivity: readonly MemberActivity[]): RankItem[] {
- return memberActivity.map((member) => ({
- id: String(member.memberId),
- name: member.name,
- value: member.messageCount,
- percentage: member.percentage,
- }))
-}
-
-export function buildMessageTypeChartData(
- messageTypes: readonly MessageTypeStats[],
- getLabel: (type: number) => string
-): EChartPieData {
- const sorted = [...messageTypes].sort((left, right) => right.count - left.count)
- return {
- labels: sorted.map((item) => getLabel(item.type)),
- values: sorted.map((item) => item.count),
- }
-}
diff --git a/packages/web-runtime/src/import/session-runtime.test.ts b/packages/web-runtime/src/import/session-runtime.test.ts
index 95065e8d..f19624ff 100644
--- a/packages/web-runtime/src/import/session-runtime.test.ts
+++ b/packages/web-runtime/src/import/session-runtime.test.ts
@@ -787,4 +787,50 @@ describe('BrowserSessionRuntime', () => {
assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery)
database.dispose()
})
+
+ it('runs the core overview timeline queries with filtering and without creating missing databases', async () => {
+ const sqlite3 = await sqlite3InitModule()
+ const database = new MemoryWorkspaceDatabase(sqlite3)
+ const runtime = new BrowserSessionRuntime(database, {
+ createSessionId: () => 'overview-query-session',
+ now: () => 100,
+ })
+ const firstTimestamp = Math.floor(new Date(2024, 0, 2, 10, 0, 0).getTime() / 1000)
+ const secondTimestamp = Math.floor(new Date(2024, 0, 3, 10, 0, 0).getTime() / 1000)
+ const fixture = {
+ chatlab: { version: '1', exportedAt: firstTimestamp },
+ meta: { name: 'Overview Query', platform: 'wechat', type: 'private', ownerId: 'alice' },
+ members: [{ platformId: 'alice', accountName: 'Alice' }],
+ messages: [
+ { sender: 'alice', accountName: 'Alice', timestamp: firstTimestamp, type: 0, content: 'one' },
+ { sender: 'alice', accountName: 'Alice', timestamp: firstTimestamp + 1, type: 0, content: 'two' },
+ { sender: 'alice', accountName: 'Alice', timestamp: secondTimestamp, type: 0, content: 'three' },
+ ],
+ }
+
+ await runtime.importSource(source('overview-query.json', fixture), { formatId: 'chatlab' })
+
+ assert.deepEqual(await runtime.getDailyActivity('overview-query-session'), [
+ { date: '2024-01-02', messageCount: 2 },
+ { date: '2024-01-03', messageCount: 1 },
+ ])
+ assert.deepEqual(await runtime.getDailyActivity('overview-query-session', { startTs: secondTimestamp }), [
+ { date: '2024-01-03', messageCount: 1 },
+ ])
+ assert.equal((await runtime.getWeekdayActivity('overview-query-session'))[1].messageCount, 2)
+ assert.equal((await runtime.getWeekdayActivity('overview-query-session'))[2].messageCount, 1)
+ assert.deepEqual(await runtime.getTimeRange('overview-query-session'), {
+ start: firstTimestamp,
+ end: secondTimestamp,
+ })
+ assert.deepEqual(await runtime.getAvailableYears('overview-query-session'), [2024])
+
+ const filenamesBeforeMissingQuery = await database.getDatabaseFilenames()
+ await assert.rejects(runtime.getDailyActivity('missing-session'), /Session missing-session was not found/)
+ await assert.rejects(runtime.getWeekdayActivity('missing-session'), /Session missing-session was not found/)
+ await assert.rejects(runtime.getTimeRange('missing-session'), /Session missing-session was not found/)
+ await assert.rejects(runtime.getAvailableYears('missing-session'), /Session missing-session was not found/)
+ assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery)
+ database.dispose()
+ })
})
diff --git a/packages/web-runtime/src/import/session-runtime.ts b/packages/web-runtime/src/import/session-runtime.ts
index 364a3af5..9160151a 100644
--- a/packages/web-runtime/src/import/session-runtime.ts
+++ b/packages/web-runtime/src/import/session-runtime.ts
@@ -1,13 +1,19 @@
import {
CHAT_DB_INDEXES,
CHAT_DB_TABLES,
+ getAvailableYears as queryAvailableYears,
+ getDailyActivity as queryDailyActivity,
getHourlyActivity as queryHourlyActivity,
getMemberActivity as queryMemberActivity,
getMessageTypeStats as queryMessageTypeStats,
+ getTimeRange as queryTimeRange,
+ getWeekdayActivity as queryWeekdayActivity,
writeParseResultToDb,
+ type DailyActivity,
type HourlyActivity,
type MemberActivity,
type MessageTypeStats,
+ type WeekdayActivity,
} from '@openchatlab/core'
import { WebRuntimeError } from '../runtime-error'
import type { WorkspaceDatabasePort, WorkspaceDatabaseStage } from '../storage/workspace-database'
@@ -248,6 +254,42 @@ export class BrowserSessionRuntime {
)
}
+ async getDailyActivity(id: string, filter?: BrowserTimeFilter): Promise
{
+ validateSessionId(id)
+ const session = await this.catalog.get(id)
+ if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
+
+ return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) =>
+ queryDailyActivity(db, filter)
+ )
+ }
+
+ async getWeekdayActivity(id: string, filter?: BrowserTimeFilter): Promise {
+ validateSessionId(id)
+ const session = await this.catalog.get(id)
+ if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
+
+ return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) =>
+ queryWeekdayActivity(db, filter)
+ )
+ }
+
+ async getTimeRange(id: string): Promise<{ start: number; end: number } | null> {
+ validateSessionId(id)
+ const session = await this.catalog.get(id)
+ if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
+
+ return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) => queryTimeRange(db))
+ }
+
+ async getAvailableYears(id: string): Promise {
+ validateSessionId(id)
+ const session = await this.catalog.get(id)
+ if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
+
+ return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) => queryAvailableYears(db))
+ }
+
async getMemberActivity(id: string, filter?: BrowserTimeFilter): Promise {
validateSessionId(id)
const session = await this.catalog.get(id)
diff --git a/packages/web-runtime/src/rpc/protocol.ts b/packages/web-runtime/src/rpc/protocol.ts
index 6c4e23bd..969f4131 100644
--- a/packages/web-runtime/src/rpc/protocol.ts
+++ b/packages/web-runtime/src/rpc/protocol.ts
@@ -1,6 +1,12 @@
import type { BrowserImportFormatId, BrowserParseSource } from '../import/browser-parser'
import type { BrowserSessionCatalogItem } from '../import/session-catalog'
-import type { HourlyActivity, MemberActivity, MessageTypeStats } from '@openchatlab/core'
+import type {
+ DailyActivity,
+ HourlyActivity,
+ MemberActivity,
+ MessageTypeStats,
+ WeekdayActivity,
+} from '@openchatlab/core'
import type {
BrowserImportFormatInfo,
BrowserMultiChatEntry,
@@ -77,6 +83,22 @@ export interface WebRuntimeTaskMap {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: HourlyActivity[]
}
+ 'analysis.daily': {
+ payload: { sessionId: string; filter?: BrowserTimeFilter }
+ result: DailyActivity[]
+ }
+ 'analysis.weekday': {
+ payload: { sessionId: string; filter?: BrowserTimeFilter }
+ result: WeekdayActivity[]
+ }
+ 'analysis.timeRange': {
+ payload: { sessionId: string }
+ result: { start: number; end: number } | null
+ }
+ 'analysis.availableYears': {
+ payload: { sessionId: string }
+ result: number[]
+ }
'analysis.members': {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: MemberActivity[]
@@ -181,6 +203,10 @@ const WEB_RUNTIME_TASK_TYPES: Record = {
'session.delete': true,
'session.rename': true,
'analysis.hourly': true,
+ 'analysis.daily': true,
+ 'analysis.weekday': true,
+ 'analysis.timeRange': true,
+ 'analysis.availableYears': true,
'analysis.members': true,
'analysis.messageTypes': true,
}
diff --git a/packages/web-runtime/src/worker/controller.test.ts b/packages/web-runtime/src/worker/controller.test.ts
index 63a1babc..5791ab83 100644
--- a/packages/web-runtime/src/worker/controller.test.ts
+++ b/packages/web-runtime/src/worker/controller.test.ts
@@ -71,8 +71,12 @@ class FakeDatabaseRuntime implements WorkerDatabaseRuntime {
class FakeSessionRuntime implements WorkerSessionRuntime {
readonly hourlyCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
+ readonly dailyCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
+ readonly weekdayCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
readonly memberCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
readonly messageTypeCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
+ readonly timeRangeCalls: string[] = []
+ readonly availableYearsCalls: string[] = []
readonly importCalls: Array<{ chatIndex?: number }> = []
readonly session = {
id: 'session-one',
@@ -138,6 +142,28 @@ class FakeSessionRuntime implements WorkerSessionRuntime {
: []
}
+ async getDailyActivity(id: string, filter?: BrowserTimeFilter) {
+ this.dailyCalls.push({ id, filter })
+ return id === this.session.id ? [{ date: '2024-01-02', messageCount: 2 }] : []
+ }
+
+ async getWeekdayActivity(id: string, filter?: BrowserTimeFilter) {
+ this.weekdayCalls.push({ id, filter })
+ return id === this.session.id
+ ? Array.from({ length: 7 }, (_, index) => ({ weekday: index + 1, messageCount: index === 1 ? 2 : 0 }))
+ : []
+ }
+
+ async getTimeRange(id: string) {
+ this.timeRangeCalls.push(id)
+ return id === this.session.id ? { start: 1, end: 2 } : null
+ }
+
+ async getAvailableYears(id: string) {
+ this.availableYearsCalls.push(id)
+ return id === this.session.id ? [2024] : []
+ }
+
async getMemberActivity(id: string, filter?: BrowserTimeFilter) {
this.memberCalls.push({ id, filter })
return id === this.session.id
@@ -302,6 +328,26 @@ describe('WebRuntimeWorkerController', () => {
type: 'analysis.hourly',
payload: { sessionId: 'session-one', filter: { startTs: 1 } },
})
+ controller.handleMessage({
+ id: 'daily-1',
+ type: 'analysis.daily',
+ payload: { sessionId: 'session-one', filter: { startTs: 1 } },
+ })
+ controller.handleMessage({
+ id: 'weekday-1',
+ type: 'analysis.weekday',
+ payload: { sessionId: 'session-one', filter: { endTs: 2 } },
+ })
+ controller.handleMessage({
+ id: 'time-range-1',
+ type: 'analysis.timeRange',
+ payload: { sessionId: 'session-one' },
+ })
+ controller.handleMessage({
+ id: 'available-years-1',
+ type: 'analysis.availableYears',
+ payload: { sessionId: 'session-one' },
+ })
controller.handleMessage({
id: 'members-1',
type: 'analysis.members',
@@ -323,6 +369,10 @@ describe('WebRuntimeWorkerController', () => {
const imported = await waitForMessage(sink, 'import-1', 'result')
const listed = await waitForMessage(sink, 'list-1', 'result')
const hourly = await waitForMessage(sink, 'hourly-1', 'result')
+ const daily = await waitForMessage(sink, 'daily-1', 'result')
+ const weekday = await waitForMessage(sink, 'weekday-1', 'result')
+ const timeRange = await waitForMessage(sink, 'time-range-1', 'result')
+ const availableYears = await waitForMessage(sink, 'available-years-1', 'result')
const members = await waitForMessage(sink, 'members-1', 'result')
const messageTypes = await waitForMessage(sink, 'message-types-1', 'result')
const renamed = await waitForMessage(sink, 'rename-1', 'result')
@@ -344,6 +394,17 @@ describe('WebRuntimeWorkerController', () => {
Array.from({ length: 24 }, (_, hour) => ({ hour, messageCount: hour === 8 ? 2 : 0 }))
)
assert.deepEqual(sessions.hourlyCalls[0], { id: 'session-one', filter: { startTs: 1 } })
+ assert.deepEqual(daily.type === 'result' ? daily.payload.result : null, [{ date: '2024-01-02', messageCount: 2 }])
+ assert.deepEqual(sessions.dailyCalls[0], { id: 'session-one', filter: { startTs: 1 } })
+ assert.deepEqual(
+ weekday.type === 'result' ? weekday.payload.result : null,
+ Array.from({ length: 7 }, (_, index) => ({ weekday: index + 1, messageCount: index === 1 ? 2 : 0 }))
+ )
+ assert.deepEqual(sessions.weekdayCalls[0], { id: 'session-one', filter: { endTs: 2 } })
+ assert.deepEqual(timeRange.type === 'result' ? timeRange.payload.result : null, { start: 1, end: 2 })
+ assert.deepEqual(sessions.timeRangeCalls, ['session-one'])
+ assert.deepEqual(availableYears.type === 'result' ? availableYears.payload.result : null, [2024])
+ assert.deepEqual(sessions.availableYearsCalls, ['session-one'])
assert.deepEqual(members.type === 'result' ? members.payload.result : null, [
{
memberId: 1,
diff --git a/packages/web-runtime/src/worker/controller.ts b/packages/web-runtime/src/worker/controller.ts
index 4f61f59b..95dca4f6 100644
--- a/packages/web-runtime/src/worker/controller.ts
+++ b/packages/web-runtime/src/worker/controller.ts
@@ -35,6 +35,10 @@ export type WorkerSessionRuntime = Pick<
| 'deleteSession'
| 'renameSession'
| 'getHourlyActivity'
+ | 'getDailyActivity'
+ | 'getWeekdayActivity'
+ | 'getTimeRange'
+ | 'getAvailableYears'
| 'getMemberActivity'
| 'getMessageTypeDistribution'
>
@@ -211,6 +215,54 @@ export class WebRuntimeWorkerController {
})
return result
}
+ case 'analysis.daily': {
+ this.assertSupportedBrowser()
+ const startedAt = performance.now()
+ const result = await this.sessionRuntime.getDailyActivity(request.payload.sessionId, request.payload.filter)
+ this.emitLog(request.id, {
+ level: 'debug',
+ scope: 'web-runtime',
+ message: 'Browser daily activity query completed',
+ data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
+ })
+ return result
+ }
+ case 'analysis.weekday': {
+ this.assertSupportedBrowser()
+ const startedAt = performance.now()
+ const result = await this.sessionRuntime.getWeekdayActivity(request.payload.sessionId, request.payload.filter)
+ this.emitLog(request.id, {
+ level: 'debug',
+ scope: 'web-runtime',
+ message: 'Browser weekday activity query completed',
+ data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
+ })
+ return result
+ }
+ case 'analysis.timeRange': {
+ this.assertSupportedBrowser()
+ const startedAt = performance.now()
+ const result = await this.sessionRuntime.getTimeRange(request.payload.sessionId)
+ this.emitLog(request.id, {
+ level: 'debug',
+ scope: 'web-runtime',
+ message: 'Browser time range query completed',
+ data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
+ })
+ return result
+ }
+ case 'analysis.availableYears': {
+ this.assertSupportedBrowser()
+ const startedAt = performance.now()
+ const result = await this.sessionRuntime.getAvailableYears(request.payload.sessionId)
+ this.emitLog(request.id, {
+ level: 'debug',
+ scope: 'web-runtime',
+ message: 'Browser available years query completed',
+ data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
+ })
+ return result
+ }
case 'analysis.members': {
this.assertSupportedBrowser()
const startedAt = performance.now()
diff --git a/src/components/analysis/Overview/DailyTrendCard.vue b/src/components/analysis/Overview/DailyTrendCard.vue
index 3909185c..439bd187 100644
--- a/src/components/analysis/Overview/DailyTrendCard.vue
+++ b/src/components/analysis/Overview/DailyTrendCard.vue
@@ -15,7 +15,7 @@ defineProps<{
-
+
diff --git a/src/components/analysis/Overview/OverviewIdentityCard.vue b/src/components/analysis/Overview/OverviewIdentityCard.vue
index b3ccecbf..ba86d55a 100644
--- a/src/components/analysis/Overview/OverviewIdentityCard.vue
+++ b/src/components/analysis/Overview/OverviewIdentityCard.vue
@@ -274,12 +274,12 @@ onUnmounted(() => {
-
-
+
+
{{ session.name }}
-
+
@@ -305,7 +305,7 @@ onUnmounted(() => {
-
+
{{ displayMessageCount.toLocaleString() }}
@@ -346,7 +346,7 @@ onUnmounted(() => {
-
+
Activity Heatmap
diff --git a/src/components/analysis/Overview/OverviewStatCards.vue b/src/components/analysis/Overview/OverviewStatCards.vue
index bd08995e..d780986e 100644
--- a/src/components/analysis/Overview/OverviewStatCards.vue
+++ b/src/components/analysis/Overview/OverviewStatCards.vue
@@ -99,7 +99,7 @@ const flatItems = computed(() => [
-
+
Key Metrics
diff --git a/src/components/layout/session/SessionAnalysisHeader.vue b/src/components/layout/session/SessionAnalysisHeader.vue
index 5035c045..9439f60d 100644
--- a/src/components/layout/session/SessionAnalysisHeader.vue
+++ b/src/components/layout/session/SessionAnalysisHeader.vue
@@ -17,15 +17,21 @@ interface SessionAnalysisTab {
const activeTab = defineModel
('activeTab', { required: true })
const timeRangeValue = defineModel('timeRangeValue', { required: true })
-const props = defineProps<{
- title: string
- avatar?: string | null
- icon: string
- iconClass: string
- tabs: SessionAnalysisTab[]
- currentSessionId: string | null
- initialTimeState: Partial
-}>()
+const props = withDefaults(
+ defineProps<{
+ title: string
+ avatar?: string | null
+ icon: string
+ iconClass: string
+ tabs: SessionAnalysisTab[]
+ currentSessionId: string | null
+ initialTimeState: Partial
+ showSessionActions?: boolean
+ }>(),
+ {
+ showSessionActions: true,
+ }
+)
const emit = defineEmits<{
(e: 'openIncrementalImport'): void
@@ -51,7 +57,7 @@ const navigationItems = computed(() =>
-
+
/>
-
+
-
+
diff --git a/src/composables/analysis/overviewTimeRange.test.ts b/src/composables/analysis/overviewTimeRange.test.ts
index 9cf95193..f5eff985 100644
--- a/src/composables/analysis/overviewTimeRange.test.ts
+++ b/src/composables/analysis/overviewTimeRange.test.ts
@@ -25,6 +25,13 @@ describe('overviewTimeRange', () => {
assert.equal(getOverviewDurationDays({ start: 1000, end: 1000 }), 1)
})
+ it('never reports fewer duration days than observed active calendar days', () => {
+ const start = Date.UTC(2026, 6, 18, 16, 0, 0) / 1000
+ const end = Date.UTC(2026, 6, 19, 16, 0, 0) / 1000
+
+ assert.equal(getOverviewDurationDays({ start, end }, 2), 2)
+ })
+
it('returns 0 for empty or invalid ranges', () => {
assert.equal(getOverviewDurationDays(null), 0)
assert.equal(getOverviewDurationDays({ start: 2, end: 1 }), 0)
diff --git a/src/composables/analysis/overviewTimeRange.ts b/src/composables/analysis/overviewTimeRange.ts
index 57274c53..361e4b0f 100644
--- a/src/composables/analysis/overviewTimeRange.ts
+++ b/src/composables/analysis/overviewTimeRange.ts
@@ -34,9 +34,9 @@ export function resolveOverviewTimeRange(
return { start, end }
}
-export function getOverviewDurationDays(timeRange: OverviewTimeRange | null): number {
+export function getOverviewDurationDays(timeRange: OverviewTimeRange | null, minimumObservedDays = 0): number {
if (!timeRange || timeRange.end < timeRange.start) return 0
- return Math.max(Math.ceil((timeRange.end - timeRange.start) / SECONDS_PER_DAY), 1)
+ return Math.max(Math.ceil((timeRange.end - timeRange.start) / SECONDS_PER_DAY), minimumObservedDays, 1)
}
export function getOverviewCalendarRange(timeRange: OverviewTimeRange | null): [string, string] | null {
diff --git a/src/composables/analysis/useOverviewStatistics.ts b/src/composables/analysis/useOverviewStatistics.ts
index de40c6c6..20c9f327 100644
--- a/src/composables/analysis/useOverviewStatistics.ts
+++ b/src/composables/analysis/useOverviewStatistics.ts
@@ -28,8 +28,13 @@ export function useOverviewStatistics(props: UseOverviewStatisticsProps, weekday
const effectiveTimeRange = computed(() => resolveOverviewTimeRange(props.timeRange, props.timeFilter))
const hasTimeFilter = computed(() => hasOverviewTimeFilter(props.timeFilter))
+ // 活跃天数
+ const activeDays = computed(() => {
+ return props.dailyActivity.filter((d) => d.messageCount > 0).length
+ })
+
// 时间跨度
- const durationDays = computed(() => getOverviewDurationDays(effectiveTimeRange.value))
+ const durationDays = computed(() => getOverviewDurationDays(effectiveTimeRange.value, activeDays.value))
// 显示的消息数
const displayMessageCount = computed(() => {
@@ -115,11 +120,6 @@ export function useOverviewStatistics(props: UseOverviewStatisticsProps, weekday
return props.dailyActivity.reduce((max, d) => (d.messageCount > max.messageCount ? d : max), props.dailyActivity[0])
})
- // 活跃天数
- const activeDays = computed(() => {
- return props.dailyActivity.filter((d) => d.messageCount > 0).length
- })
-
// 总天数(用于计算活跃率)
const totalDays = computed(() => {
return durationDays.value
diff --git a/src/pages/group-chat/components/OverviewTab.vue b/src/pages/group-chat/components/OverviewTab.vue
index 680725cb..c6a8871b 100644
--- a/src/pages/group-chat/components/OverviewTab.vue
+++ b/src/pages/group-chat/components/OverviewTab.vue
@@ -59,7 +59,7 @@ const memberChartData = computed
(() => {
-
+
(() => {
-
+
-
+
diff --git a/src/pages/private-chat/components/OverviewTab.vue b/src/pages/private-chat/components/OverviewTab.vue
index b6931b96..016f937c 100644
--- a/src/pages/private-chat/components/OverviewTab.vue
+++ b/src/pages/private-chat/components/OverviewTab.vue
@@ -73,7 +73,7 @@ const comparisonChartData = computed
(() => {
-
+
(() => {
-
+
-
+
diff --git a/src/services/data/browser.test.ts b/src/services/data/browser.test.ts
index a38c424f..1c6c6845 100644
--- a/src/services/data/browser.test.ts
+++ b/src/services/data/browser.test.ts
@@ -39,25 +39,36 @@ describe('BrowserDataAdapter', () => {
? item
: type === 'analysis.hourly'
? Array.from({ length: 24 }, (_, hour) => ({ hour, messageCount: hour === 8 ? 2 : 0 }))
- : type === 'analysis.members'
- ? [
- {
- memberId: 1,
- platformId: 'alice',
- name: 'Alice',
- avatar: null,
- messageCount: 2,
- percentage: 100,
- },
- ]
- : type === 'analysis.messageTypes'
- ? [
- { type: 1, count: 2 },
- { type: 0, count: 1 },
- ]
- : type === 'session.delete'
- ? { deleted: true }
- : { renamed: true }
+ : type === 'analysis.daily'
+ ? [{ date: '2024-01-02', messageCount: 2 }]
+ : type === 'analysis.weekday'
+ ? Array.from({ length: 7 }, (_, index) => ({
+ weekday: index + 1,
+ messageCount: index === 1 ? 2 : 0,
+ }))
+ : type === 'analysis.timeRange'
+ ? { start: 1, end: 2 }
+ : type === 'analysis.availableYears'
+ ? [2024]
+ : type === 'analysis.members'
+ ? [
+ {
+ memberId: 1,
+ platformId: 'alice',
+ name: 'Alice',
+ avatar: null,
+ messageCount: 2,
+ percentage: 100,
+ },
+ ]
+ : type === 'analysis.messageTypes'
+ ? [
+ { type: 1, count: 2 },
+ { type: 0, count: 1 },
+ ]
+ : type === 'session.delete'
+ ? { deleted: true }
+ : { renamed: true }
return result as WebRuntimeTaskResult
},
dispose: () => undefined,
@@ -86,6 +97,12 @@ describe('BrowserDataAdapter', () => {
})
assert.equal((await adapter.getSession('session-one'))?.id, 'session-one')
assert.equal((await adapter.getHourlyActivity('session-one', { startTs: 1 }))[8].messageCount, 2)
+ assert.deepEqual(await adapter.getDailyActivity('session-one', { startTs: 1 }), [
+ { date: '2024-01-02', messageCount: 2 },
+ ])
+ assert.equal((await adapter.getWeekdayActivity('session-one', { endTs: 2 }))[1].messageCount, 2)
+ assert.deepEqual(await adapter.getTimeRange('session-one'), { start: 1, end: 2 })
+ assert.deepEqual(await adapter.getAvailableYears('session-one'), [2024])
assert.equal((await adapter.getMemberActivity('session-one', { endTs: 2 }))[0].name, 'Alice')
assert.deepEqual(await adapter.getMessageTypeDistribution('session-one', { startTs: 1, endTs: 2 }), [
{ type: 1, count: 2 },
@@ -97,6 +114,10 @@ describe('BrowserDataAdapter', () => {
{ type: 'session.list', payload: undefined },
{ type: 'session.get', payload: { sessionId: 'session-one' } },
{ type: 'analysis.hourly', payload: { sessionId: 'session-one', filter: { startTs: 1 } } },
+ { type: 'analysis.daily', payload: { sessionId: 'session-one', filter: { startTs: 1 } } },
+ { type: 'analysis.weekday', payload: { sessionId: 'session-one', filter: { endTs: 2 } } },
+ { type: 'analysis.timeRange', payload: { sessionId: 'session-one' } },
+ { type: 'analysis.availableYears', payload: { sessionId: 'session-one' } },
{ type: 'analysis.members', payload: { sessionId: 'session-one', filter: { endTs: 2 } } },
{
type: 'analysis.messageTypes',
diff --git a/src/services/data/browser.ts b/src/services/data/browser.ts
index ad4556e1..dbe681c9 100644
--- a/src/services/data/browser.ts
+++ b/src/services/data/browser.ts
@@ -1,6 +1,6 @@
import { sessionDatabaseFilename, type BrowserSessionCatalogItem } from '@openchatlab/web-runtime'
import type { AnalysisSession, MessageType } from '@/types/base'
-import type { HourlyActivity, MemberActivity } from '@/types/analysis'
+import type { DailyActivity, HourlyActivity, MemberActivity, WeekdayActivity } from '@/types/analysis'
import type { TimeFilter } from '@openchatlab/shared-types'
import type { BrowserRuntimeRpcPort } from '../browser-runtime/types'
import type { DataAdapter } from './types'
@@ -12,6 +12,10 @@ type BrowserSessionDataAdapter = Pick<
| 'deleteSession'
| 'renameSession'
| 'getHourlyActivity'
+ | 'getDailyActivity'
+ | 'getWeekdayActivity'
+ | 'getTimeRange'
+ | 'getAvailableYears'
| 'getMemberActivity'
| 'getMessageTypeDistribution'
>
@@ -40,6 +44,22 @@ export class BrowserDataAdapter implements BrowserSessionDataAdapter {
return this.rpc.request('analysis.hourly', { sessionId, filter })
}
+ getDailyActivity(sessionId: string, filter?: TimeFilter): Promise {
+ return this.rpc.request('analysis.daily', { sessionId, filter })
+ }
+
+ getWeekdayActivity(sessionId: string, filter?: TimeFilter): Promise {
+ return this.rpc.request('analysis.weekday', { sessionId, filter })
+ }
+
+ getTimeRange(sessionId: string): Promise<{ start: number; end: number } | null> {
+ return this.rpc.request('analysis.timeRange', { sessionId })
+ }
+
+ getAvailableYears(sessionId: string): Promise {
+ return this.rpc.request('analysis.availableYears', { sessionId })
+ }
+
getMemberActivity(sessionId: string, filter?: TimeFilter): Promise {
return this.rpc.request('analysis.members', { sessionId, filter })
}