From 755a0b5a762331176b11322e008ec4f52bcefc17 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 23 Jul 2026 15:52:42 +0800 Subject: [PATCH] feat(insight): add annual activity rhythm metrics --- src/i18n/locales/en-US/insight.json | 6 ++ src/i18n/locales/ja-JP/insight.json | 6 ++ src/i18n/locales/zh-CN/insight.json | 6 ++ src/i18n/locales/zh-TW/insight.json | 6 ++ .../annual-activity-rhythm.test.ts | 65 ++++++++++++++ .../annual-summary/annual-activity-rhythm.ts | 78 +++++++++++++++++ .../components/AnnualInsightBoard.vue | 85 +++++++++++++------ 7 files changed, 228 insertions(+), 24 deletions(-) create mode 100644 src/pages/insight/annual-summary/annual-activity-rhythm.test.ts create mode 100644 src/pages/insight/annual-summary/annual-activity-rhythm.ts diff --git a/src/i18n/locales/en-US/insight.json b/src/i18n/locales/en-US/insight.json index 8fb83629..c7313c0f 100644 --- a/src/i18n/locales/en-US/insight.json +++ b/src/i18n/locales/en-US/insight.json @@ -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" diff --git a/src/i18n/locales/ja-JP/insight.json b/src/i18n/locales/ja-JP/insight.json index 4589efd3..8f86dc5a 100644 --- a/src/i18n/locales/ja-JP/insight.json +++ b/src/i18n/locales/ja-JP/insight.json @@ -31,6 +31,12 @@ "dailyContacts": "1日平均交流人数", "peakMonth": "最も活発な月", "peakDay": "最も活発な日", + "longestActiveStreak": "最長連続アクティブ", + "consecutiveActiveDays": "連続してメッセージを送信した日数", + "topWeekday": "最も活発な曜日", + "mostMessagesSent": "送信数が最多", + "weekendMessageRate": "週末メッセージ比率", + "weekdayMessageRate": "平日 {rate}%", "textRatio": "テキストメッセージ比率", "medianLength": "テキスト長の中央値", "p90Length": "テキスト長 P90" diff --git a/src/i18n/locales/zh-CN/insight.json b/src/i18n/locales/zh-CN/insight.json index 972dabde..d4ad9ae8 100644 --- a/src/i18n/locales/zh-CN/insight.json +++ b/src/i18n/locales/zh-CN/insight.json @@ -31,6 +31,12 @@ "dailyContacts": "日均互动人数", "peakMonth": "最活跃月份", "peakDay": "最活跃日期", + "longestActiveStreak": "最长连续活跃", + "consecutiveActiveDays": "连续发出消息的天数", + "topWeekday": "最常活跃星期", + "mostMessagesSent": "发出消息最多", + "weekendMessageRate": "周末消息占比", + "weekdayMessageRate": "工作日 {rate}%", "textRatio": "文字消息占比", "medianLength": "文本长度中位数", "p90Length": "文本长度 P90" diff --git a/src/i18n/locales/zh-TW/insight.json b/src/i18n/locales/zh-TW/insight.json index 7ffed908..1f9e86e9 100644 --- a/src/i18n/locales/zh-TW/insight.json +++ b/src/i18n/locales/zh-TW/insight.json @@ -31,6 +31,12 @@ "dailyContacts": "日均互動人數", "peakMonth": "最活躍月份", "peakDay": "最活躍日期", + "longestActiveStreak": "最長連續活躍", + "consecutiveActiveDays": "連續發出訊息的天數", + "topWeekday": "最常活躍星期", + "mostMessagesSent": "發出訊息最多", + "weekendMessageRate": "週末訊息佔比", + "weekdayMessageRate": "工作日 {rate}%", "textRatio": "文字訊息占比", "medianLength": "文字長度中位數", "p90Length": "文字長度 P90" diff --git a/src/pages/insight/annual-summary/annual-activity-rhythm.test.ts b/src/pages/insight/annual-summary/annual-activity-rhythm.test.ts new file mode 100644 index 00000000..7139f71f --- /dev/null +++ b/src/pages/insight/annual-summary/annual-activity-rhythm.test.ts @@ -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) +}) diff --git a/src/pages/insight/annual-summary/annual-activity-rhythm.ts b/src/pages/insight/annual-summary/annual-activity-rhythm.ts new file mode 100644 index 00000000..0f4cd48b --- /dev/null +++ b/src/pages/insight/annual-summary/annual-activity-rhythm.ts @@ -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() + + 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 +} diff --git a/src/pages/insight/annual-summary/components/AnnualInsightBoard.vue b/src/pages/insight/annual-summary/components/AnnualInsightBoard.vue index 049656ab..a3d5fd65 100644 --- a/src/pages/insight/annual-summary/components/AnnualInsightBoard.vue +++ b/src/pages/insight/annual-summary/components/AnnualInsightBoard.vue @@ -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 {

{{ t('insight.overviewCard.keyMetrics') }}

-
-
-
- {{ item.value }} +
+
+
+
+ {{ item.value }} +
+
+ {{ t(`insight.overviewCard.${item.key}`) }} +
+
{{ item.detail }}
-
- {{ t(`insight.overviewCard.${item.key}`) }} -
-
{{ item.detail }}