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
5
fork

Configure Feed

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

fix: rollback analytics feature to pre-analysis version

+386 -1070
+271 -317
components/app/analytics/time-analytics.tsx
··· 1 1 "use client"; 2 2 3 - import { useMemo, useState } from "react"; 4 3 import { 5 - Bar, 4 + PieChart, 5 + Pie, 6 + Cell, 7 + ResponsiveContainer, 6 8 BarChart, 7 - CartesianGrid, 8 - PolarAngleAxis, 9 - PolarGrid, 10 - Radar, 11 - RadarChart, 9 + Bar, 12 10 XAxis, 13 11 YAxis, 14 - Pie, 15 - PieChart, 16 - Cell, 12 + Tooltip, 13 + Legend, 17 14 } from "recharts"; 18 15 import { 19 - ChartContainer, 20 - ChartLegend, 21 - ChartLegendContent, 22 - ChartTooltip, 23 - ChartTooltipContent, 24 - type ChartConfig, 25 - } from "@/components/ui/chart"; 26 - import { 27 - buildAnalyticsRange, 28 - generateScheduleAnalytics, 29 - type AnalyticsRangeOption, 30 - type DayPartKey, 31 - } from "@/lib/time-analytics"; 16 + Select, 17 + SelectContent, 18 + SelectItem, 19 + SelectTrigger, 20 + SelectValue, 21 + } from "@/components/ui/select"; 22 + import { analyzeTimeUsage, type TimeAnalytics } from "@/lib/time-analytics"; 32 23 import type { CalendarCategory } from "../sidebar/sidebar"; 33 - import type { CalendarEvent } from "../calendar"; 34 - import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 35 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 36 - import { cn } from "@/lib/utils"; 24 + import { useLocalStorage } from "@/hooks/useLocalStorage"; 37 25 import { translations, useLanguage } from "@/lib/i18n"; 26 + import type { CalendarEvent } from "../calendar"; 27 + import { useState, useEffect } from "react"; 38 28 39 29 interface TimeAnalyticsProps { 40 30 events: CalendarEvent[]; 41 31 calendars?: CalendarCategory[]; 42 32 } 43 33 44 - const COLOR_POOL = [ 45 - "#4f46e5", 46 - "#06b6d4", 47 - "#22c55e", 48 - "#f59e0b", 49 - "#ef4444", 50 - "#ec4899", 51 - "#8b5cf6", 52 - "#0ea5e9", 53 - "#84cc16", 54 - "#f97316", 55 - ]; 56 - 57 - const TW_CLASS_TO_HEX: Record<string, string> = { 58 - "bg-blue-500": "#3b82f6", 59 - "bg-green-500": "#10b981", 60 - "bg-yellow-500": "#f59e0b", 61 - "bg-red-500": "#ef4444", 62 - "bg-purple-500": "#8b5cf6", 63 - "bg-pink-500": "#ec4899", 64 - "bg-teal-500": "#14b8a6", 65 - "bg-gray-500": "#6b7280", 66 - }; 67 - 68 - export default function TimeAnalyticsComponent({ events, calendars = [] }: TimeAnalyticsProps) { 34 + export default function TimeAnalyticsComponent({ 35 + events, 36 + calendars = [], 37 + }: TimeAnalyticsProps) { 38 + const [timeCategories, setTimeCategories] = useLocalStorage< 39 + CalendarCategory[] 40 + >("calendar-categories", calendars); 41 + const [analytics, setAnalytics] = useState<TimeAnalytics | null>(null); 42 + const [newCategory, setNewCategory] = useState<Partial<CalendarCategory>>({ 43 + name: "", 44 + color: "bg-gray-500", 45 + keywords: [], 46 + }); 47 + const [timeRange, setTimeRange] = useState<"week" | "month" | "year">( 48 + "month", 49 + ); 69 50 const [language] = useLanguage(); 70 51 const t = translations[language]; 71 52 72 - const RANGE_OPTIONS: Array<{ key: AnalyticsRangeOption["key"]; label: string }> = [ 73 - { key: "7d", label: t.analyticsRange7d }, 74 - { key: "30d", label: t.analyticsRange30d }, 75 - { key: "90d", label: t.analyticsRange90d }, 76 - { key: "1y", label: t.analyticsRange1y }, 77 - ]; 53 + const [forceUpdate, setForceUpdate] = useState(0); 78 54 79 - const weekdayNames = [ 80 - t.analyticsWeekdayMon, 81 - t.analyticsWeekdayTue, 82 - t.analyticsWeekdayWed, 83 - t.analyticsWeekdayThu, 84 - t.analyticsWeekdayFri, 85 - t.analyticsWeekdaySat, 86 - t.analyticsWeekdaySun, 87 - ]; 55 + useEffect(() => { 56 + const handleLanguageChange = () => { 57 + setForceUpdate((prev) => prev + 1); 58 + }; 88 59 89 - const dayPartNames: Record<DayPartKey, string> = { 90 - allDay: t.analyticsDayPartAllDay, 91 - morning: t.analyticsDayPartMorning, 92 - afternoon: t.analyticsDayPartAfternoon, 93 - evening: t.analyticsDayPartEvening, 94 - lateNight: t.analyticsDayPartLateNight, 95 - }; 60 + window.addEventListener("languagechange", handleLanguageChange); 61 + return () => { 62 + window.removeEventListener("languagechange", handleLanguageChange); 63 + }; 64 + }, []); 96 65 97 - const [rangeKey, setRangeKey] = useState<AnalyticsRangeOption["key"]>("30d"); 98 - const [granularity, setGranularity] = useState<"day" | "month">("day"); 99 - const [compareMode, setCompareMode] = useState<"week" | "month">("week"); 66 + useEffect(() => { 67 + const now = new Date(); 68 + const filteredEvents = events.filter((event) => { 69 + const eventDate = new Date(event.startDate); 70 + if (timeRange === "week") { 71 + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); 72 + return eventDate >= oneWeekAgo; 73 + } else if (timeRange === "month") { 74 + const oneMonthAgo = new Date( 75 + now.getFullYear(), 76 + now.getMonth() - 1, 77 + now.getDate(), 78 + ); 79 + return eventDate >= oneMonthAgo; 80 + } else if (timeRange === "year") { 81 + const oneYearAgo = new Date( 82 + now.getFullYear() - 1, 83 + now.getMonth(), 84 + now.getDate(), 85 + ); 86 + return eventDate >= oneYearAgo; 87 + } 88 + return true; 89 + }); 100 90 101 - const range = useMemo( 102 - () => 103 - buildAnalyticsRange(rangeKey, { 104 - "7d": t.analyticsRange7d, 105 - "30d": t.analyticsRange30d, 106 - "90d": t.analyticsRange90d, 107 - "1y": t.analyticsRange1y, 108 - }), 109 - [rangeKey, t], 110 - ); 91 + const result = analyzeTimeUsage(filteredEvents, timeCategories); 92 + setAnalytics(result); 93 + }, [events, timeCategories, timeRange, language, forceUpdate]); 111 94 112 - const analytics = useMemo( 113 - () => 114 - generateScheduleAnalytics({ 115 - events, 116 - categories: calendars, 117 - range, 118 - granularity, 119 - compareMode, 120 - labels: { 121 - uncategorized: t.uncategorized, 122 - currentWeek: t.thisWeek, 123 - previousWeek: t.analyticsPreviousWeek, 124 - currentMonth: t.thisMonth, 125 - previousMonth: t.analyticsPreviousMonth, 126 - }, 127 - }), 128 - [events, calendars, range, granularity, compareMode, t], 129 - ); 95 + const handleAddCategory = () => { 96 + if (newCategory.name) { 97 + const newCat: CalendarCategory = { 98 + id: Date.now().toString(), 99 + name: newCategory.name, 100 + color: newCategory.color || "bg-gray-500", 101 + keywords: [], 102 + }; 103 + setTimeCategories([...timeCategories, newCat]); 104 + setNewCategory({ 105 + name: "", 106 + color: "bg-gray-500", 107 + keywords: [], 108 + }); 109 + } 110 + }; 130 111 131 - const categoryColorMap = useMemo(() => { 132 - const map: Record<string, string> = { uncategorized: "#a1a1aa" }; 133 - calendars.forEach((category, idx) => { 134 - map[category.id] = TW_CLASS_TO_HEX[category.color] ?? COLOR_POOL[idx % COLOR_POOL.length]; 112 + const handleRemoveCategory = (id: string) => { 113 + setTimeCategories(timeCategories.filter((cat) => cat.id !== id)); 114 + }; 115 + 116 + if (!analytics) { 117 + return <div>{t.loading}</div>; 118 + } 119 + 120 + const pieData = Object.entries(analytics.categorizedHours) 121 + .filter(([_, hours]) => hours > 0) 122 + .map(([categoryId, hours]) => { 123 + const category = timeCategories.find((cat) => cat.id === categoryId); 124 + return { 125 + name: category ? category.name : t.uncategorized, 126 + value: Math.round(hours * 10) / 10, 127 + color: category ? category.color.replace("bg-", "") : "gray-500", 128 + }; 135 129 }); 136 - return map; 137 - }, [calendars]); 138 130 139 - const chartConfig = useMemo<ChartConfig>(() => { 140 - const config: ChartConfig = { count: { label: t.analyticsEventCountLabel, color: "#3b82f6" } }; 141 - analytics.categoryShare.forEach((category, idx) => { 142 - config[category.key] = { 143 - label: category.label, 144 - color: categoryColorMap[category.key] ?? COLOR_POOL[idx % COLOR_POOL.length], 131 + const barData = Object.entries(analytics.categorizedHours) 132 + .filter(([_, hours]) => hours > 0) 133 + .map(([categoryId, hours]) => { 134 + const category = timeCategories.find((cat) => cat.id === categoryId); 135 + return { 136 + name: category ? category.name : t.uncategorized, 137 + hours: Math.round(hours * 10) / 10, 138 + color: category ? category.color.replace("bg-", "") : "gray-500", 145 139 }; 146 140 }); 147 - return config; 148 - }, [analytics.categoryShare, categoryColorMap, t.analyticsEventCountLabel]); 141 + 142 + const colorMap: Record<string, string> = { 143 + "blue-500": "#3b82f6", 144 + "green-500": "#22c55e", 145 + "purple-500": "#a855f7", 146 + "yellow-500": "#eab308", 147 + "red-500": "#ef4444", 148 + "gray-500": "#6b7280", 149 + "pink-500": "#ec4899", 150 + "indigo-500": "#6366f1", 151 + "orange-500": "#f97316", 152 + "teal-500": "#14b8a6", 153 + }; 149 154 150 - const maxHeat = Math.max(...analytics.yearlyHeatmap.map((item) => item.count), 0); 155 + const busiestCategoryName = (() => { 156 + if (analytics.busiestCategoryId === "uncategorized") return t.uncategorized; 157 + return ( 158 + timeCategories.find((cat) => cat.id === analytics.busiestCategoryId) 159 + ?.name || t.uncategorized 160 + ); 161 + })(); 151 162 152 163 return ( 153 - <div className="space-y-4"> 154 - <Card> 155 - <CardHeader className="flex flex-row items-center justify-between gap-2"> 156 - <CardTitle>{t.analyticsV2Title}</CardTitle> 157 - <div className="flex gap-2"> 158 - <Select value={rangeKey} onValueChange={(value) => setRangeKey(value as AnalyticsRangeOption["key"])}> 159 - <SelectTrigger className="w-28"><SelectValue /></SelectTrigger> 160 - <SelectContent> 161 - {RANGE_OPTIONS.map((option) => ( 162 - <SelectItem key={option.key} value={option.key}>{option.label}</SelectItem> 163 - ))} 164 - </SelectContent> 165 - </Select> 166 - <Select value={granularity} onValueChange={(value) => setGranularity(value as "day" | "month")}> 167 - <SelectTrigger className="w-28"><SelectValue /></SelectTrigger> 168 - <SelectContent> 169 - <SelectItem value="day">{t.analyticsGranularityDay}</SelectItem> 170 - <SelectItem value="month">{t.analyticsGranularityMonth}</SelectItem> 171 - </SelectContent> 172 - </Select> 173 - <Select value={compareMode} onValueChange={(value) => setCompareMode(value as "week" | "month")}> 174 - <SelectTrigger className="w-28"><SelectValue /></SelectTrigger> 175 - <SelectContent> 176 - <SelectItem value="week">{t.analyticsCompareWeek}</SelectItem> 177 - <SelectItem value="month">{t.analyticsCompareMonth}</SelectItem> 178 - </SelectContent> 179 - </Select> 164 + <div className="w-full rounded-lg border p-4 space-y-6"> 165 + <div> 166 + <div className="flex items-center justify-between gap-3"> 167 + <div> 168 + <h2 className="text-base font-semibold">{t.timeAnalytics}</h2> 169 + <p className="text-sm text-muted-foreground"> 170 + {t.timeAnalyticsDesc || "Analyze how you spend your time"} 171 + </p> 180 172 </div> 181 - </CardHeader> 182 - </Card> 183 - 184 - <div className="grid gap-4 lg:grid-cols-2"> 185 - <Card> 186 - <CardHeader><CardTitle>{t.analyticsCardDailyMonthly}</CardTitle></CardHeader> 187 - <CardContent className="h-72"> 188 - <ChartContainer config={chartConfig}> 189 - <BarChart data={analytics.dailyOrMonthlyCounts}> 190 - <CartesianGrid vertical={false} /> 191 - <XAxis dataKey="label" interval="preserveStartEnd" minTickGap={20} /> 192 - <YAxis allowDecimals={false} /> 193 - <ChartTooltip content={<ChartTooltipContent />} /> 194 - <Bar dataKey="count" fill="var(--color-count)" radius={[6, 6, 0, 0]} /> 195 - </BarChart> 196 - </ChartContainer> 197 - </CardContent> 198 - </Card> 199 - 200 - <Card> 201 - <CardHeader><CardTitle>{t.analyticsCardHeatmap}</CardTitle></CardHeader> 202 - <CardContent> 203 - <div className="overflow-x-auto"> 204 - <div className="grid grid-rows-7 grid-flow-col gap-1 min-w-max"> 205 - {analytics.yearlyHeatmap.map((cell) => { 206 - const intensity = maxHeat === 0 ? 0 : cell.count / maxHeat; 207 - const bg = intensity === 0 ? "#e5e7eb" : `rgba(79,70,229,${0.2 + intensity * 0.75})`; 208 - return ( 209 - <div 210 - key={cell.date} 211 - title={`${cell.date} · ${cell.count} ${t.analyticsItemsUnit}`} 212 - className="h-3 w-3 rounded-[2px]" 213 - style={{ backgroundColor: bg }} 214 - /> 215 - ); 216 - })} 217 - </div> 173 + <Select 174 + value={timeRange} 175 + onValueChange={(value: "week" | "month" | "year") => 176 + setTimeRange(value) 177 + } 178 + > 179 + <SelectTrigger className="w-[140px]"> 180 + <SelectValue /> 181 + </SelectTrigger> 182 + <SelectContent> 183 + <SelectItem value="week">{t.thisWeek || "This Week"}</SelectItem> 184 + <SelectItem value="month"> 185 + {t.thisMonth || "This Month"} 186 + </SelectItem> 187 + <SelectItem value="year">{t.thisYear || "This Year"}</SelectItem> 188 + </SelectContent> 189 + </Select> 190 + </div> 191 + </div> 192 + <div> 193 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 194 + <div> 195 + <h3 className="text-lg font-medium mb-2">{t.timeDistribution}</h3> 196 + <div className="h-[300px]"> 197 + <ResponsiveContainer width="100%" height="100%"> 198 + <PieChart> 199 + <Pie 200 + data={pieData} 201 + cx="50%" 202 + cy="50%" 203 + labelLine={true} 204 + label={({ name, value }) => `${name}: ${value}h`} 205 + outerRadius={80} 206 + fill="#8884d8" 207 + dataKey="value" 208 + > 209 + {pieData.map((entry, index) => ( 210 + <Cell 211 + key={`cell-${index}`} 212 + fill={colorMap[entry.color] || "#6b7280"} 213 + /> 214 + ))} 215 + </Pie> 216 + <Tooltip formatter={(value) => [`${value} ${t.hours}`, ""]} /> 217 + </PieChart> 218 + </ResponsiveContainer> 218 219 </div> 219 - <div className="mt-3 flex justify-end items-center gap-2 text-xs text-muted-foreground"> 220 - <span>{t.analyticsHeatmapLow}</span> 221 - {[0.2, 0.4, 0.6, 0.8, 1].map((v) => ( 222 - <span key={v} className="h-3 w-3 rounded-[2px]" style={{ backgroundColor: `rgba(79,70,229,${v})` }} /> 223 - ))} 224 - <span>{t.analyticsHeatmapHigh}</span> 225 - </div> 226 - </CardContent> 227 - </Card> 228 - 229 - <Card> 230 - <CardHeader><CardTitle>{t.analyticsCardCategoryShare}</CardTitle></CardHeader> 231 - <CardContent className="h-72"> 232 - <ChartContainer config={chartConfig}> 233 - <PieChart> 234 - <ChartTooltip content={<ChartTooltipContent formatter={(value: number | string) => `${value} ${t.analyticsItemsUnit}`} />} /> 235 - <Pie 236 - data={analytics.categoryShare} 237 - dataKey="count" 238 - nameKey="label" 239 - innerRadius={55} 240 - outerRadius={95} 241 - paddingAngle={3} 220 + </div> 221 + <div> 222 + <h3 className="text-lg font-medium mb-2">{t.categoryTime}</h3> 223 + <div className="h-[300px]"> 224 + <ResponsiveContainer width="100%" height="100%"> 225 + <BarChart 226 + data={barData} 227 + layout="vertical" 228 + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} 242 229 > 243 - {analytics.categoryShare.map((item, idx) => ( 244 - <Cell key={item.key} fill={categoryColorMap[item.key] ?? COLOR_POOL[idx % COLOR_POOL.length]} /> 245 - ))} 246 - </Pie> 247 - <ChartLegend content={<ChartLegendContent />} /> 248 - </PieChart> 249 - </ChartContainer> 250 - </CardContent> 251 - </Card> 252 - 253 - <Card> 254 - <CardHeader><CardTitle>{t.analyticsCardRadar}</CardTitle></CardHeader> 255 - <CardContent className="h-72"> 256 - <ChartContainer config={{ radar: { label: t.analyticsTimeRatioLabel, color: "#4f46e5" } }}> 257 - <RadarChart data={analytics.radarDistribution} outerRadius="70%"> 258 - <PolarGrid /> 259 - <PolarAngleAxis dataKey="subject" /> 260 - <ChartTooltip content={<ChartTooltipContent />} /> 261 - <Radar dataKey="value" fill="var(--color-radar)" fillOpacity={0.35} stroke="var(--color-radar)" /> 262 - </RadarChart> 263 - </ChartContainer> 264 - </CardContent> 265 - </Card> 266 - 267 - <Card> 268 - <CardHeader><CardTitle>{t.analyticsCardBusyIdle}</CardTitle></CardHeader> 269 - <CardContent className="space-y-5"> 270 - <div> 271 - <p className="mb-2 text-sm font-medium">{t.analyticsBusyTop5}</p> 272 - <div className="space-y-2"> 273 - {analytics.busySlots.map((item) => ( 274 - <div key={`${item.weekday}-${item.part}`} className="flex items-center gap-2 text-sm"> 275 - <span className="w-24 shrink-0">{`${weekdayNames[item.weekday]}${dayPartNames[item.part]}`}</span> 276 - <div className="h-2 flex-1 rounded bg-muted overflow-hidden"> 277 - <div className="h-full bg-red-500" style={{ width: `${Math.max(8, (item.count / Math.max(analytics.busySlots[0]?.count || 1, 1)) * 100)}%` }} /> 278 - </div> 279 - <span>{item.count}{t.analyticsItemsUnit}</span> 280 - </div> 281 - ))} 282 - </div> 230 + <XAxis type="number" /> 231 + <YAxis dataKey="name" type="category" width={80} /> 232 + <Tooltip formatter={(value) => [`${value} ${t.hours}`, ""]} /> 233 + <Legend /> 234 + <Bar dataKey="hours" name={t.hours}> 235 + {barData.map((entry, index) => ( 236 + <Cell 237 + key={`cell-${index}`} 238 + fill={colorMap[entry.color] || "#6b7280"} 239 + /> 240 + ))} 241 + </Bar> 242 + </BarChart> 243 + </ResponsiveContainer> 283 244 </div> 284 - <div> 285 - <p className="mb-2 text-sm font-medium">{t.analyticsIdleTop5}</p> 286 - <div className="space-y-2"> 287 - {analytics.idleSlots.map((item) => ( 288 - <div key={`${item.weekday}-${item.part}`} className="flex items-center gap-2 text-sm"> 289 - <span className="w-24 shrink-0">{`${weekdayNames[item.weekday]}${dayPartNames[item.part]}`}</span> 290 - <div className="h-2 flex-1 rounded bg-muted overflow-hidden"> 291 - <div className="h-full bg-emerald-500" style={{ width: `${Math.max(8, (item.count / Math.max(analytics.busySlots[0]?.count || 1, 1)) * 100)}%` }} /> 292 - </div> 293 - <span>{item.count}{t.analyticsItemsUnit}</span> 294 - </div> 295 - ))} 296 - </div> 245 + </div> 246 + </div> 247 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6"> 248 + <div className="rounded-lg border p-4"> 249 + <div className="text-center"> 250 + <h3 className="text-lg font-medium">{t.totalEvents}</h3> 251 + <p className="text-3xl font-bold mt-2">{analytics.totalEvents}</p> 297 252 </div> 298 - </CardContent> 299 - </Card> 253 + </div> 254 + <div className="rounded-lg border p-4"> 255 + <div className="text-center"> 256 + <h3 className="text-lg font-medium">{t.mostProductiveDay}</h3> 257 + <p className="text-3xl font-bold mt-2"> 258 + {analytics.mostProductiveDay 259 + ? new Date(analytics.mostProductiveDay).toLocaleDateString() 260 + : t.noData} 261 + </p> 262 + </div> 263 + </div> 264 + <div className="rounded-lg border p-4"> 265 + <div className="text-center"> 266 + <h3 className="text-lg font-medium">{t.mostProductiveHour}</h3> 267 + <p className="text-3xl font-bold mt-2"> 268 + {analytics.mostProductiveHour !== undefined 269 + ? `${analytics.mostProductiveHour}:00` 270 + : t.noData} 271 + </p> 272 + </div> 273 + </div> 274 + </div> 300 275 301 - <Card> 302 - <CardHeader><CardTitle>{t.analyticsCardComparison}</CardTitle></CardHeader> 303 - <CardContent className="space-y-3"> 304 - <div className="grid grid-cols-2 gap-3"> 305 - <div className="rounded-lg border p-3"> 306 - <p className="text-sm text-muted-foreground">{analytics.periodComparison.currentLabel}</p> 307 - <p className="text-3xl font-semibold">{analytics.periodComparison.currentCount}</p> 308 - </div> 309 - <div className="rounded-lg border p-3"> 310 - <p className="text-sm text-muted-foreground">{analytics.periodComparison.previousLabel}</p> 311 - <p className="text-3xl font-semibold">{analytics.periodComparison.previousCount}</p> 312 - </div> 276 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4"> 277 + <div className="rounded-lg border p-4"> 278 + <div className="text-center"> 279 + <h3 className="text-lg font-medium"> 280 + {t.totalHours || "Total Hours"} 281 + </h3> 282 + <p className="text-3xl font-bold mt-2"> 283 + {analytics.totalHours.toFixed(1)}h 284 + </p> 285 + </div> 286 + </div> 287 + <div className="rounded-lg border p-4"> 288 + <div className="text-center"> 289 + <h3 className="text-lg font-medium"> 290 + {t.averageEventDuration || "Average Event Duration"} 291 + </h3> 292 + <p className="text-3xl font-bold mt-2"> 293 + {analytics.averageEventDuration.toFixed(1)}h 294 + </p> 295 + </div> 296 + </div> 297 + <div className="rounded-lg border p-4"> 298 + <div className="text-center"> 299 + <h3 className="text-lg font-medium"> 300 + {t.busiestCategory || "Busiest Category"} 301 + </h3> 302 + <p className="text-2xl font-bold mt-2"> 303 + {busiestCategoryName || t.noData} 304 + </p> 305 + <p className="text-sm text-muted-foreground mt-1"> 306 + {t.activeDays || "Active Days"}: {analytics.activeDays} 307 + </p> 313 308 </div> 314 - <p className={cn("text-sm font-medium", analytics.periodComparison.delta >= 0 ? "text-emerald-600" : "text-red-500")}> 315 - {analytics.periodComparison.delta >= 0 ? t.analyticsTrendUp : t.analyticsTrendDown} 316 - {Math.abs(analytics.periodComparison.delta)} {t.analyticsItemsUnit}({Math.abs(analytics.periodComparison.ratio)}%) 317 - </p> 318 - </CardContent> 319 - </Card> 320 - 321 - <Card> 322 - <CardHeader><CardTitle>{t.analyticsCardAverageDuration}</CardTitle></CardHeader> 323 - <CardContent className="h-72"> 324 - <ChartContainer config={chartConfig}> 325 - <BarChart data={analytics.categoryAvgDuration} layout="vertical" margin={{ left: 10, right: 10 }}> 326 - <CartesianGrid horizontal={false} /> 327 - <XAxis type="number" /> 328 - <YAxis dataKey="category" type="category" width={80} /> 329 - <ChartTooltip content={<ChartTooltipContent formatter={(value: number | string) => `${value} ${t.analyticsMinutesUnit}`} />} /> 330 - <Bar dataKey="avgMinutes" fill="#8b5cf6" radius={4} /> 331 - </BarChart> 332 - </ChartContainer> 333 - </CardContent> 334 - </Card> 335 - 336 - <Card> 337 - <CardHeader><CardTitle>{t.analyticsCardWeeklyStacked}</CardTitle></CardHeader> 338 - <CardContent className="h-72"> 339 - <ChartContainer config={chartConfig}> 340 - <BarChart data={analytics.weeklyStackedDuration.map((row) => ({ ...row, dayLabel: weekdayNames[row.day] }))}> 341 - <CartesianGrid vertical={false} /> 342 - <XAxis dataKey="dayLabel" /> 343 - <YAxis /> 344 - <ChartTooltip content={<ChartTooltipContent formatter={(value: number | string) => `${Number(value).toFixed(1)} ${t.analyticsHoursUnit}`} />} /> 345 - {analytics.categoryShare.map((item, idx) => ( 346 - <Bar 347 - key={item.key} 348 - dataKey={item.key} 349 - stackId="weekly" 350 - fill={categoryColorMap[item.key] ?? COLOR_POOL[idx % COLOR_POOL.length]} 351 - /> 352 - ))} 353 - </BarChart> 354 - </ChartContainer> 355 - </CardContent> 356 - </Card> 309 + </div> 310 + </div> 357 311 </div> 358 312 </div> 359 313 );
-372
components/ui/chart.tsx
··· 1 - "use client" 2 - 3 - import * as React from "react" 4 - import * as RechartsPrimitive from "recharts" 5 - import type { TooltipValueType } from "recharts" 6 - 7 - import { cn } from "@/lib/utils" 8 - 9 - const THEMES = { light: "", dark: ".dark" } as const 10 - 11 - const INITIAL_DIMENSION = { width: 320, height: 200 } as const 12 - type TooltipNameType = number | string 13 - 14 - export type ChartConfig = Record< 15 - string, 16 - { 17 - label?: React.ReactNode 18 - icon?: React.ComponentType 19 - } & ( 20 - | { color?: string; theme?: never } 21 - | { color?: never; theme: Record<keyof typeof THEMES, string> } 22 - ) 23 - > 24 - 25 - type ChartContextProps = { 26 - config: ChartConfig 27 - } 28 - 29 - const ChartContext = React.createContext<ChartContextProps | null>(null) 30 - 31 - function useChart() { 32 - const context = React.useContext(ChartContext) 33 - 34 - if (!context) { 35 - throw new Error("useChart must be used within a <ChartContainer />") 36 - } 37 - 38 - return context 39 - } 40 - 41 - function ChartContainer({ 42 - id, 43 - className, 44 - children, 45 - config, 46 - initialDimension = INITIAL_DIMENSION, 47 - ...props 48 - }: React.ComponentProps<"div"> & { 49 - config: ChartConfig 50 - children: React.ComponentProps< 51 - typeof RechartsPrimitive.ResponsiveContainer 52 - >["children"] 53 - initialDimension?: { 54 - width: number 55 - height: number 56 - } 57 - }) { 58 - const uniqueId = React.useId() 59 - const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` 60 - 61 - return ( 62 - <ChartContext.Provider value={{ config }}> 63 - <div 64 - data-slot="chart" 65 - data-chart={chartId} 66 - className={cn( 67 - "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", 68 - className 69 - )} 70 - {...props} 71 - > 72 - <ChartStyle id={chartId} config={config} /> 73 - <RechartsPrimitive.ResponsiveContainer 74 - initialDimension={initialDimension} 75 - > 76 - {children} 77 - </RechartsPrimitive.ResponsiveContainer> 78 - </div> 79 - </ChartContext.Provider> 80 - ) 81 - } 82 - 83 - const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { 84 - const colorConfig = Object.entries(config).filter( 85 - ([, config]) => config.theme ?? config.color 86 - ) 87 - 88 - if (!colorConfig.length) { 89 - return null 90 - } 91 - 92 - return ( 93 - <style 94 - dangerouslySetInnerHTML={{ 95 - __html: Object.entries(THEMES) 96 - .map( 97 - ([theme, prefix]) => ` 98 - ${prefix} [data-chart=${id}] { 99 - ${colorConfig 100 - .map(([key, itemConfig]) => { 101 - const color = 102 - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? 103 - itemConfig.color 104 - return color ? ` --color-${key}: ${color};` : null 105 - }) 106 - .join("\n")} 107 - } 108 - ` 109 - ) 110 - .join("\n"), 111 - }} 112 - /> 113 - ) 114 - } 115 - 116 - const ChartTooltip = RechartsPrimitive.Tooltip 117 - 118 - function ChartTooltipContent({ 119 - active, 120 - payload, 121 - className, 122 - indicator = "dot", 123 - hideLabel = false, 124 - hideIndicator = false, 125 - label, 126 - labelFormatter, 127 - labelClassName, 128 - formatter, 129 - color, 130 - nameKey, 131 - labelKey, 132 - }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & 133 - React.ComponentProps<"div"> & { 134 - hideLabel?: boolean 135 - hideIndicator?: boolean 136 - indicator?: "line" | "dot" | "dashed" 137 - nameKey?: string 138 - labelKey?: string 139 - } & Omit< 140 - RechartsPrimitive.DefaultTooltipContentProps< 141 - TooltipValueType, 142 - TooltipNameType 143 - >, 144 - "accessibilityLayer" 145 - >) { 146 - const { config } = useChart() 147 - 148 - const tooltipLabel = React.useMemo(() => { 149 - if (hideLabel || !payload?.length) { 150 - return null 151 - } 152 - 153 - const [item] = payload 154 - const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}` 155 - const itemConfig = getPayloadConfigFromPayload(config, item, key) 156 - const value = 157 - !labelKey && typeof label === "string" 158 - ? (config[label]?.label ?? label) 159 - : itemConfig?.label 160 - 161 - if (labelFormatter) { 162 - return ( 163 - <div className={cn("font-medium", labelClassName)}> 164 - {labelFormatter(value, payload)} 165 - </div> 166 - ) 167 - } 168 - 169 - if (!value) { 170 - return null 171 - } 172 - 173 - return <div className={cn("font-medium", labelClassName)}>{value}</div> 174 - }, [ 175 - label, 176 - labelFormatter, 177 - payload, 178 - hideLabel, 179 - labelClassName, 180 - config, 181 - labelKey, 182 - ]) 183 - 184 - if (!active || !payload?.length) { 185 - return null 186 - } 187 - 188 - const nestLabel = payload.length === 1 && indicator !== "dot" 189 - 190 - return ( 191 - <div 192 - className={cn( 193 - "grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", 194 - className 195 - )} 196 - > 197 - {!nestLabel ? tooltipLabel : null} 198 - <div className="grid gap-1.5"> 199 - {payload 200 - .filter((item) => item.type !== "none") 201 - .map((item, index) => { 202 - const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}` 203 - const itemConfig = getPayloadConfigFromPayload(config, item, key) 204 - const indicatorColor = color ?? item.payload?.fill ?? item.color 205 - 206 - return ( 207 - <div 208 - key={index} 209 - className={cn( 210 - "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", 211 - indicator === "dot" && "items-center" 212 - )} 213 - > 214 - {formatter && item?.value !== undefined && item.name ? ( 215 - formatter(item.value, item.name, item, index, item.payload) 216 - ) : ( 217 - <> 218 - {itemConfig?.icon ? ( 219 - <itemConfig.icon /> 220 - ) : ( 221 - !hideIndicator && ( 222 - <div 223 - className={cn( 224 - "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", 225 - { 226 - "h-2.5 w-2.5": indicator === "dot", 227 - "w-1": indicator === "line", 228 - "w-0 border-[1.5px] border-dashed bg-transparent": 229 - indicator === "dashed", 230 - "my-0.5": nestLabel && indicator === "dashed", 231 - } 232 - )} 233 - style={ 234 - { 235 - "--color-bg": indicatorColor, 236 - "--color-border": indicatorColor, 237 - } as React.CSSProperties 238 - } 239 - /> 240 - ) 241 - )} 242 - <div 243 - className={cn( 244 - "flex flex-1 justify-between leading-none", 245 - nestLabel ? "items-end" : "items-center" 246 - )} 247 - > 248 - <div className="grid gap-1.5"> 249 - {nestLabel ? tooltipLabel : null} 250 - <span className="text-muted-foreground"> 251 - {itemConfig?.label ?? item.name} 252 - </span> 253 - </div> 254 - {item.value != null && ( 255 - <span className="font-mono font-medium text-foreground tabular-nums"> 256 - {typeof item.value === "number" 257 - ? item.value.toLocaleString() 258 - : String(item.value)} 259 - </span> 260 - )} 261 - </div> 262 - </> 263 - )} 264 - </div> 265 - ) 266 - })} 267 - </div> 268 - </div> 269 - ) 270 - } 271 - 272 - const ChartLegend = RechartsPrimitive.Legend 273 - 274 - function ChartLegendContent({ 275 - className, 276 - hideIcon = false, 277 - payload, 278 - verticalAlign = "bottom", 279 - nameKey, 280 - }: React.ComponentProps<"div"> & { 281 - hideIcon?: boolean 282 - nameKey?: string 283 - } & RechartsPrimitive.DefaultLegendContentProps) { 284 - const { config } = useChart() 285 - 286 - if (!payload?.length) { 287 - return null 288 - } 289 - 290 - return ( 291 - <div 292 - className={cn( 293 - "flex items-center justify-center gap-4", 294 - verticalAlign === "top" ? "pb-3" : "pt-3", 295 - className 296 - )} 297 - > 298 - {payload 299 - .filter((item) => item.type !== "none") 300 - .map((item, index) => { 301 - const key = `${nameKey ?? item.dataKey ?? "value"}` 302 - const itemConfig = getPayloadConfigFromPayload(config, item, key) 303 - 304 - return ( 305 - <div 306 - key={index} 307 - className={cn( 308 - "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" 309 - )} 310 - > 311 - {itemConfig?.icon && !hideIcon ? ( 312 - <itemConfig.icon /> 313 - ) : ( 314 - <div 315 - className="h-2 w-2 shrink-0 rounded-[2px]" 316 - style={{ 317 - backgroundColor: item.color, 318 - }} 319 - /> 320 - )} 321 - {itemConfig?.label} 322 - </div> 323 - ) 324 - })} 325 - </div> 326 - ) 327 - } 328 - 329 - function getPayloadConfigFromPayload( 330 - config: ChartConfig, 331 - payload: unknown, 332 - key: string 333 - ) { 334 - if (typeof payload !== "object" || payload === null) { 335 - return undefined 336 - } 337 - 338 - const payloadPayload = 339 - "payload" in payload && 340 - typeof payload.payload === "object" && 341 - payload.payload !== null 342 - ? payload.payload 343 - : undefined 344 - 345 - let configLabelKey: string = key 346 - 347 - if ( 348 - key in payload && 349 - typeof payload[key as keyof typeof payload] === "string" 350 - ) { 351 - configLabelKey = payload[key as keyof typeof payload] as string 352 - } else if ( 353 - payloadPayload && 354 - key in payloadPayload && 355 - typeof payloadPayload[key as keyof typeof payloadPayload] === "string" 356 - ) { 357 - configLabelKey = payloadPayload[ 358 - key as keyof typeof payloadPayload 359 - ] as string 360 - } 361 - 362 - return configLabelKey in config ? config[configLabelKey] : config[key] 363 - } 364 - 365 - export { 366 - ChartContainer, 367 - ChartTooltip, 368 - ChartTooltipContent, 369 - ChartLegend, 370 - ChartLegendContent, 371 - ChartStyle, 372 - }
+112 -294
lib/time-analytics.ts
··· 1 - import { 2 - addDays, 3 - addMonths, 4 - differenceInCalendarDays, 5 - eachDayOfInterval, 6 - endOfDay, 7 - endOfMonth, 8 - endOfWeek, 9 - endOfYear, 10 - format, 11 - isWithinInterval, 12 - startOfDay, 13 - startOfMonth, 14 - startOfWeek, 15 - startOfYear, 16 - subMonths, 17 - subWeeks, 18 - } from "date-fns"; 19 - 20 1 export interface TimeCategory { 21 2 id: string; 22 3 name: string; 23 4 color: string; 24 - keywords?: string[]; 5 + keywords: string[]; 25 6 } 26 7 27 - export interface CalendarEventLike { 28 - id: string; 29 - title: string; 30 - startDate: Date | string; 31 - endDate: Date | string; 32 - isAllDay?: boolean; 33 - description?: string; 34 - calendarId?: string; 35 - } 36 - 37 - export interface AnalyticsRangeOption { 38 - key: "7d" | "30d" | "90d" | "1y"; 39 - label: string; 40 - start: Date; 41 - end: Date; 42 - } 43 - 44 - export type DayPartKey = "allDay" | "morning" | "afternoon" | "evening" | "lateNight"; 45 - 46 - export interface ScheduleAnalytics { 47 - dailyOrMonthlyCounts: Array<{ label: string; count: number }>; 48 - yearlyHeatmap: Array<{ date: string; week: number; day: number; count: number }>; 49 - categoryShare: Array<{ key: string; label: string; count: number; hours: number }>; 50 - radarDistribution: Array<{ subject: string; value: number }>; 51 - busySlots: Array<{ weekday: number; part: DayPartKey; count: number }>; 52 - idleSlots: Array<{ weekday: number; part: DayPartKey; count: number }>; 53 - periodComparison: { 54 - mode: "week" | "month"; 55 - currentLabel: string; 56 - previousLabel: string; 57 - currentCount: number; 58 - previousCount: number; 59 - delta: number; 60 - ratio: number; 8 + export interface TimeAnalytics { 9 + totalEvents: number; 10 + totalHours: number; 11 + categorizedHours: Record<string, number>; 12 + mostProductiveDay: string; 13 + mostProductiveHour: number; 14 + averageEventDuration: number; 15 + activeDays: number; 16 + busiestCategoryId: string; 17 + longestEvent: { 18 + title: string; 19 + duration: number; 61 20 }; 62 - categoryAvgDuration: Array<{ category: string; avgMinutes: number }>; 63 - weeklyStackedDuration: Array<Record<string, number | string> & { day: number }>; 64 21 } 65 22 66 - interface NormalizedEvent { 67 - title: string; 68 - description: string; 69 - start: Date; 70 - end: Date; 71 - calendarId?: string; 72 - isAllDay: boolean; 73 - } 23 + export function analyzeTimeUsage( 24 + events: any[], 25 + categories: TimeCategory[] = [], 26 + ): TimeAnalytics { 27 + const result: TimeAnalytics = { 28 + totalEvents: events.length, 29 + totalHours: 0, 30 + categorizedHours: {}, 31 + mostProductiveDay: "", 32 + mostProductiveHour: 0, 33 + averageEventDuration: 0, 34 + activeDays: 0, 35 + busiestCategoryId: "uncategorized", 36 + longestEvent: { 37 + title: "", 38 + duration: 0, 39 + }, 40 + }; 74 41 75 - const DAY_PARTS: Array<{ key: DayPartKey; from: number; to: number }> = [ 76 - { key: "allDay", from: -1, to: -1 }, 77 - { key: "morning", from: 5, to: 12 }, 78 - { key: "afternoon", from: 12, to: 18 }, 79 - { key: "evening", from: 18, to: 24 }, 80 - { key: "lateNight", from: 0, to: 5 }, 81 - ]; 82 - 83 - function normalizeEvents(events: CalendarEventLike[]): NormalizedEvent[] { 84 - const normalized: NormalizedEvent[] = []; 85 - 86 - events.forEach((event) => { 87 - const start = new Date(event.startDate); 88 - const end = new Date(event.endDate); 89 - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { 90 - return; 91 - } 92 - 93 - normalized.push({ 94 - title: event.title ?? "", 95 - description: event.description ?? "", 96 - start, 97 - end: end > start ? end : addDays(start, 1), 98 - calendarId: event.calendarId, 99 - isAllDay: Boolean(event.isAllDay), 100 - }); 42 + categories.forEach((category) => { 43 + result.categorizedHours[category.id] = 0; 101 44 }); 45 + result.categorizedHours["uncategorized"] = 0; 102 46 103 - return normalized; 104 - } 105 - 106 - function getEventDurationMinutes(event: NormalizedEvent): number { 107 - if (event.isAllDay) { 108 - const days = Math.max(1, differenceInCalendarDays(event.end, event.start)); 109 - return days * 24 * 60; 47 + const eventsByDay: Record<string, number> = {}; 48 + const eventsByHour: Record<number, number> = {}; 49 + for (let i = 0; i < 24; i++) { 50 + eventsByHour[i] = 0; 110 51 } 111 - return Math.max(1, Math.round((event.end.getTime() - event.start.getTime()) / 60000)); 112 - } 113 52 114 - function resolveCategory(event: NormalizedEvent, categories: TimeCategory[]): TimeCategory | null { 115 - if (event.calendarId) { 116 - const matched = categories.find((c) => c.id === event.calendarId); 117 - if (matched) return matched; 118 - } 53 + events.forEach((event) => { 54 + const startDate = new Date(event.startDate); 55 + const endDate = new Date(event.endDate); 56 + const durationMs = endDate.getTime() - startDate.getTime(); 57 + const normalizedDurationMs = event.isAllDay 58 + ? Math.max(1, Math.ceil(durationMs / (1000 * 60 * 60 * 24))) * 59 + 24 * 60 + 60 * 61 + 60 * 62 + 1000 63 + : durationMs; 64 + const durationHours = normalizedDurationMs / (1000 * 60 * 60); 119 65 120 - const target = `${event.title} ${event.description}`.toLowerCase(); 121 - return ( 122 - categories.find((c) => 123 - (c.keywords ?? []).some((keyword) => keyword && target.includes(keyword.toLowerCase())), 124 - ) ?? null 125 - ); 126 - } 127 - 128 - function dayIndexMonday(date: Date): number { 129 - return (date.getDay() + 6) % 7; 130 - } 131 - 132 - export function buildAnalyticsRange( 133 - key: AnalyticsRangeOption["key"], 134 - labels?: Partial<Record<AnalyticsRangeOption["key"], string>>, 135 - ): AnalyticsRangeOption { 136 - const today = new Date(); 137 - const end = endOfDay(today); 138 - 139 - const resolved = { 140 - "7d": labels?.["7d"] ?? "Last 7 days", 141 - "30d": labels?.["30d"] ?? "Last 30 days", 142 - "90d": labels?.["90d"] ?? "Last 90 days", 143 - "1y": labels?.["1y"] ?? "Last 1 year", 144 - }; 66 + result.totalHours += durationHours; 145 67 146 - if (key === "7d") { 147 - return { key, label: resolved["7d"], start: startOfDay(addDays(today, -6)), end }; 148 - } 149 - if (key === "30d") { 150 - return { key, label: resolved["30d"], start: startOfDay(addDays(today, -29)), end }; 151 - } 152 - if (key === "90d") { 153 - return { key, label: resolved["90d"], start: startOfDay(addDays(today, -89)), end }; 154 - } 155 - 156 - return { key, label: resolved["1y"], start: startOfDay(addDays(today, -364)), end }; 157 - } 158 - 159 - export function generateScheduleAnalytics(params: { 160 - events: CalendarEventLike[]; 161 - categories: TimeCategory[]; 162 - range: AnalyticsRangeOption; 163 - granularity: "day" | "month"; 164 - compareMode: "week" | "month"; 165 - labels?: { 166 - uncategorized?: string; 167 - currentWeek?: string; 168 - previousWeek?: string; 169 - currentMonth?: string; 170 - previousMonth?: string; 171 - }; 172 - }): ScheduleAnalytics { 173 - const { events, categories, range, granularity, compareMode, labels } = params; 174 - const normalized = normalizeEvents(events); 175 - const inRange = normalized.filter((event) => 176 - isWithinInterval(event.start, { start: range.start, end: range.end }), 177 - ); 68 + if (durationHours > result.longestEvent.duration) { 69 + result.longestEvent = { 70 + title: event.title, 71 + duration: durationHours, 72 + }; 73 + } 178 74 179 - const dailyOrMonthlyCounts = 180 - granularity === "day" 181 - ? eachDayOfInterval({ start: range.start, end: range.end }).map((day) => ({ 182 - label: format(day, "MM/dd"), 183 - count: inRange.filter((event) => format(event.start, "yyyy-MM-dd") === format(day, "yyyy-MM-dd")) 184 - .length, 185 - })) 186 - : (() => { 187 - const result: Array<{ label: string; count: number }> = []; 188 - let cursor = startOfMonth(range.start); 189 - const limit = endOfMonth(range.end); 190 - while (cursor <= limit) { 191 - result.push({ 192 - label: format(cursor, "yyyy-MM"), 193 - count: inRange.filter((event) => format(event.start, "yyyy-MM") === format(cursor, "yyyy-MM")) 194 - .length, 195 - }); 196 - cursor = addMonths(cursor, 1); 197 - } 198 - return result; 199 - })(); 75 + const dayKey = startDate.toISOString().split("T")[0]; 76 + eventsByDay[dayKey] = (eventsByDay[dayKey] || 0) + durationHours; 200 77 201 - const yearStart = startOfWeek(startOfYear(new Date()), { weekStartsOn: 1 }); 202 - const yearEnd = endOfWeek(endOfYear(new Date()), { weekStartsOn: 1 }); 203 - const yearlyHeatmap = eachDayOfInterval({ start: yearStart, end: yearEnd }).map((day) => { 204 - const week = Math.floor(differenceInCalendarDays(day, yearStart) / 7); 205 - return { 206 - date: format(day, "yyyy-MM-dd"), 207 - week, 208 - day: dayIndexMonday(day), 209 - count: normalized.filter((event) => format(event.start, "yyyy-MM-dd") === format(day, "yyyy-MM-dd")) 210 - .length, 211 - }; 212 - }); 78 + const hour = startDate.getHours(); 79 + eventsByHour[hour] = (eventsByHour[hour] || 0) + 1; 213 80 214 - const uncategorizedLabel = labels?.uncategorized ?? "Uncategorized"; 215 - const categoryMap = new Map<string, { key: string; label: string; count: number; hours: number }>(); 216 - inRange.forEach((event) => { 217 - const category = resolveCategory(event, categories); 218 - const key = category?.id ?? "uncategorized"; 219 - const label = category?.name ?? uncategorizedLabel; 220 - const item = categoryMap.get(key) ?? { key, label, count: 0, hours: 0 }; 221 - item.count += 1; 222 - item.hours += getEventDurationMinutes(event) / 60; 223 - categoryMap.set(key, item); 224 - }); 225 - const categoryShare = Array.from(categoryMap.values()).sort((a, b) => b.count - a.count); 81 + if ( 82 + event.calendarId && 83 + categories.some((cat) => cat.id === event.calendarId) 84 + ) { 85 + result.categorizedHours[event.calendarId] += durationHours; 86 + return; 87 + } 226 88 227 - const totalHours = categoryShare.reduce((sum, item) => sum + item.hours, 0); 228 - const radarDistribution = categoryShare.map((item) => ({ 229 - subject: item.label, 230 - value: totalHours > 0 ? Number(((item.hours / totalHours) * 100).toFixed(1)) : 0, 231 - })); 89 + let categorized = false; 90 + for (const category of categories) { 91 + const matchesKeyword = category.keywords.some( 92 + (keyword) => 93 + event.title.toLowerCase().includes(keyword.toLowerCase()) || 94 + (event.description && 95 + event.description.toLowerCase().includes(keyword.toLowerCase())), 96 + ); 232 97 233 - const slots = Array.from({ length: 7 }).flatMap((_, idx) => 234 - DAY_PARTS.map((part) => ({ key: `${idx}-${part.key}`, weekday: idx, part: part.key, count: 0 })), 235 - ); 98 + if (matchesKeyword) { 99 + result.categorizedHours[category.id] += durationHours; 100 + categorized = true; 101 + break; 102 + } 103 + } 236 104 237 - inRange.forEach((event) => { 238 - const weekday = dayIndexMonday(event.start); 239 - const hour = event.start.getHours(); 240 - const matchedPart: DayPartKey = event.isAllDay 241 - ? "allDay" 242 - : DAY_PARTS.find((part) => part.from >= 0 && hour >= part.from && hour < part.to)?.key ?? "lateNight"; 243 - const key = `${weekday}-${matchedPart}`; 244 - const target = slots.find((item) => item.key === key); 245 - if (target) target.count += 1; 105 + if (!categorized) { 106 + result.categorizedHours["uncategorized"] += durationHours; 107 + } 246 108 }); 247 109 248 - const sortedBusy = [...slots].sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); 249 - const busySlots = sortedBusy.slice(0, 5).map(({ weekday, part, count }) => ({ weekday, part, count })); 250 - const idleSlots = [...slots] 251 - .sort((a, b) => a.count - b.count || a.key.localeCompare(b.key)) 252 - .slice(0, 5) 253 - .map(({ weekday, part, count }) => ({ weekday, part, count })); 110 + let maxHours = 0; 111 + for (const [day, hours] of Object.entries(eventsByDay)) { 112 + if (hours > maxHours) { 113 + maxHours = hours; 114 + result.mostProductiveDay = day; 115 + } 116 + } 254 117 255 - const now = new Date(); 256 - const currentInterval = 257 - compareMode === "week" 258 - ? { start: startOfWeek(now, { weekStartsOn: 1 }), end: endOfDay(now), label: labels?.currentWeek ?? "This week" } 259 - : { start: startOfMonth(now), end: endOfDay(now), label: labels?.currentMonth ?? "This month" }; 260 - const previousInterval = 261 - compareMode === "week" 262 - ? { 263 - start: startOfWeek(subWeeks(now, 1), { weekStartsOn: 1 }), 264 - end: endOfWeek(subWeeks(now, 1), { weekStartsOn: 1 }), 265 - label: labels?.previousWeek ?? "Last week", 266 - } 267 - : { 268 - start: startOfMonth(subMonths(now, 1)), 269 - end: endOfMonth(subMonths(now, 1)), 270 - label: labels?.previousMonth ?? "Last month", 271 - }; 118 + let maxEvents = 0; 119 + for (const [hour, count] of Object.entries(eventsByHour)) { 120 + if (count > maxEvents) { 121 + maxEvents = count; 122 + result.mostProductiveHour = Number.parseInt(hour); 123 + } 124 + } 272 125 273 - const currentCount = normalized.filter((event) => 274 - isWithinInterval(event.start, { start: currentInterval.start, end: currentInterval.end }), 275 - ).length; 276 - const previousCount = normalized.filter((event) => 277 - isWithinInterval(event.start, { start: previousInterval.start, end: previousInterval.end }), 278 - ).length; 279 - const delta = currentCount - previousCount; 280 - const ratio = previousCount === 0 ? (currentCount > 0 ? 100 : 0) : Number(((delta / previousCount) * 100).toFixed(1)); 126 + result.activeDays = Object.keys(eventsByDay).length; 127 + result.averageEventDuration = 128 + result.totalEvents > 0 ? result.totalHours / result.totalEvents : 0; 281 129 282 - const categoryAvgDuration = categoryShare 283 - .map((item) => ({ category: item.label, avgMinutes: item.count ? Number(((item.hours * 60) / item.count).toFixed(1)) : 0 })) 284 - .sort((a, b) => b.avgMinutes - a.avgMinutes); 285 - 286 - const categoryKeys = categoryShare.map((item) => item.key); 287 - const weeklyStackedDuration = Array.from({ length: 7 }).map((_, dayIndex) => { 288 - const row: Record<string, number | string> & { day: number } = { day: dayIndex }; 289 - categoryKeys.forEach((key) => { 290 - row[key] = 0; 291 - }); 292 - 293 - inRange 294 - .filter((event) => dayIndexMonday(event.start) === dayIndex) 295 - .forEach((event) => { 296 - const category = resolveCategory(event, categories); 297 - const key = category?.id ?? "uncategorized"; 298 - row[key] = Number(row[key] ?? 0) + getEventDurationMinutes(event) / 60; 299 - }); 300 - 301 - return row; 302 - }); 130 + let busiestCategoryId = "uncategorized"; 131 + let busiestHours = -1; 132 + for (const [categoryId, hours] of Object.entries(result.categorizedHours)) { 133 + if (hours > busiestHours) { 134 + busiestHours = hours; 135 + busiestCategoryId = categoryId; 136 + } 137 + } 138 + result.busiestCategoryId = busiestCategoryId; 303 139 304 - return { 305 - dailyOrMonthlyCounts, 306 - yearlyHeatmap, 307 - categoryShare, 308 - radarDistribution, 309 - busySlots, 310 - idleSlots, 311 - periodComparison: { 312 - mode: compareMode, 313 - currentLabel: currentInterval.label, 314 - previousLabel: previousInterval.label, 315 - currentCount, 316 - previousCount, 317 - delta, 318 - ratio, 319 - }, 320 - categoryAvgDuration, 321 - weeklyStackedDuration, 322 - }; 140 + return result; 323 141 }
+1 -43
locales/en.json
··· 503 503 "status": "Status", 504 504 "feedback": "Feedback", 505 505 "privacy": "Privacy", 506 - "tos": "ToS", 507 - "analyticsV2Title": "Schedule Analytics", 508 - "analyticsRange7d": "Last 7 days", 509 - "analyticsRange30d": "Last 30 days", 510 - "analyticsRange90d": "Last 90 days", 511 - "analyticsRange1y": "Last 1 year", 512 - "analyticsGranularityDay": "By day", 513 - "analyticsGranularityMonth": "By month", 514 - "analyticsCompareWeek": "Week comparison", 515 - "analyticsCompareMonth": "Month comparison", 516 - "analyticsPreviousWeek": "Last week", 517 - "analyticsPreviousMonth": "Last month", 518 - "analyticsCardDailyMonthly": "1. Daily / Monthly event count", 519 - "analyticsCardHeatmap": "2. Yearly event heatmap", 520 - "analyticsCardCategoryShare": "3. Category share (donut)", 521 - "analyticsCardRadar": "4. Time allocation radar", 522 - "analyticsCardBusyIdle": "5. Busiest / idlest timeslots", 523 - "analyticsCardComparison": "6. Period-over-period comparison", 524 - "analyticsCardAverageDuration": "7. Average duration by category", 525 - "analyticsCardWeeklyStacked": "8. Weekly stacked time distribution", 526 - "analyticsHeatmapLow": "Less", 527 - "analyticsHeatmapHigh": "More", 528 - "analyticsBusyTop5": "Top 5 busiest", 529 - "analyticsIdleTop5": "Top 5 idlest", 530 - "analyticsTrendUp": "Increased by ", 531 - "analyticsTrendDown": "Decreased by ", 532 - "analyticsItemsUnit": "items", 533 - "analyticsHoursUnit": "hours", 534 - "analyticsMinutesUnit": "minutes", 535 - "analyticsEventCountLabel": "Event count", 536 - "analyticsTimeRatioLabel": "Time ratio", 537 - "analyticsWeekdayMon": "Mon", 538 - "analyticsWeekdayTue": "Tue", 539 - "analyticsWeekdayWed": "Wed", 540 - "analyticsWeekdayThu": "Thu", 541 - "analyticsWeekdayFri": "Fri", 542 - "analyticsWeekdaySat": "Sat", 543 - "analyticsWeekdaySun": "Sun", 544 - "analyticsDayPartAllDay": "All day", 545 - "analyticsDayPartMorning": "Morning", 546 - "analyticsDayPartAfternoon": "Afternoon", 547 - "analyticsDayPartEvening": "Evening", 548 - "analyticsDayPartLateNight": "Late night" 506 + "tos": "ToS" 549 507 }
+1 -43
locales/zh-CN.json
··· 503 503 "status": "状态", 504 504 "feedback": "反馈", 505 505 "privacy": "隐私", 506 - "tos": "服务条款", 507 - "analyticsV2Title": "日程分析", 508 - "analyticsRange7d": "近7天", 509 - "analyticsRange30d": "近30天", 510 - "analyticsRange90d": "近90天", 511 - "analyticsRange1y": "近1年", 512 - "analyticsGranularityDay": "按天", 513 - "analyticsGranularityMonth": "按月", 514 - "analyticsCompareWeek": "周对比", 515 - "analyticsCompareMonth": "月对比", 516 - "analyticsPreviousWeek": "上周", 517 - "analyticsPreviousMonth": "上月", 518 - "analyticsCardDailyMonthly": "1. 每天 / 每月日程数量", 519 - "analyticsCardHeatmap": "2. 全年日程热力图", 520 - "analyticsCardCategoryShare": "3. 日程分类占比(环形图)", 521 - "analyticsCardRadar": "4. 时间分配维度雷达", 522 - "analyticsCardBusyIdle": "5. 最忙 / 最空时段排行", 523 - "analyticsCardComparison": "6. 同期日程对比", 524 - "analyticsCardAverageDuration": "7. 各分类日程平均时长", 525 - "analyticsCardWeeklyStacked": "8. 一周各天时间分布堆叠图", 526 - "analyticsHeatmapLow": "少", 527 - "analyticsHeatmapHigh": "多", 528 - "analyticsBusyTop5": "最忙 Top 5", 529 - "analyticsIdleTop5": "最空 Top 5", 530 - "analyticsTrendUp": "增加", 531 - "analyticsTrendDown": "减少", 532 - "analyticsItemsUnit": "条", 533 - "analyticsHoursUnit": "小时", 534 - "analyticsMinutesUnit": "分钟", 535 - "analyticsEventCountLabel": "日程数", 536 - "analyticsTimeRatioLabel": "时间占比", 537 - "analyticsWeekdayMon": "周一", 538 - "analyticsWeekdayTue": "周二", 539 - "analyticsWeekdayWed": "周三", 540 - "analyticsWeekdayThu": "周四", 541 - "analyticsWeekdayFri": "周五", 542 - "analyticsWeekdaySat": "周六", 543 - "analyticsWeekdaySun": "周日", 544 - "analyticsDayPartAllDay": "全天", 545 - "analyticsDayPartMorning": "上午", 546 - "analyticsDayPartAfternoon": "下午", 547 - "analyticsDayPartEvening": "晚上", 548 - "analyticsDayPartLateNight": "凌晨" 506 + "tos": "服务条款" 549 507 }
+1 -1
package.json
··· 1 1 { 2 2 "name": "one-calendar", 3 - "version": "2.3.0", 3 + "version": "2.2.13", 4 4 "private": true, 5 5 "packageManager": "bun@1.3.8", 6 6 "scripts": {