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