+ {/* Spacer matches month label height to align rows */}
+
c.charCodeAt(0));
+ return await crypto.subtle.importKey('raw', buf, 'AES-GCM', false, ['encrypt', 'decrypt']);
+ } catch {
+ return null;
+ }
+}
+
+export async function encryptToken(token) {
+ const key = await getCryptoKey();
+ if (!key) return token; // fallback
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const enc = new TextEncoder().encode(token);
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc));
+ return `${btoa(String.fromCharCode(...iv))}:${btoa(String.fromCharCode(...ct))}`;
+}
+
+export async function decryptToken(tokenEnc) {
+ const key = await getCryptoKey();
+ if (!key || !tokenEnc.includes(':')) return tokenEnc || '';
+ const [ivB64, ctB64] = tokenEnc.split(':');
+ const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
+ const ct = Uint8Array.from(atob(ctB64), c => c.charCodeAt(0));
+ try {
+ const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
+ return new TextDecoder().decode(pt);
+ } catch {
+ return '';
+ }
+}
+
+// --- Integrations CRUD ---
+export function getGitEnabled() {
+ return localStorage.getItem(GIT_ENABLED_KEY) === 'true';
+}
+export function setGitEnabled(enabled) {
+ localStorage.setItem(GIT_ENABLED_KEY, enabled ? 'true' : 'false');
+}
+
+export function getIntegrations() {
+ try {
+ const raw = localStorage.getItem(GIT_INT_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+export async function addIntegration({ provider, baseUrl, username, token }) {
+ const integrations = getIntegrations();
+ const tokenEnc = await encryptToken(token);
+ const id = Date.now().toString();
+ integrations.push({ id, provider, baseUrl, username, tokenEnc, createdAt: new Date().toISOString() });
+ localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
+ return id;
+}
+
+export function removeIntegration(id) {
+ const integrations = getIntegrations().filter(x => x.id !== id);
+ localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
+}
+
+// --- Caching ---
+function getCache() {
+ try {
+ const raw = localStorage.getItem(GIT_CACHE_KEY);
+ return raw ? JSON.parse(raw) : { lastSync: null, dailyCounts: {} };
+ } catch {
+ return { lastSync: null, dailyCounts: {} };
+ }
+}
+function setCache(cache) {
+ localStorage.setItem(GIT_CACHE_KEY, JSON.stringify(cache));
+}
+
+export function getCachedGitActivity() {
+ return getCache();
+}
+
+// --- Fetch events per provider ---
+function isOlderThan(dateStr, days) {
+ const d = new Date(dateStr);
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - days);
+ return d < cutoff;
+}
+
+function deriveGitHubGraphQLEndpoint(baseUrl) {
+ try {
+ const u = new URL(baseUrl || 'https://api.github.com');
+ // If /api/v3 -> likely /api/graphql
+ if (u.pathname.includes('/api/v3')) {
+ return `${u.origin}/api/graphql`;
+ }
+ // If ends with /api -> /graphql under same base
+ if (u.pathname.endsWith('/api')) {
+ return `${u.origin}/graphql`;
+ }
+ // Default: append /graphql
+ return `${baseUrl.replace(/\/$/, '')}/graphql`;
+ } catch {
+ return 'https://api.github.com/graphql';
+ }
+}
+
+async function fetchGitHubGraphQL({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
+ if (!token) return null; // require token for GraphQL
+ const endpoint = deriveGitHubGraphQLEndpoint(baseUrl);
+ const to = new Date();
+ const from = new Date();
+ from.setDate(from.getDate() - days + 1);
+ const query = `query($login:String!, $from:DateTime!, $to:DateTime!) {
+ user(login:$login) {
+ contributionsCollection(from:$from, to:$to) {
+ contributionCalendar { weeks { contributionDays { date contributionCount } } }
+ }
+ }
+ }`;
+ const res = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify({ query, variables: { login: username, from: from.toISOString(), to: to.toISOString() } }),
+ });
+ if (!res.ok) return null;
+ const json = await res.json();
+ const daysArr = json?.data?.user?.contributionsCollection?.contributionCalendar?.weeks?.flatMap(w => w.contributionDays) || [];
+ const counts = {};
+ for (const d of daysArr) {
+ if (d?.date) counts[d.date] = (counts[d.date] || 0) + (d.contributionCount || 0);
+ }
+ return counts;
+}
+
+async function fetchGitHubEvents({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
+ // Prefer GraphQL for full-year coverage if token present
+ try {
+ if (token) {
+ const graphCounts = await fetchGitHubGraphQL({ baseUrl, username, token }, days);
+ if (graphCounts) return graphCounts;
+ }
+ } catch {}
+ const headers = { 'Accept': 'application/vnd.github+json' };
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+ const counts = {};
+ for (let page = 1; page <= 3; page++) {
+ const url = `${baseUrl.replace(/\/$/, '')}/users/${encodeURIComponent(username)}/events?per_page=100&page=${page}`;
+ const res = await fetch(url, { headers });
+ if (!res.ok) break;
+ const events = await res.json();
+ if (!Array.isArray(events) || events.length === 0) break;
+ for (const ev of events) {
+ if (!ev || !ev.created_at) continue;
+ if (isOlderThan(ev.created_at, days)) { page = 999; break; }
+ if (ev.type === 'PushEvent') {
+ const c = ev.payload?.size || 1;
+ const day = formatDate(new Date(ev.created_at));
+ counts[day] = (counts[day] || 0) + c;
+ }
+ }
+ }
+ return counts;
+}
+
+async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
+ const headers = { 'Accept': 'application/json' };
+ if (token) headers['Authorization'] = `token ${token}`;
+ const counts = {};
+ for (let page = 1; page <= 5; page++) {
+ const url = `${baseUrl.replace(/\/$/, '')}/api/v1/users/${encodeURIComponent(username)}/events?limit=50&page=${page}`;
+ const res = await fetch(url, { headers });
+ if (!res.ok) break;
+ const events = await res.json();
+ if (!Array.isArray(events) || events.length === 0) break;
+ for (const ev of events) {
+ const created = ev?.created || ev?.created_at || ev?.timestamp;
+ if (!created) continue;
+ if (isOlderThan(created, days)) { page = 999; break; }
+ const action = ev?.op_type || ev?.action || ev?.type;
+ if (String(action).toLowerCase().includes('push')) {
+ const day = formatDate(new Date(created));
+ counts[day] = (counts[day] || 0) + 1;
+ }
+ }
+ }
+ return counts;
+}
+
+async function fetchGitLabEvents({ baseUrl = 'https://gitlab.com', token }, days = 365) {
+ const headers = { 'Accept': 'application/json', 'PRIVATE-TOKEN': token };
+ const counts = {};
+ for (let page = 1; page <= 5; page++) {
+ const url = `${baseUrl.replace(/\/$/, '')}/api/v4/events?per_page=100&page=${page}&action=push`;
+ const res = await fetch(url, { headers });
+ if (!res.ok) break;
+ const events = await res.json();
+ if (!Array.isArray(events) || events.length === 0) break;
+ for (const ev of events) {
+ const created = ev?.created_at;
+ if (!created) continue;
+ if (isOlderThan(created, days)) { page = 999; break; }
+ const day = formatDate(new Date(created));
+ counts[day] = (counts[day] || 0) + 1;
+ }
+ }
+ return counts;
+}
+
+export async function fetchAllGitActivity({ force = false, days = 365 } = {}) {
+ const { lastSync, dailyCounts } = getCache();
+ const last = lastSync ? new Date(lastSync) : null;
+ const now = new Date();
+ const withinDay = last && (now - last) < 24 * 60 * 60 * 1000;
+ if (!force && withinDay && dailyCounts) {
+ return { dailyCounts, lastSync };
+ }
+
+ const integrations = getIntegrations();
+ const perSource = [];
+ for (const src of integrations) {
+ const token = await decryptToken(src.tokenEnc);
+ const info = { baseUrl: src.baseUrl, username: src.username, token };
+ try {
+ if (src.provider === 'github') {
+ perSource.push(await fetchGitHubEvents(info, days));
+ } else if (src.provider === 'gitlab') {
+ perSource.push(await fetchGitLabEvents(info, days));
+ } else if (src.provider === 'gitea' || src.provider === 'forgejo' || src.provider === 'custom') {
+ perSource.push(await fetchGiteaLike(info, days));
+ }
+ } catch (e) {
+ // Continue other sources
+ console.warn('Git fetch failed for', src.provider, e);
+ }
+ }
+ // Merge
+ const merged = {};
+ for (const m of perSource) {
+ for (const [day, cnt] of Object.entries(m)) {
+ merged[day] = (merged[day] || 0) + cnt;
+ }
+ }
+ const updated = { lastSync: new Date().toISOString(), dailyCounts: merged };
+ setCache(updated);
+ return updated;
+}
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index 9896e4c..be69e6b 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -5,6 +5,8 @@ import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-r
import { Button } from '../components/ui/button';
import { useToast } from '../components/ui/use-toast';
import HabitCard from '../components/HabitCard';
+import GitActivityGrid from '../components/GitActivityGrid';
+import { getGitEnabled } from '../lib/git';
import { getHabits } from '../lib/storage';
const HomePage = () => {
@@ -12,12 +14,14 @@ const HomePage = () => {
const { toast } = useToast();
const [habits, setHabits] = useState([]);
const [isPremium] = useState(false);
+ const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
const [darkMode, setDarkMode] = useState(() => {
return localStorage.getItem('theme') === 'dark';
});
useEffect(() => {
loadHabits();
+ setGitEnabled(getGitEnabled());
}, []);
useEffect(() => {
@@ -113,6 +117,13 @@ const HomePage = () => {
)}
+ {/* Git Activity */}
+ {gitEnabled && (
+
+
+
+ )}
+
{/* Habits List */}
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index a0a5b76..42a1b31 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
-import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2 } from 'lucide-react';
+import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2, Plus, Trash, GitBranch } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Switch } from '../components/ui/switch';
import { Label } from '../components/ui/label';
import { useToast } from '../components/ui/use-toast';
import { exportData, importData, clearAllData } from '../lib/storage';
+import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
const SettingsPage = () => {
const navigate = useNavigate();
@@ -15,6 +16,11 @@ const SettingsPage = () => {
return localStorage.getItem('theme') === 'dark';
});
const [notifications, setNotifications] = useState(false);
+ const [gitEnabled, setGitEnabledState] = useState(getGitEnabled());
+ const [sources, setSources] = useState(() => getIntegrations());
+ const [form, setForm] = useState({ provider: 'github', baseUrl: '', username: '', token: '' });
+ const [{ lastSync }, setCacheInfo] = useState(getCachedGitActivity());
+ const [syncing, setSyncing] = useState(false);
useEffect(() => {
if (darkMode) {
@@ -30,6 +36,31 @@ const SettingsPage = () => {
setDarkMode(enabled);
};
+ const toggleGitEnabled = (enabled) => {
+ setGitEnabledState(enabled);
+ setGitEnabled(enabled);
+ };
+
+ const handleAddSource = async () => {
+ if (!form.username) return;
+ const baseUrl = form.baseUrl || (form.provider === 'github' ? 'https://api.github.com' : form.provider === 'gitlab' ? 'https://gitlab.com' : '');
+ await addIntegration({ provider: form.provider, baseUrl, username: form.username, token: form.token });
+ setSources(getIntegrations());
+ setForm({ provider: 'github', baseUrl: '', username: '', token: '' });
+ };
+
+ const handleRemoveSource = (id) => {
+ removeIntegration(id);
+ setSources(getIntegrations());
+ };
+
+ const handleSyncGit = async () => {
+ setSyncing(true);
+ const data = await fetchAllGitActivity({ force: true });
+ setCacheInfo(data);
+ setSyncing(false);
+ };
+
const handleExport = () => {
const data = exportData();
const blob = new Blob([data], { type: 'application/json' });
@@ -118,6 +149,71 @@ const SettingsPage = () => {
+ {/* Integrations */}
+
+ Integrations
+
+
+
+
Display a unified Git activity grid
+
+
+
+
+
+
+
+
+
+
+
+ setForm({ ...form, baseUrl: e.target.value })} />
+
+
+
+ setForm({ ...form, username: e.target.value })} />
+
+
+
+ setForm({ ...form, token: e.target.value })} />
+
+
+
+
+
+
+ {lastSync ? `Last sync: ${new Date(lastSync).toLocaleString()}` : ''}
+
+
+ {sources.length > 0 && (
+
+ {sources.map(src => (
+
+
+
{src.provider} • {src.username}
+
{src.baseUrl}
+
+
+
+ ))}
+
+ )}
+
{/* Appearance */}