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.

Merge pull request #242 from EvanTechDev/codex/refactor-schedule-analysis-feature

Add Schedule Analytics v2 with chart primitives and analytics generator

authored by

Evan Huang and committed by
GitHub
555e6734 1f4b51af

+1070 -386
+317 -271
components/app/analytics/time-analytics.tsx
··· 1 1 "use client"; 2 2 3 + import { useMemo, useState } from "react"; 3 4 import { 4 - PieChart, 5 - Pie, 6 - Cell, 7 - ResponsiveContainer, 5 + Bar, 8 6 BarChart, 9 - Bar, 7 + CartesianGrid, 8 + PolarAngleAxis, 9 + PolarGrid, 10 + Radar, 11 + RadarChart, 10 12 XAxis, 11 13 YAxis, 12 - Tooltip, 13 - Legend, 14 + Pie, 15 + PieChart, 16 + Cell, 14 17 } from "recharts"; 15 18 import { 16 - Select, 17 - SelectContent, 18 - SelectItem, 19 - SelectTrigger, 20 - SelectValue, 21 - } from "@/components/ui/select"; 22 - import { analyzeTimeUsage, type TimeAnalytics } from "@/lib/time-analytics"; 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"; 23 32 import type { CalendarCategory } from "../sidebar/sidebar"; 24 - import { useLocalStorage } from "@/hooks/useLocalStorage"; 25 - import { translations, useLanguage } from "@/lib/i18n"; 26 33 import type { CalendarEvent } from "../calendar"; 27 - import { useState, useEffect } from "react"; 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"; 37 + import { translations, useLanguage } from "@/lib/i18n"; 28 38 29 39 interface TimeAnalyticsProps { 30 40 events: CalendarEvent[]; 31 41 calendars?: CalendarCategory[]; 32 42 } 33 43 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 - ); 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) { 50 69 const [language] = useLanguage(); 51 70 const t = translations[language]; 52 71 53 - const [forceUpdate, setForceUpdate] = useState(0); 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 + ]; 54 78 55 - useEffect(() => { 56 - const handleLanguageChange = () => { 57 - setForceUpdate((prev) => prev + 1); 58 - }; 59 - 60 - window.addEventListener("languagechange", handleLanguageChange); 61 - return () => { 62 - window.removeEventListener("languagechange", handleLanguageChange); 63 - }; 64 - }, []); 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 + ]; 65 88 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 - }); 90 - 91 - const result = analyzeTimeUsage(filteredEvents, timeCategories); 92 - setAnalytics(result); 93 - }, [events, timeCategories, timeRange, language, forceUpdate]); 94 - 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 - } 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, 110 95 }; 111 96 112 - const handleRemoveCategory = (id: string) => { 113 - setTimeCategories(timeCategories.filter((cat) => cat.id !== id)); 114 - }; 97 + const [rangeKey, setRangeKey] = useState<AnalyticsRangeOption["key"]>("30d"); 98 + const [granularity, setGranularity] = useState<"day" | "month">("day"); 99 + const [compareMode, setCompareMode] = useState<"week" | "month">("week"); 115 100 116 - if (!analytics) { 117 - return <div>{t.loading}</div>; 118 - } 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 + ); 119 111 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 - }; 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 + ); 130 + 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]; 129 135 }); 136 + return map; 137 + }, [calendars]); 130 138 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", 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], 139 145 }; 140 146 }); 147 + return config; 148 + }, [analytics.categoryShare, categoryColorMap, t.analyticsEventCountLabel]); 141 149 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 - }; 154 - 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 - })(); 150 + const maxHeat = Math.max(...analytics.yearlyHeatmap.map((item) => item.count), 0); 162 151 163 152 return ( 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> 172 - </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> 219 - </div> 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> 220 180 </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 }} 229 - > 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> 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> 244 218 </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> 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> 252 225 </div> 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> 226 + </CardContent> 227 + </Card> 275 228 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> 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} 242 + > 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> 285 283 </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> 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> 295 297 </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> 298 + </CardContent> 299 + </Card> 300 + 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> 308 313 </div> 309 - </div> 310 - </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> 311 357 </div> 312 358 </div> 313 359 );
+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 + }
+294 -112
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 + 1 20 export interface TimeCategory { 2 21 id: string; 3 22 name: string; 4 23 color: string; 5 - keywords: string[]; 24 + keywords?: string[]; 25 + } 26 + 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; 6 42 } 7 43 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; 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; 20 61 }; 62 + categoryAvgDuration: Array<{ category: string; avgMinutes: number }>; 63 + weeklyStackedDuration: Array<Record<string, number | string> & { day: number }>; 21 64 } 22 65 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 - }; 66 + interface NormalizedEvent { 67 + title: string; 68 + description: string; 69 + start: Date; 70 + end: Date; 71 + calendarId?: string; 72 + isAllDay: boolean; 73 + } 74 + 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 + } 41 92 42 - categories.forEach((category) => { 43 - result.categorizedHours[category.id] = 0; 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 + }); 44 101 }); 45 - result.categorizedHours["uncategorized"] = 0; 46 102 47 - const eventsByDay: Record<string, number> = {}; 48 - const eventsByHour: Record<number, number> = {}; 49 - for (let i = 0; i < 24; i++) { 50 - eventsByHour[i] = 0; 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; 51 110 } 111 + return Math.max(1, Math.round((event.end.getTime() - event.start.getTime()) / 60000)); 112 + } 52 113 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); 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 + } 65 119 66 - result.totalHours += durationHours; 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 + } 67 127 68 - if (durationHours > result.longestEvent.duration) { 69 - result.longestEvent = { 70 - title: event.title, 71 - duration: durationHours, 72 - }; 73 - } 128 + function dayIndexMonday(date: Date): number { 129 + return (date.getDay() + 6) % 7; 130 + } 74 131 75 - const dayKey = startDate.toISOString().split("T")[0]; 76 - eventsByDay[dayKey] = (eventsByDay[dayKey] || 0) + durationHours; 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); 77 138 78 - const hour = startDate.getHours(); 79 - eventsByHour[hour] = (eventsByHour[hour] || 0) + 1; 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 + }; 80 145 81 - if ( 82 - event.calendarId && 83 - categories.some((cat) => cat.id === event.calendarId) 84 - ) { 85 - result.categorizedHours[event.calendarId] += durationHours; 86 - return; 87 - } 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 + } 88 155 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 - ); 156 + return { key, label: resolved["1y"], start: startOfDay(addDays(today, -364)), end }; 157 + } 97 158 98 - if (matchesKeyword) { 99 - result.categorizedHours[category.id] += durationHours; 100 - categorized = true; 101 - break; 102 - } 103 - } 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 + ); 104 178 105 - if (!categorized) { 106 - result.categorizedHours["uncategorized"] += durationHours; 107 - } 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 + })(); 200 + 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 + }); 213 + 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); 226 + 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 + })); 232 + 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 + ); 236 + 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; 108 246 }); 109 247 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 - } 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 })); 254 + 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 + }; 272 + 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)); 281 + 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); 117 285 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 - } 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 + }); 125 292 126 - result.activeDays = Object.keys(eventsByDay).length; 127 - result.averageEventDuration = 128 - result.totalEvents > 0 ? result.totalHours / result.totalEvents : 0; 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 + }); 129 300 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; 301 + return row; 302 + }); 139 303 140 - return result; 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 + }; 141 323 }
+43 -1
locales/en.json
··· 503 503 "status": "Status", 504 504 "feedback": "Feedback", 505 505 "privacy": "Privacy", 506 - "tos": "ToS" 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" 507 549 }
+43 -1
locales/zh-CN.json
··· 503 503 "status": "状态", 504 504 "feedback": "反馈", 505 505 "privacy": "隐私", 506 - "tos": "服务条款" 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": "凌晨" 507 549 }
+1 -1
package.json
··· 1 1 { 2 2 "name": "one-calendar", 3 - "version": "2.2.13", 3 + "version": "2.3.0", 4 4 "private": true, 5 5 "packageManager": "bun@1.3.8", 6 6 "scripts": {