[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
488 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: { day: string; [category: string]: string | number } = {
277 day: label,
278 }
279 const values = buckets[label] ?? {}
280 categoryColors.forEach((_, category) => {
281 row[category] = Number((values[category]?.hours ?? 0).toFixed(1))
282 })
283 return row
284 })
285
286 const series = Array.from(categoryColors.entries())
287 .sort(
288 (a, b) => getChartColorOrderIndex(a[1]) - getChartColorOrderIndex(b[1]),
289 )
290 .map(([key, color]) => ({
291 key,
292 color,
293 }))
294
295 return { data, series }
296 }, [categoryMeta, rangeEvents, resolveCategoryLabel, weekdayLabels])
297
298 const metrics = useMemo(() => {
299 if (rangeEvents.length === 0) {
300 const futureLeadTimes = futureRangeEvents.map((event) =>
301 Math.max(
302 (event.start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
303 0,
304 ),
305 )
306 const futureAvgLead =
307 futureLeadTimes.length === 0
308 ? 0
309 : futureLeadTimes.reduce((sum, value) => sum + value, 0) /
310 futureLeadTimes.length
311 return [
312 {
313 title: t.analyticsMetricLongestStreak,
314 value: `0 ${t.analyticsDayUnit}`,
315 subtitle: t.analyticsNoScheduleInRange,
316 },
317 {
318 title: t.analyticsMetricBusiestWeekday,
319 value: t.analyticsNone,
320 subtitle: t.analyticsNoScheduleInRange,
321 },
322 {
323 title: t.analyticsMetricAvgLeadDays,
324 value: `${futureAvgLead.toFixed(1)} ${t.analyticsDayUnit}`,
325 subtitle: t.analyticsLeadDaysHint,
326 },
327 {
328 title: t.analyticsMetricPeakTimeWindow,
329 value: t.analyticsNone,
330 subtitle: t.analyticsNoScheduleInRange,
331 },
332 ]
333 }
334
335 const days = generateRangeDays(dateRange)
336 const activeSet = new Set(
337 rangeEvents.map((event) => groupDayKey(event.start)),
338 )
339
340 let bestStreak = 0
341 let currentStreak = 0
342 let bestStart = days[0]
343 let bestEnd = days[0]
344 let currentStart = days[0]
345
346 days.forEach((day) => {
347 const key = groupDayKey(day)
348 if (activeSet.has(key)) {
349 if (currentStreak === 0) currentStart = day
350 currentStreak += 1
351 if (currentStreak > bestStreak) {
352 bestStreak = currentStreak
353 bestStart = currentStart
354 bestEnd = day
355 }
356 } else {
357 currentStreak = 0
358 }
359 })
360
361 const weekdayMap = new Map<string, number>()
362 rangeEvents.forEach((event) => {
363 const label = dayName(event.start)
364 weekdayMap.set(label, (weekdayMap.get(label) ?? 0) + 1)
365 })
366
367 const totalWeeks = Math.max(
368 (differenceInCalendarDays(dateRange.end, dateRange.start) + 1) / 7,
369 1,
370 )
371 const weekdayAverages = weekdayLabels.map((label) => ({
372 day: label,
373 avg: (weekdayMap.get(label) ?? 0) / totalWeeks,
374 }))
375
376 const busiestDay = weekdayAverages.reduce(
377 (max, current) => (current.avg > max.avg ? current : max),
378 weekdayAverages[0],
379 )
380 const overallAvg = rangeEvents.length / 7
381 const uplift =
382 overallAvg === 0 ? 0 : ((busiestDay.avg - overallAvg) / overallAvg) * 100
383
384 const leadTimes = futureRangeEvents.map((event) =>
385 Math.max(
386 (event.start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
387 0,
388 ),
389 )
390 const avgLead =
391 leadTimes.length === 0
392 ? 0
393 : leadTimes.reduce((sum, value) => sum + value, 0) / leadTimes.length
394
395 const hourCounts = Array.from({ length: 24 }).map(() => 0)
396 rangeEvents.forEach((event) => {
397 hourCounts[event.start.getHours()] += 1
398 })
399
400 let bestWindowHour = 0
401 let bestWindowCount = 0
402 for (let i = 0; i < 24; i += 1) {
403 const count = hourCounts[i] + hourCounts[(i + 1) % 24]
404 if (count > bestWindowCount) {
405 bestWindowCount = count
406 bestWindowHour = i
407 }
408 }
409
410 const concentrationRatio =
411 rangeEvents.length === 0
412 ? 0
413 : (bestWindowCount / rangeEvents.length) * 100
414
415 return [
416 {
417 title: t.analyticsMetricLongestStreak,
418 value: `${bestStreak} ${t.analyticsDayUnit}`,
419 subtitle: `${format(bestStart, 'yyyy-MM-dd')} ${t.analyticsTo} ${format(bestEnd, 'yyyy-MM-dd')}`,
420 },
421 {
422 title: t.analyticsMetricBusiestWeekday,
423 value: busiestDay.day,
424 subtitle: t.analyticsBusiestWeekdaySubtitle
425 .replace('{avg}', busiestDay.avg.toFixed(1))
426 .replace('{pct}', uplift.toFixed(1))
427 .replace('{unit}', t.analyticsScheduleUnit),
428 },
429 {
430 title: t.analyticsMetricAvgLeadDays,
431 value: `${avgLead.toFixed(1)} ${t.analyticsDayUnit}`,
432 subtitle: t.analyticsLeadDaysHint,
433 },
434 {
435 title: t.analyticsMetricPeakTimeWindow,
436 value: formatHourRange(bestWindowHour),
437 subtitle: t.analyticsPeakWindowSubtitle.replace(
438 '{pct}',
439 concentrationRatio.toFixed(1),
440 ),
441 },
442 ]
443 }, [dateRange, futureRangeEvents, now, rangeEvents, t, weekdayLabels])
444
445 return (
446 <div className="space-y-6 rounded-lg border p-4">
447 <div className="flex items-center justify-between">
448 <h2 className="text-base font-semibold">{t.analyticsOverviewTitle}</h2>
449 <Select
450 value={preset}
451 onValueChange={(value) => setPreset(value as AnalyticsRangePreset)}
452 >
453 <SelectTrigger className="w-[160px]">
454 <SelectValue />
455 </SelectTrigger>
456 <SelectContent>
457 <SelectItem value="week">{t.analyticsPresetWeek}</SelectItem>
458 <SelectItem value="month">{t.analyticsPresetMonth}</SelectItem>
459 <SelectItem value="quarter">{t.analyticsPresetQuarter}</SelectItem>
460 </SelectContent>
461 </Select>
462 </div>
463
464 <AnalyticsMetricsGrid items={metrics} />
465
466 <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
467 <DailyMonthlyCountChart
468 dailyData={countChart.dailyData}
469 monthlyData={countChart.monthlyData}
470 series={countChart.series}
471 mode={countMode}
472 onModeChange={setCountMode}
473 />
474 <CategoryDonutChart data={categoryDonutData} />
475 </div>
476
477 <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
478 <CategoryAverageDurationChart data={categoryAvgDurationData} />
479 <WeekdayStackedDurationChart
480 data={weekdayStacked.data}
481 series={weekdayStacked.series}
482 />
483 </div>
484
485 <YearHeatmapChart data={heatmapData} />
486 </div>
487 )
488}