[READ-ONLY] One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links 馃搮
calendar.xyehr.cn
nextjs
16 kB
486 lines
1'use client'
2
3import { useMemo, useState } from 'react'
4import { differenceInCalendarDays, format, getDay } from 'date-fns'
5import type { CalendarEvent } from '../calendar'
6import type { CalendarCategory } from '../sidebar/sidebar'
7import {
8 addDurationByDayCategory,
9 calculateDaySpanInHours,
10 filterEventsInRange,
11 formatHourRange,
12 generateRangeDays,
13 getChartColorOrderIndex,
14 getMonthDays,
15 groupDayKey,
16 groupMonthKey,
17 mapEventsToAnalyticsEvents,
18 normalizeChartColor,
19 resolveDateRange,
20 type AnalyticsRangePreset,
21} from './analytics-utils'
22import {
23 Select,
24 SelectContent,
25 SelectItem,
26 SelectTrigger,
27 SelectValue,
28} from '@/components/ui/select'
29import { DailyMonthlyCountChart } from './charts/daily-monthly-count-chart'
30import { YearHeatmapChart } from './charts/year-heatmap-chart'
31import { CategoryDonutChart } from './charts/category-donut-chart'
32import { CategoryAverageDurationChart } from './charts/category-average-duration-chart'
33import { WeekdayStackedDurationChart } from './charts/weekday-stacked-duration-chart'
34import { AnalyticsMetricsGrid } from './metrics/analytics-metrics-grid'
35import { translations, useLanguage } from '@/lib/i18n'
36
37interface TimeAnalyticsProps {
38 events: CalendarEvent[]
39 calendars?: CalendarCategory[]
40 key?: string
41}
42
43export default function TimeAnalyticsComponent({
44 events,
45 calendars = [],
46}: TimeAnalyticsProps) {
47 const [language] = useLanguage()
48 const t = translations[language]
49 const weekdayLabels = [
50 t.weekdays[1],
51 t.weekdays[2],
52 t.weekdays[3],
53 t.weekdays[4],
54 t.weekdays[5],
55 t.weekdays[6],
56 t.weekdays[0],
57 ]
58 const dayName = (date: Date): string => {
59 const day = getDay(date)
60 if (day === 0) return weekdayLabels[6]
61 return weekdayLabels[day - 1]
62 }
63 const [preset, setPreset] = useState<AnalyticsRangePreset>('month')
64 const [countMode, setCountMode] = useState<'day' | 'month'>('day')
65
66 const now = useMemo(() => new Date(), [])
67 const dateRange = useMemo(() => resolveDateRange(preset, now), [preset, now])
68 const futureDateRange = useMemo(
69 () => ({
70 start: now,
71 end: new Date(
72 now.getTime() + (dateRange.end.getTime() - dateRange.start.getTime()),
73 ),
74 }),
75 [dateRange.end, dateRange.start, now],
76 )
77
78 const normalizedEvents = useMemo(
79 () => mapEventsToAnalyticsEvents(events),
80 [events],
81 )
82 const rangeEvents = useMemo(
83 () => filterEventsInRange(normalizedEvents, dateRange),
84 [normalizedEvents, dateRange],
85 )
86 const futureRangeEvents = useMemo(
87 () => filterEventsInRange(normalizedEvents, futureDateRange),
88 [futureDateRange, normalizedEvents],
89 )
90
91 const categoryMeta = useMemo(() => {
92 return new Map(
93 calendars.map((calendar) => [
94 calendar.id,
95 {
96 name: calendar.name,
97 color: normalizeChartColor(calendar.color),
98 },
99 ]),
100 )
101 }, [calendars])
102
103 const resolveCategoryLabel = (categoryId: string): string => {
104 if (categoryId === 'uncategorized') return t.uncategorized
105 return categoryMeta.get(categoryId)?.name ?? categoryId
106 }
107
108 const resolveCategoryColor = (
109 categoryId: string,
110 fallbackColor: string,
111 ): string => {
112 if (categoryId === 'uncategorized') return '#64748b'
113 return categoryMeta.get(categoryId)?.color ?? fallbackColor
114 }
115
116 const resolveColorName = (color: string): string => {
117 const normalized = color.toLowerCase()
118 if (normalized === '#3b82f6') return t.colorBlue
119 if (normalized === '#10b981') return t.colorGreen
120 if (normalized === '#f59e0b') return t.colorYellow
121 if (normalized === '#ef4444') return t.colorRed
122 if (normalized === '#8b5cf6') return t.colorPurple
123 if (normalized === '#ec4899') return t.colorPink
124 if (normalized === '#14b8a6') return t.colorTeal
125 if (normalized === '#6366f1') return t.colorIndigo
126 if (normalized === '#fb923c') return t.colorOrange
127 return color
128 }
129
130 const countChart = useMemo(() => {
131 const seriesMeta = new Map<
132 string,
133 { label: string; color: string; totalCount: number }
134 >()
135 const dailyBuckets = new Map<string, Record<string, number>>()
136 const monthlyBuckets = new Map<string, Record<string, number>>()
137
138 rangeEvents.forEach((event) => {
139 const seriesColor = event.color
140 const seriesKey = seriesColor
141 const seriesLabel = resolveColorName(seriesColor)
142 const previous = seriesMeta.get(seriesKey)
143 seriesMeta.set(seriesKey, {
144 label: previous?.label ?? seriesLabel,
145 color: seriesColor,
146 totalCount: (previous?.totalCount ?? 0) + 1,
147 })
148
149 const dayKey = groupDayKey(event.start)
150 const monthKey = groupMonthKey(event.start)
151
152 const dayBucket = dailyBuckets.get(dayKey) ?? {}
153 dayBucket[seriesKey] = (dayBucket[seriesKey] ?? 0) + 1
154 dailyBuckets.set(dayKey, dayBucket)
155
156 const monthBucket = monthlyBuckets.get(monthKey) ?? {}
157 monthBucket[seriesKey] = (monthBucket[seriesKey] ?? 0) + 1
158 monthlyBuckets.set(monthKey, monthBucket)
159 })
160
161 const series = Array.from(seriesMeta.entries())
162 .sort((a, b) => {
163 const colorOrder =
164 getChartColorOrderIndex(a[1].color) -
165 getChartColorOrderIndex(b[1].color)
166 if (colorOrder !== 0) return colorOrder
167 return b[1].totalCount - a[1].totalCount
168 })
169 .map(([key, value]) => ({
170 key,
171 label: value.label,
172 color: value.color,
173 }))
174
175 const dailyData = Array.from(dailyBuckets.entries())
176 .sort((a, b) => a[0].localeCompare(b[0]))
177 .map(([label, values]) => ({ label, ...values }))
178
179 const monthlyData = Array.from(monthlyBuckets.entries())
180 .sort((a, b) => a[0].localeCompare(b[0]))
181 .map(([label, values]) => ({ label, ...values }))
182
183 return { series, dailyData, monthlyData }
184 }, [categoryMeta, rangeEvents, resolveColorName])
185
186 const heatmapData = useMemo(() => {
187 const currentYear = now.getFullYear()
188 const days = getMonthDays(currentYear)
189 const counts = new Map<string, number>()
190 normalizedEvents.forEach((event) => {
191 if (event.start.getFullYear() !== currentYear) return
192 const key = groupDayKey(event.start)
193 counts.set(key, (counts.get(key) ?? 0) + 1)
194 })
195
196 return days.map((date) => ({
197 date,
198 count: counts.get(groupDayKey(date)) ?? 0,
199 }))
200 }, [normalizedEvents, now])
201
202 const categoryDonutData = useMemo(() => {
203 const categoryCounts = new Map<string, { count: number; color: string }>()
204 rangeEvents.forEach((event) => {
205 const label = resolveCategoryLabel(event.category)
206 const prev = categoryCounts.get(label)
207 categoryCounts.set(label, {
208 count: (prev?.count ?? 0) + 1,
209 color: prev?.color ?? resolveCategoryColor(event.category, event.color),
210 })
211 })
212
213 const total = rangeEvents.length
214 if (total === 0) return []
215
216 return Array.from(categoryCounts.entries())
217 .filter(([, value]) => value.count > 0)
218 .map(([category, value]) => ({
219 category,
220 count: value.count,
221 percent: (value.count / total) * 100,
222 color: value.color,
223 }))
224 .sort((a, b) => b.count - a.count)
225 }, [categoryMeta, rangeEvents, resolveCategoryLabel])
226
227 const categoryAvgDurationData = useMemo(() => {
228 const categoryDuration = new Map<
229 string,
230 { total: number; count: number; color: string }
231 >()
232 rangeEvents.forEach((event) => {
233 const duration = calculateDaySpanInHours(event.start, event.end)
234 const label = resolveCategoryLabel(event.category)
235 const prev = categoryDuration.get(label)
236 categoryDuration.set(label, {
237 total: (prev?.total ?? 0) + duration,
238 count: (prev?.count ?? 0) + 1,
239 color: prev?.color ?? resolveCategoryColor(event.category, event.color),
240 })
241 })
242
243 return Array.from(categoryDuration.entries())
244 .map(([category, value]) => ({
245 category,
246 hours: Number((value.total / value.count).toFixed(1)),
247 color: value.color,
248 }))
249 .sort((a, b) => b.hours - a.hours)
250 }, [categoryMeta, rangeEvents, resolveCategoryLabel])
251
252 const weekdayStacked = useMemo(() => {
253 const buckets: Record<
254 string,
255 Record<string, { hours: number; color: string }>
256 > = {}
257
258 rangeEvents.forEach((event) => {
259 const label = dayName(event.start)
260 const categoryLabel = resolveCategoryLabel(event.category)
261 const color = resolveCategoryColor(event.category, event.color)
262 const hours = calculateDaySpanInHours(event.start, event.end)
263 addDurationByDayCategory(buckets, label, categoryLabel, color, hours)
264 })
265
266 const categoryColors = new Map<string, string>()
267 Object.values(buckets).forEach((value) => {
268 Object.entries(value).forEach(([category, item]) => {
269 if (!categoryColors.has(category)) {
270 categoryColors.set(category, item.color)
271 }
272 })
273 })
274
275 const data = weekdayLabels.map((label) => {
276 const row: Record<string, string | number> = { day: label }
277 const values = buckets[label] ?? {}
278 categoryColors.forEach((_, category) => {
279 row[category] = Number((values[category]?.hours ?? 0).toFixed(1))
280 })
281 return row
282 })
283
284 const series = Array.from(categoryColors.entries())
285 .sort(
286 (a, b) => getChartColorOrderIndex(a[1]) - getChartColorOrderIndex(b[1]),
287 )
288 .map(([key, color]) => ({
289 key,
290 color,
291 }))
292
293 return { data, series }
294 }, [categoryMeta, rangeEvents, resolveCategoryLabel, weekdayLabels])
295
296 const metrics = useMemo(() => {
297 if (rangeEvents.length === 0) {
298 const futureLeadTimes = futureRangeEvents.map((event) =>
299 Math.max(
300 (event.start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
301 0,
302 ),
303 )
304 const futureAvgLead =
305 futureLeadTimes.length === 0
306 ? 0
307 : futureLeadTimes.reduce((sum, value) => sum + value, 0) /
308 futureLeadTimes.length
309 return [
310 {
311 title: t.analyticsMetricLongestStreak,
312 value: `0 ${t.analyticsDayUnit}`,
313 subtitle: t.analyticsNoScheduleInRange,
314 },
315 {
316 title: t.analyticsMetricBusiestWeekday,
317 value: t.analyticsNone,
318 subtitle: t.analyticsNoScheduleInRange,
319 },
320 {
321 title: t.analyticsMetricAvgLeadDays,
322 value: `${futureAvgLead.toFixed(1)} ${t.analyticsDayUnit}`,
323 subtitle: t.analyticsLeadDaysHint,
324 },
325 {
326 title: t.analyticsMetricPeakTimeWindow,
327 value: t.analyticsNone,
328 subtitle: t.analyticsNoScheduleInRange,
329 },
330 ]
331 }
332
333 const days = generateRangeDays(dateRange)
334 const activeSet = new Set(
335 rangeEvents.map((event) => groupDayKey(event.start)),
336 )
337
338 let bestStreak = 0
339 let currentStreak = 0
340 let bestStart = days[0]
341 let bestEnd = days[0]
342 let currentStart = days[0]
343
344 days.forEach((day) => {
345 const key = groupDayKey(day)
346 if (activeSet.has(key)) {
347 if (currentStreak === 0) currentStart = day
348 currentStreak += 1
349 if (currentStreak > bestStreak) {
350 bestStreak = currentStreak
351 bestStart = currentStart
352 bestEnd = day
353 }
354 } else {
355 currentStreak = 0
356 }
357 })
358
359 const weekdayMap = new Map<string, number>()
360 rangeEvents.forEach((event) => {
361 const label = dayName(event.start)
362 weekdayMap.set(label, (weekdayMap.get(label) ?? 0) + 1)
363 })
364
365 const totalWeeks = Math.max(
366 (differenceInCalendarDays(dateRange.end, dateRange.start) + 1) / 7,
367 1,
368 )
369 const weekdayAverages = weekdayLabels.map((label) => ({
370 day: label,
371 avg: (weekdayMap.get(label) ?? 0) / totalWeeks,
372 }))
373
374 const busiestDay = weekdayAverages.reduce(
375 (max, current) => (current.avg > max.avg ? current : max),
376 weekdayAverages[0],
377 )
378 const overallAvg = rangeEvents.length / 7
379 const uplift =
380 overallAvg === 0 ? 0 : ((busiestDay.avg - overallAvg) / overallAvg) * 100
381
382 const leadTimes = futureRangeEvents.map((event) =>
383 Math.max(
384 (event.start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
385 0,
386 ),
387 )
388 const avgLead =
389 leadTimes.length === 0
390 ? 0
391 : leadTimes.reduce((sum, value) => sum + value, 0) / leadTimes.length
392
393 const hourCounts = Array.from({ length: 24 }).map(() => 0)
394 rangeEvents.forEach((event) => {
395 hourCounts[event.start.getHours()] += 1
396 })
397
398 let bestWindowHour = 0
399 let bestWindowCount = 0
400 for (let i = 0; i < 24; i += 1) {
401 const count = hourCounts[i] + hourCounts[(i + 1) % 24]
402 if (count > bestWindowCount) {
403 bestWindowCount = count
404 bestWindowHour = i
405 }
406 }
407
408 const concentrationRatio =
409 rangeEvents.length === 0
410 ? 0
411 : (bestWindowCount / rangeEvents.length) * 100
412
413 return [
414 {
415 title: t.analyticsMetricLongestStreak,
416 value: `${bestStreak} ${t.analyticsDayUnit}`,
417 subtitle: `${format(bestStart, 'yyyy-MM-dd')} ${t.analyticsTo} ${format(bestEnd, 'yyyy-MM-dd')}`,
418 },
419 {
420 title: t.analyticsMetricBusiestWeekday,
421 value: busiestDay.day,
422 subtitle: t.analyticsBusiestWeekdaySubtitle
423 .replace('{avg}', busiestDay.avg.toFixed(1))
424 .replace('{pct}', uplift.toFixed(1))
425 .replace('{unit}', t.analyticsScheduleUnit),
426 },
427 {
428 title: t.analyticsMetricAvgLeadDays,
429 value: `${avgLead.toFixed(1)} ${t.analyticsDayUnit}`,
430 subtitle: t.analyticsLeadDaysHint,
431 },
432 {
433 title: t.analyticsMetricPeakTimeWindow,
434 value: formatHourRange(bestWindowHour),
435 subtitle: t.analyticsPeakWindowSubtitle.replace(
436 '{pct}',
437 concentrationRatio.toFixed(1),
438 ),
439 },
440 ]
441 }, [dateRange, futureRangeEvents, now, rangeEvents, t, weekdayLabels])
442
443 return (
444 <div className="space-y-6 rounded-lg border p-4">
445 <div className="flex items-center justify-between">
446 <h2 className="text-base font-semibold">{t.analyticsOverviewTitle}</h2>
447 <Select
448 value={preset}
449 onValueChange={(value) => setPreset(value as AnalyticsRangePreset)}
450 >
451 <SelectTrigger className="w-[160px]">
452 <SelectValue />
453 </SelectTrigger>
454 <SelectContent>
455 <SelectItem value="week">{t.analyticsPresetWeek}</SelectItem>
456 <SelectItem value="month">{t.analyticsPresetMonth}</SelectItem>
457 <SelectItem value="quarter">{t.analyticsPresetQuarter}</SelectItem>
458 </SelectContent>
459 </Select>
460 </div>
461
462 <AnalyticsMetricsGrid items={metrics} />
463
464 <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
465 <DailyMonthlyCountChart
466 dailyData={countChart.dailyData}
467 monthlyData={countChart.monthlyData}
468 series={countChart.series}
469 mode={countMode}
470 onModeChange={setCountMode}
471 />
472 <CategoryDonutChart data={categoryDonutData} />
473 </div>
474
475 <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
476 <CategoryAverageDurationChart data={categoryAvgDurationData} />
477 <WeekdayStackedDurationChart
478 data={weekdayStacked.data}
479 series={weekdayStacked.series}
480 />
481 </div>
482
483 <YearHeatmapChart data={heatmapData} />
484 </div>
485 )
486}