diff --git a/src/App.tsx b/src/App.tsx
index 7b08b20a..9d6422a1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -10,6 +10,7 @@ import { Link, Route, Routes, useParams } from "react-router-dom";
import { AskAI } from "@/components/ask-ai";
import { ProjectListItem } from "@/components/ProjectListItem";
import { TimeSince } from "@/components/time-since";
+import { WakaTimeStats } from "@/components/wakatime-stats";
import { type ProjectEntry, projectList, projects } from "@/projects";
import { ThemeToggle } from "./components/theme-toggle";
@@ -102,7 +103,7 @@ function Home() {
Age:
-
+
{sortedProjects.map((project) => (
))}
+
diff --git a/src/components/wakatime-stats.tsx b/src/components/wakatime-stats.tsx
new file mode 100644
index 00000000..301bf783
--- /dev/null
+++ b/src/components/wakatime-stats.tsx
@@ -0,0 +1,155 @@
+import { useEffect, useMemo, useState } from "react";
+
+type WakaTimeLanguage = {
+ name: string;
+ percent: number;
+ seconds: number;
+ text: string;
+};
+
+type WakaTimeStatsPayload = {
+ isCoding: boolean;
+ last7Text: string;
+ last7Seconds: number;
+ languages: WakaTimeLanguage[];
+ updatedAt: string;
+};
+
+const defaultRefreshMs = 60_000;
+
+export function WakaTimeStats() {
+ const [stats, setStats] = useState
(null);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const refreshMs = useMemo(() => {
+ const raw = import.meta.env.VITE_WAKATIME_REFRESH_MS;
+ if (!raw) return defaultRefreshMs;
+ const parsed = Number.parseInt(raw, 10);
+ if (Number.isNaN(parsed) || parsed < 10_000) return defaultRefreshMs;
+ return parsed;
+ }, []);
+
+ useEffect(() => {
+ let isActive = true;
+
+ const load = async () => {
+ try {
+ const response = await fetch("/api/wakatime/stats", {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`request failed (${response.status})`);
+ }
+
+ const data = (await response.json()) as WakaTimeStatsPayload;
+ if (!isActive) return;
+
+ setStats(data);
+ setError(null);
+ } catch (fetchError) {
+ if (!isActive) return;
+ setError(
+ fetchError instanceof Error
+ ? fetchError.message
+ : "Unable to fetch WakaTime stats",
+ );
+ } finally {
+ if (isActive) setIsLoading(false);
+ }
+ };
+
+ void load();
+ const timer = window.setInterval(() => {
+ void load();
+ }, refreshMs);
+
+ return () => {
+ isActive = false;
+ window.clearInterval(timer);
+ };
+ }, [refreshMs]);
+
+ return (
+
+
+
WakaTime
+
+ {stats?.updatedAt
+ ? `Updated ${new Date(stats.updatedAt).toLocaleTimeString()}`
+ : "Waiting for update"}
+
+
+
+ {isLoading ? (
+ Loading coding stats...
+ ) : null}
+
+ {error ? (
+
+ WakaTime unavailable: {error}
+
+ ) : null}
+
+ {stats ? (
+
+
+
Currently coding
+
+ {stats.isCoding ? "Yes" : "No"}
+
+
+
+
+
Last 7 days
+
{stats.last7Text}
+
+
+
+
Tracked languages
+
+ {stats.languages.length}
+
+
+
+ ) : null}
+
+ {stats?.languages.length ? (
+
+ {stats.languages.slice(0, 7).map((language) => (
+ -
+
+
+ {language.name}
+
+ {language.percent.toFixed(1)}%
+
+
+
+
+
+ {language.text}
+
+
+ ))}
+
+ ) : null}
+
+ );
+}