feat(insight): add annual activity rhythm metrics

This commit is contained in:
digua committed 2026-07-23 16:50:05 +08:00
1 parent 66311a2412
commit 755a0b5a76
7 files changed
+228 -24

No files matched your search

+6
View File
@@ -31,6 +31,12 @@
"dailyContacts": "Contacts per day",
"peakMonth": "Most active month",
"peakDay": "Most active day",
"longestActiveStreak": "Longest active streak",
"consecutiveActiveDays": "Consecutive days with sent messages",
"topWeekday": "Most active weekday",
"mostMessagesSent": "Most messages sent",
"weekendMessageRate": "Weekend message share",
"weekdayMessageRate": "Weekdays {rate}%",
"textRatio": "Text message share",
"medianLength": "Median text length",
"p90Length": "P90 text length"
+6
View File
@@ -31,6 +31,12 @@
"dailyContacts": "1日平均交流人数",
"peakMonth": "最も活発な月",
"peakDay": "最も活発な日",
"longestActiveStreak": "最長連続アクティブ",
"consecutiveActiveDays": "連続してメッセージを送信した日数",
"topWeekday": "最も活発な曜日",
"mostMessagesSent": "送信数が最多",
"weekendMessageRate": "週末メッセージ比率",
"weekdayMessageRate": "平日 {rate}%",
"textRatio": "テキストメッセージ比率",
"medianLength": "テキスト長の中央値",
"p90Length": "テキスト長 P90"
+6
View File
@@ -31,6 +31,12 @@
"dailyContacts": "日均互动人数",
"peakMonth": "最活跃月份",
"peakDay": "最活跃日期",
"longestActiveStreak": "最长连续活跃",
"consecutiveActiveDays": "连续发出消息的天数",
"topWeekday": "最常活跃星期",
"mostMessagesSent": "发出消息最多",
"weekendMessageRate": "周末消息占比",
"weekdayMessageRate": "工作日 {rate}%",
"textRatio": "文字消息占比",
"medianLength": "文本长度中位数",
"p90Length": "文本长度 P90"
+6
View File
@@ -31,6 +31,12 @@
"dailyContacts": "日均互動人數",
"peakMonth": "最活躍月份",
"peakDay": "最活躍日期",
"longestActiveStreak": "最長連續活躍",
"consecutiveActiveDays": "連續發出訊息的天數",
"topWeekday": "最常活躍星期",
"mostMessagesSent": "發出訊息最多",
"weekendMessageRate": "週末訊息佔比",
"weekdayMessageRate": "工作日 {rate}%",
"textRatio": "文字訊息占比",
"medianLength": "文字長度中位數",
"p90Length": "文字長度 P90"
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { deriveAnnualActivityRhythm } from './annual-activity-rhythm'
test('returns empty activity rhythm when there are no sent messages', () => {
assert.deepEqual(deriveAnnualActivityRhythm([]), {
longestActiveStreak: 0,
topWeekday: null,
weekdayMessageRate: null,
weekendMessageRate: null,
})
})
test('derives a single active day and complementary weekday rates', () => {
assert.deepEqual(deriveAnnualActivityRhythm([{ date: '2026-07-20', messageCount: 3 }]), {
longestActiveStreak: 1,
topWeekday: 1,
weekdayMessageRate: 100,
weekendMessageRate: 0,
})
})
test('finds the longest streak across month and year boundaries regardless of input order', () => {
const result = deriveAnnualActivityRhythm([
{ date: '2026-01-04', messageCount: 1 },
{ date: '2025-12-31', messageCount: 2 },
{ date: '2026-01-02', messageCount: 1 },
{ date: '2026-01-01', messageCount: 1 },
{ date: '2026-01-03', messageCount: 1 },
{ date: '2025-12-28', messageCount: 4 },
])
assert.equal(result.longestActiveStreak, 5)
})
test('treats leap day as a consecutive calendar day', () => {
const result = deriveAnnualActivityRhythm([
{ date: '2024-02-28', messageCount: 1 },
{ date: '2024-02-29', messageCount: 1 },
{ date: '2024-03-01', messageCount: 1 },
])
assert.equal(result.longestActiveStreak, 3)
})
test('uses Monday-first order to break top-weekday ties', () => {
const result = deriveAnnualActivityRhythm([
{ date: '2026-07-21', messageCount: 5 },
{ date: '2026-07-20', messageCount: 5 },
{ date: '2026-07-22', messageCount: 1 },
])
assert.equal(result.topWeekday, 1)
})
test('computes weekday and weekend shares that always add up to 100', () => {
const result = deriveAnnualActivityRhythm([
{ date: '2026-07-20', messageCount: 1 },
{ date: '2026-07-25', messageCount: 2 },
])
assert.equal(result.weekdayMessageRate, 33)
assert.equal(result.weekendMessageRate, 67)
assert.equal(result.weekdayMessageRate! + result.weekendMessageRate!, 100)
})
@@ -0,0 +1,78 @@
export interface AnnualActivityRhythm {
longestActiveStreak: number
topWeekday: number | null
weekdayMessageRate: number | null
weekendMessageRate: number | null
}
interface DailyActivity {
date: string
messageCount: number
}
const DAY_MS = 24 * 60 * 60 * 1000
export function deriveAnnualActivityRhythm(dailyActivity: DailyActivity[]): AnnualActivityRhythm {
const messagesByDay = new Map<number, number>()
for (const item of dailyActivity) {
if (item.messageCount <= 0) continue
const day = parseLocalDateKey(item.date)
if (day === null) continue
messagesByDay.set(day, (messagesByDay.get(day) ?? 0) + item.messageCount)
}
const activeDays = [...messagesByDay.keys()].sort((a, b) => a - b)
if (activeDays.length === 0) {
return {
longestActiveStreak: 0,
topWeekday: null,
weekdayMessageRate: null,
weekendMessageRate: null,
}
}
const messagesByWeekday = Array.from({ length: 7 }, () => 0)
let longestActiveStreak = 1
let currentStreak = 1
activeDays.forEach((day, index) => {
const mondayBasedWeekday = (new Date(day).getUTCDay() + 6) % 7
messagesByWeekday[mondayBasedWeekday]! += messagesByDay.get(day) ?? 0
if (index === 0) return
if (day - activeDays[index - 1]! === DAY_MS) {
currentStreak++
longestActiveStreak = Math.max(longestActiveStreak, currentStreak)
} else {
currentStreak = 1
}
})
const totalMessages = messagesByWeekday.reduce((sum, count) => sum + count, 0)
const weekendMessages = messagesByWeekday[5]! + messagesByWeekday[6]!
const weekendMessageRate = Math.round((weekendMessages / totalMessages) * 100)
const topWeekdayIndex = messagesByWeekday.reduce(
(peakIndex, count, index) => (count > messagesByWeekday[peakIndex]! ? index : peakIndex),
0
)
return {
longestActiveStreak,
topWeekday: topWeekdayIndex + 1,
weekdayMessageRate: 100 - weekendMessageRate,
weekendMessageRate,
}
}
function parseLocalDateKey(value: string): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const timestamp = Date.UTC(year, month - 1, day)
const parsed = new Date(timestamp)
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) return null
return timestamp
}
@@ -10,6 +10,7 @@ import type {
import { MessageType, getMessageTypeName } from '@/types/base'
import { formatDateRange } from '@/utils'
import { CardDecoration, ThemeCard } from '@/components/UI'
import { deriveAnnualActivityRhythm } from '../annual-activity-rhythm'
import AnnualCalendarGrid from './AnnualCalendarGrid.vue'
import AnnualMessageTrend from './AnnualMessageTrend.vue'
@@ -58,24 +59,53 @@ const activeRate = computed(() => {
const days = Math.max(1, Math.round((props.range.endTs - props.range.startTs) / 86400) + 1)
return percentage(props.metrics.activeDayCount, days)
})
const highlights = computed(() => [
{
key: 'dailyContacts',
value: formatValue(props.metrics.averageDirectContactsPerDay),
detail: t('insight.overviewCard.perDay'),
},
{
key: 'peakMonth',
value: formatMonth(peakMonth.value?.month),
detail: t('insight.overviewCard.messagesCount', { count: formatValue(peakMonth.value?.messageCount ?? 0) }),
},
{
key: 'peakDay',
value: peakDay.value?.date.slice(5).replace('-', '/') ?? '-',
detail: t('insight.overviewCard.messagesCount', { count: formatValue(peakDay.value?.messageCount ?? 0) }),
},
const activityRhythm = computed(() => deriveAnnualActivityRhythm(props.dailyActivity))
const weekdayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
const highlightRows = computed(() => [
[
{
key: 'dailyContacts',
value: formatValue(props.metrics.averageDirectContactsPerDay),
detail: t('insight.overviewCard.perDay'),
},
{
key: 'peakMonth',
value: formatMonth(peakMonth.value?.month),
detail: t('insight.overviewCard.messagesCount', { count: formatValue(peakMonth.value?.messageCount ?? 0) }),
},
{
key: 'peakDay',
value: peakDay.value?.date.slice(5).replace('-', '/') ?? '-',
detail: t('insight.overviewCard.messagesCount', { count: formatValue(peakDay.value?.messageCount ?? 0) }),
},
],
[
{
key: 'longestActiveStreak',
value: formatValue(activityRhythm.value.longestActiveStreak),
detail: t('insight.overviewCard.consecutiveActiveDays'),
},
{
key: 'topWeekday',
value: formatWeekday(activityRhythm.value.topWeekday),
detail: t('insight.overviewCard.mostMessagesSent'),
},
{
key: 'weekendMessageRate',
value: activityRhythm.value.weekendMessageRate === null ? '-' : `${activityRhythm.value.weekendMessageRate}%`,
detail:
activityRhythm.value.weekdayMessageRate === null
? t('insight.noData')
: t('insight.overviewCard.weekdayMessageRate', { rate: activityRhythm.value.weekdayMessageRate }),
},
],
])
function formatWeekday(weekday: number | null): string {
if (weekday === null) return '-'
return t(`common.weekday.${weekdayKeys[weekday - 1]}`)
}
function formatValue(value: number): string {
return Number.isInteger(value)
? value.toLocaleString()
@@ -261,15 +291,22 @@ function percentage(value: number, total: number): number {
<h3 class="text-[11px] font-bold uppercase text-pink-600 dark:text-pink-400">
{{ t('insight.overviewCard.keyMetrics') }}
</h3>
<div class="mt-4 grid grid-cols-3 divide-x divide-gray-200 dark:divide-white/10">
<div v-for="item in highlights" :key="item.key" class="min-w-0 px-3 first:pl-0 last:pr-0">
<div class="truncate font-mono text-sm font-black tabular-nums text-gray-900 dark:text-white">
{{ item.value }}
<div class="mt-4 space-y-4">
<div
v-for="(row, rowIndex) in highlightRows"
:key="rowIndex"
class="grid grid-cols-3 divide-x divide-gray-200 dark:divide-white/10"
:class="{ 'border-t border-gray-200 pt-4 dark:border-white/10': rowIndex > 0 }"
>
<div v-for="item in row" :key="item.key" class="min-w-0 px-3 first:pl-0 last:pr-0">
<div class="truncate font-mono text-sm font-black tabular-nums text-gray-900 dark:text-white">
{{ item.value }}
</div>
<div class="mt-1 text-[9px] leading-tight font-medium text-gray-500 dark:text-zinc-400">
{{ t(`insight.overviewCard.${item.key}`) }}
</div>
<div class="mt-0.5 truncate text-[8px] text-gray-400 dark:text-zinc-500">{{ item.detail }}</div>
</div>
<div class="mt-1 truncate text-[9px] font-medium text-gray-500 dark:text-zinc-400">
{{ t(`insight.overviewCard.${item.key}`) }}
</div>
<div class="mt-0.5 truncate text-[8px] text-gray-400 dark:text-zinc-500">{{ item.detail }}</div>
</div>
</div>
</section>