[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
0

Configure Feed

Select the types of activity you want to include in your feed.

One-Calendar / components / app / analytics / time-analytics.tsx
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}