From 9041c7db94ac3a4c23b3eb7096e7f309589d8701 Mon Sep 17 00:00:00 2001 From: count0 Date: Wed, 15 Oct 2025 14:22:03 +0200 Subject: [PATCH] git activity update --- src/components/GitActivityGrid.jsx | 96 +++++++++++ src/components/HabitGrid.jsx | 3 +- src/lib/git.js | 264 +++++++++++++++++++++++++++++ src/pages/HomePage.jsx | 11 ++ src/pages/SettingsPage.jsx | 98 ++++++++++- 5 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 src/components/GitActivityGrid.jsx create mode 100644 src/lib/git.js diff --git a/src/components/GitActivityGrid.jsx b/src/components/GitActivityGrid.jsx new file mode 100644 index 0000000..f25d4d9 --- /dev/null +++ b/src/components/GitActivityGrid.jsx @@ -0,0 +1,96 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { GitBranch } from 'lucide-react'; +import { getCachedGitActivity } from '../lib/git'; +import { formatDate, isToday, getWeekdayLabel } from '../lib/utils-habit'; + +const GitActivityGrid = () => { + const [{ dailyCounts }, setData] = useState(() => getCachedGitActivity()); + + const weeks = useMemo(() => { + const today = new Date(); + const todayDay = today.getDay(); + const daysSinceMonday = (todayDay + 6) % 7; + const mondayThisWeek = new Date(today); + mondayThisWeek.setDate(today.getDate() - daysSinceMonday); + const weeksArray = []; + const totalWeeks = 52; + for (let week = totalWeeks - 1; week >= 0; week--) { + const weekDays = []; + const monday = new Date(mondayThisWeek); + monday.setDate(mondayThisWeek.getDate() - week * 7); + for (let day = 0; day < 7; day++) { + const date = new Date(monday); + date.setDate(monday.getDate() + day); + weekDays.push(date); + } + weeksArray.push(weekDays); + } + return weeksArray; + }, []); + + const getOpacity = (count) => { + if (!count) return 0.15; + if (count < 2) return 0.35; + if (count < 5) return 0.6; + if (count < 10) return 0.8; + return 1; + }; + + useEffect(() => { + // Display current cache only; syncing is done from Settings + setData(getCachedGitActivity()); + }, []); + + return ( +
+
+
+ +

Git Activity

+
+
+
+
+
+ {weeks.map((week, weekIndex) => ( +
+
+ {weekIndex % 4 === 0 && week[0].toLocaleDateString('en-US', { month: 'short' })} +
+ {week.map((date, dayIndex) => { + const dateStr = formatDate(date); + const count = dailyCounts?.[dateStr] || 0; + const isTodayCell = isToday(date); + const isFuture = date > new Date(); + return ( +
+ ); + })} +
+ ))} +
+
+ {[1, 2, 3, 4, 5, 6, 0].map((day) => ( +
+ {getWeekdayLabel(day)} +
+ ))} +
+
+
+
+ ); +}; + +export default GitActivityGrid; diff --git a/src/components/HabitGrid.jsx b/src/components/HabitGrid.jsx index 64ebc63..7d33588 100644 --- a/src/components/HabitGrid.jsx +++ b/src/components/HabitGrid.jsx @@ -95,7 +95,8 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => { ))} {/* Weekday labels: Monday (top) to Sunday (bottom) */}
-
+ {/* Spacer matches month label height to align rows */} +
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
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 */}