Skip to content

Commit 3f2eaef

Browse files
MarkShawn2020claude
andcommitted
feat(home): 持久化 Activity/Command 图表设置
- 添加 Features 按钮到 collapsed layout 导航栏 - 新增 home atoms 支持图表偏好持久化 - Activity 默认显示 Hour 视图 - Command 默认显示 3M 范围和 Cumulative 模式 - 修复 Command 排序只计算选中范围内的数据 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ad5a89c commit 3f2eaef

5 files changed

Lines changed: 227 additions & 45 deletions

File tree

src/components/GlobalHeader/GlobalHeader.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ export function GlobalHeader({
7272
icon={<img src="/logo.svg" alt="Lovcode" className="w-4 h-4" />}
7373
label="Lovcode"
7474
/>
75+
<NavButton
76+
isActive={primaryFeature === "features"}
77+
onClick={() => handleMainNavClick("features")}
78+
icon={<LayersIcon className="w-4 h-4" />}
79+
label="Features"
80+
/>
7581
<NavButton
7682
isActive={primaryFeature === "workspace"}
7783
onClick={() => handleMainNavClick("workspace")}

src/components/home/ActivityHeatmap.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useMemo, useRef, useEffect, useState } from "react";
2-
3-
type ViewMode = "weekday" | "hour";
1+
import { useMemo, useRef, useEffect } from "react";
2+
import { useAtom } from "jotai";
3+
import { activityViewModeAtom } from "@/store";
44

55
interface ActivityHeatmapProps {
66
/** Map of date (YYYY-MM-DD) to count */
@@ -10,7 +10,7 @@ interface ActivityHeatmapProps {
1010
}
1111

1212
export function ActivityHeatmap({ daily, detailed }: ActivityHeatmapProps) {
13-
const [mode, setMode] = useState<ViewMode>("weekday");
13+
const [mode, setMode] = useAtom(activityViewModeAtom);
1414
const scrollRef = useRef<HTMLDivElement>(null);
1515

1616
const dailyMap = useMemo(() => new Map(Object.entries(daily)), [daily]);
Lines changed: 209 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useMemo } from "react";
1+
import { useMemo, useState } from "react";
2+
import { useAtom } from "jotai";
23
import {
34
LineChart,
45
Line,
@@ -8,60 +9,125 @@ import {
89
ResponsiveContainer,
910
Legend,
1011
} from "recharts";
12+
import { commandRangeAtom, commandModeAtom } from "@/store";
1113

1214
interface CommandTrendChartProps {
1315
/** Map of command_name -> { week_key -> count } */
1416
data: Record<string, Record<string, number>>;
1517
weeks?: number;
1618
}
1719

18-
// Color palette for commands (using primary variations)
20+
// Color palette for commands
1921
const COLORS = [
20-
"hsl(var(--primary))",
21-
"hsl(var(--primary) / 0.7)",
22-
"hsl(var(--primary) / 0.5)",
23-
"hsl(25, 50%, 55%)",
24-
"hsl(25, 40%, 65%)",
25-
"hsl(25, 30%, 75%)",
26-
"hsl(200, 40%, 55%)",
27-
"hsl(160, 40%, 50%)",
22+
"#CC785C", // primary (陶土色)
23+
"#D4896D",
24+
"#DC9A7E",
25+
"#B86A4E",
26+
"#5B8A72", // 绿
27+
"#4A7B9D", // 蓝
28+
"#8B7355", // 棕
29+
"#7D6B8A", // 紫
2830
];
2931

30-
const MAX_COMMANDS = 8; // Only show top N commands in chart
32+
const MAX_COMMANDS = 8;
33+
34+
interface HoverData {
35+
label: string;
36+
items: { name: string; value: number; color: string }[];
37+
}
38+
39+
// Get ISO week number
40+
function getISOWeek(date: Date): { year: number; week: number } {
41+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
42+
const dayNum = d.getUTCDay() || 7;
43+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
44+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
45+
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
46+
return { year: d.getUTCFullYear(), week: weekNo };
47+
}
48+
49+
type ChartMode = "weekly" | "cumulative";
50+
type TimeRange = "1m" | "3m" | "all";
51+
52+
const RANGE_WEEKS: Record<TimeRange, number | null> = {
53+
"1m": 5, // ~1 month
54+
"3m": 13, // ~3 months
55+
"all": null,
56+
};
57+
58+
export function CommandTrendChart({ data, weeks = 13 }: CommandTrendChartProps) {
59+
const [hoverData, setHoverData] = useState<HoverData | null>(null);
60+
const [mode, setMode] = useAtom(commandModeAtom);
61+
const [range, setRange] = useAtom(commandRangeAtom);
3162

32-
export function CommandTrendChart({ data, weeks = 12 }: CommandTrendChartProps) {
3363
const { chartData, commands, totalCommands } = useMemo(() => {
34-
// Collect all week keys from the actual data
35-
const weekKeySet = new Set<string>();
36-
Object.values(data).forEach((weekData) => {
37-
Object.keys(weekData).forEach((week) => weekKeySet.add(week));
38-
});
64+
// Generate week keys based on range
65+
const weekKeys: string[] = [];
66+
const rangeWeeks = RANGE_WEEKS[range];
3967

40-
// Sort week keys chronologically and take last N weeks
41-
const allWeekKeys = Array.from(weekKeySet).sort();
42-
const weekKeys = allWeekKeys.slice(-weeks);
68+
if (rangeWeeks === null) {
69+
// "all" mode: extract all unique weeks from data, sorted
70+
const allWeeks = new Set<string>();
71+
Object.values(data).forEach(weekData => {
72+
Object.keys(weekData).forEach(w => allWeeks.add(w));
73+
});
74+
weekKeys.push(...Array.from(allWeeks).sort());
75+
} else {
76+
// Fixed range: generate week keys for the last N weeks
77+
const now = new Date();
78+
for (let i = rangeWeeks - 1; i >= 0; i--) {
79+
const d = new Date(now);
80+
d.setDate(d.getDate() - i * 7);
81+
const { year, week } = getISOWeek(d);
82+
weekKeys.push(`${year}-W${week.toString().padStart(2, "0")}`);
83+
}
84+
}
4385

44-
// Get all commands and sort by total usage
86+
// Get all commands and sort by total usage within the selected range
87+
const weekKeySet = new Set(weekKeys);
4588
const commandTotals: [string, number][] = Object.entries(data).map(([cmd, weekData]) => [
4689
cmd,
47-
Object.values(weekData).reduce((sum, count) => sum + count, 0),
90+
Object.entries(weekData)
91+
.filter(([w]) => weekKeySet.has(w))
92+
.reduce((sum, [, count]) => sum + count, 0),
4893
]);
4994
commandTotals.sort((a, b) => b[1] - a[1]);
5095
const totalCommands = commandTotals.length;
5196
// Only take top N commands for display
5297
const commands = commandTotals.slice(0, MAX_COMMANDS).map(([cmd]) => cmd);
5398

54-
// Build chart data
55-
const chartData = weekKeys.map((week) => {
56-
const point: Record<string, string | number> = { week: week.replace(/^\d{4}-W/, "W") };
57-
commands.forEach((cmd) => {
58-
point[cmd] = data[cmd]?.[week] || 0;
59-
});
99+
// Build chart data based on mode
100+
const chartData = weekKeys.map((week, idx) => {
101+
const isCurrentWeek = idx === weekKeys.length - 1;
102+
const point: Record<string, string | number | null> = {
103+
week: week.replace(/^\d{4}-W/, "W"),
104+
};
105+
106+
if (mode === "cumulative") {
107+
// Cumulative: sum all weeks up to this point
108+
commands.forEach((cmd) => {
109+
let cumulative = 0;
110+
for (let i = 0; i <= idx; i++) {
111+
cumulative += data[cmd]?.[weekKeys[i]] || 0;
112+
}
113+
point[cmd] = cumulative;
114+
});
115+
} else {
116+
// Weekly: show each week's data, current week as dot
117+
commands.forEach((cmd) => {
118+
if (isCurrentWeek) {
119+
point[cmd] = null; // Break the line
120+
point[`${cmd}_current`] = data[cmd]?.[week] || 0;
121+
} else {
122+
point[cmd] = data[cmd]?.[week] || 0;
123+
}
124+
});
125+
}
60126
return point;
61127
});
62128

63129
return { chartData, commands, totalCommands };
64-
}, [data, weeks]);
130+
}, [data, range, mode]);
65131

66132
if (commands.length === 0) {
67133
return (
@@ -74,17 +140,63 @@ export function CommandTrendChart({ data, weeks = 12 }: CommandTrendChartProps)
74140
return (
75141
<div className="space-y-2">
76142
<div className="flex items-center justify-between">
77-
<span className="text-xs text-muted-foreground uppercase tracking-wide">
78-
Command Trends
79-
</span>
143+
<div className="flex items-center gap-2">
144+
<span className="text-xs text-muted-foreground uppercase tracking-wide">
145+
Command Trends
146+
</span>
147+
{/* Time range toggle */}
148+
<div className="flex rounded-md border border-border/60 overflow-hidden">
149+
{(["1m", "3m", "all"] as TimeRange[]).map((r) => (
150+
<button
151+
key={r}
152+
onClick={() => setRange(r)}
153+
className={`px-2 py-0.5 text-[10px] transition-colors ${
154+
range === r
155+
? "bg-primary text-primary-foreground"
156+
: "bg-transparent text-muted-foreground hover:bg-accent"
157+
}`}
158+
>
159+
{r === "1m" ? "1M" : r === "3m" ? "3M" : "All"}
160+
</button>
161+
))}
162+
</div>
163+
{/* Mode toggle */}
164+
<div className="flex rounded-md border border-border/60 overflow-hidden">
165+
<button
166+
onClick={() => setMode("weekly")}
167+
className={`px-2 py-0.5 text-[10px] transition-colors ${
168+
mode === "weekly"
169+
? "bg-primary text-primary-foreground"
170+
: "bg-transparent text-muted-foreground hover:bg-accent"
171+
}`}
172+
>
173+
Weekly
174+
</button>
175+
<button
176+
onClick={() => setMode("cumulative")}
177+
className={`px-2 py-0.5 text-[10px] transition-colors ${
178+
mode === "cumulative"
179+
? "bg-primary text-primary-foreground"
180+
: "bg-transparent text-muted-foreground hover:bg-accent"
181+
}`}
182+
>
183+
Cumulative
184+
</button>
185+
</div>
186+
</div>
80187
<span className="text-xs text-muted-foreground">
81188
Top {commands.length} of {totalCommands}
82189
</span>
83190
</div>
84191

85-
<div className="h-40">
86-
<ResponsiveContainer width="100%" height="100%">
87-
<LineChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: -20 }}>
192+
{/* Chart */}
193+
<div className="h-36 w-full min-w-0">
194+
<ResponsiveContainer width="100%" height={144} minWidth={0}>
195+
<LineChart
196+
data={chartData}
197+
margin={{ top: 5, right: 5, bottom: 5, left: -20 }}
198+
onMouseLeave={() => setHoverData(null)}
199+
>
88200
<XAxis
89201
dataKey="week"
90202
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
@@ -99,13 +211,32 @@ export function CommandTrendChart({ data, weeks = 12 }: CommandTrendChartProps)
99211
allowDecimals={false}
100212
/>
101213
<Tooltip
102-
contentStyle={{
103-
backgroundColor: "hsl(var(--card))",
104-
border: "1px solid hsl(var(--border))",
105-
borderRadius: "8px",
106-
fontSize: "12px",
214+
content={({ active, payload, label }) => {
215+
// Update external state when tooltip is active
216+
if (active && payload && payload.length > 0) {
217+
// Merge _current entries with main entries
218+
const merged = new Map<string, { value: number; color: string }>();
219+
payload.forEach((p) => {
220+
const name = (p.name as string).replace(/_current$/, "");
221+
const value = (p.value as number) ?? 0;
222+
const existing = merged.get(name);
223+
if (!existing || value > 0) {
224+
merged.set(name, { value, color: (p.color as string) || "#999" });
225+
}
226+
});
227+
const newData: HoverData = {
228+
label: label as string,
229+
items: Array.from(merged.entries()).map(([name, { value, color }]) => ({
230+
name,
231+
value,
232+
color,
233+
})),
234+
};
235+
// Use setTimeout to avoid setState during render
236+
setTimeout(() => setHoverData(newData), 0);
237+
}
238+
return null; // Don't render tooltip
107239
}}
108-
labelStyle={{ color: "hsl(var(--foreground))" }}
109240
/>
110241
<Legend
111242
wrapperStyle={{ fontSize: "10px", paddingTop: "8px" }}
@@ -120,11 +251,48 @@ export function CommandTrendChart({ data, weeks = 12 }: CommandTrendChartProps)
120251
strokeWidth={1.5}
121252
dot={false}
122253
activeDot={{ r: 3 }}
254+
connectNulls={false}
255+
/>
256+
))}
257+
{/* Current week points - shown as dots only in weekly mode */}
258+
{mode === "weekly" && commands.map((cmd, i) => (
259+
<Line
260+
key={`${cmd}_current`}
261+
type="monotone"
262+
dataKey={`${cmd}_current`}
263+
stroke={COLORS[i % COLORS.length]}
264+
strokeWidth={0}
265+
dot={{ r: 3, fill: COLORS[i % COLORS.length], strokeWidth: 0 }}
266+
activeDot={{ r: 4 }}
267+
legendType="none"
123268
/>
124269
))}
125270
</LineChart>
126271
</ResponsiveContainer>
127272
</div>
273+
274+
{/* Details below chart */}
275+
<div className="min-h-[60px] pt-2 border-t border-border/40">
276+
{hoverData ? (
277+
<div className="flex flex-wrap gap-x-4 gap-y-1">
278+
<span className="text-sm font-medium w-full mb-1">{hoverData.label}</span>
279+
{[...hoverData.items].sort((a, b) => b.value - a.value).map((entry, i) => (
280+
<div key={i} className="flex items-center gap-1.5 text-xs">
281+
<span
282+
className="w-2 h-2 rounded-full flex-shrink-0"
283+
style={{ backgroundColor: entry.color }}
284+
/>
285+
<span className="text-muted-foreground">{entry.name}:</span>
286+
<span className="font-medium">{entry.value}</span>
287+
</div>
288+
))}
289+
</div>
290+
) : (
291+
<div className="text-xs text-muted-foreground italic">
292+
Hover chart to see week details
293+
</div>
294+
)}
295+
</div>
128296
</div>
129297
);
130298
}

src/store/atoms/home.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { atomWithStorage } from "jotai/utils";
2+
3+
export const activityViewModeAtom = atomWithStorage<"weekday" | "hour">("lovcode:home:activityViewMode", "hour");
4+
export const commandRangeAtom = atomWithStorage<"1m" | "3m" | "all">("lovcode:home:commandRange", "3m");
5+
export const commandModeAtom = atomWithStorage<"weekly" | "cumulative">("lovcode:home:commandMode", "cumulative");

src/store/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ export {
3737

3838
// Component atoms
3939
export { collapsibleStatesAtom, docReaderCollapsedGroupsAtom } from "./atoms/components";
40+
41+
// Home atoms
42+
export { activityViewModeAtom, commandRangeAtom, commandModeAtom } from "./atoms/home";

0 commit comments

Comments
 (0)