From 9bf8f114e23c0e801ed6f9f0579935c2c3e544ad Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 16 Apr 2026 10:02:34 +0000 Subject: [PATCH] feat: add SavingsWidget + GitActivityWidget --- src/app/(dashboard)/page.tsx | 12 +- src/app/api/git-activity/route.ts | 53 +++++++++ src/app/api/savings/route.ts | 54 +++++++++ src/components/widgets/GitActivityWidget.tsx | 118 +++++++++++++++++++ src/components/widgets/SavingsWidget.tsx | 107 +++++++++++++++++ 5 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 src/app/api/git-activity/route.ts create mode 100644 src/app/api/savings/route.ts create mode 100644 src/components/widgets/GitActivityWidget.tsx create mode 100644 src/components/widgets/SavingsWidget.tsx diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index cc9a9bb..8d486c9 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -3,13 +3,23 @@ export const dynamic = "force-dynamic"; import { WeatherWidget } from "@/components/widgets/WeatherWidget"; import { CalendarWidget } from "@/components/widgets/CalendarWidget"; import { DashboardHeader } from "@/components/widgets/DashboardHeader"; +import { SavingsWidget } from "@/components/widgets/SavingsWidget"; +import { GitActivityWidget } from "@/components/widgets/GitActivityWidget"; export default function DashboardPage() { return (
- +
+
+ +
+
+ + +
+
); } diff --git a/src/app/api/git-activity/route.ts b/src/app/api/git-activity/route.ts new file mode 100644 index 0000000..5b1ad84 --- /dev/null +++ b/src/app/api/git-activity/route.ts @@ -0,0 +1,53 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; + +const GITEA_URL = "https://git.digital-home.site"; +const GITEA_TOKEN = "f7a12c4f58e0799ac119243d0d95f4551c5be8b1"; + +export async function GET() { + try { + // Получить репозитории + const reposRes = await fetch( + `${GITEA_URL}/api/v1/repos/search?limit=20&sort=updated&token=${GITEA_TOKEN}`, + { signal: AbortSignal.timeout(8000) } + ); + const reposData = await reposRes.json(); + const repos = reposData.data || []; + + // Получить последние коммиты из топ-5 активных репо + const topRepos = repos + .filter((r: any) => !r.archived && !r.empty) + .slice(0, 8); + + const commits = await Promise.allSettled( + topRepos.map(async (repo: any) => { + const res = await fetch( + `${GITEA_URL}/api/v1/repos/${repo.full_name}/commits?limit=3&token=${GITEA_TOKEN}`, + { signal: AbortSignal.timeout(5000) } + ); + const data = await res.json(); + return { + repo: repo.name, + full_name: repo.full_name, + commits: (Array.isArray(data) ? data : []).map((c: any) => ({ + sha: c.sha?.slice(0, 7), + message: c.commit?.message?.split("\n")[0]?.slice(0, 60), + author: c.commit?.author?.name, + date: c.commit?.author?.date, + })), + updated_at: repo.updated_at, + }; + }) + ); + + const result = commits + .filter(r => r.status === "fulfilled" && (r.value as any).commits.length > 0) + .map(r => (r as any).value) + .sort((a: any, b: any) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) + .slice(0, 6); + + return NextResponse.json({ repos: result }); + } catch (e) { + return NextResponse.json({ error: String(e), repos: [] }, { status: 500 }); + } +} diff --git a/src/app/api/savings/route.ts b/src/app/api/savings/route.ts new file mode 100644 index 0000000..30cfce6 --- /dev/null +++ b/src/app/api/savings/route.ts @@ -0,0 +1,54 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; + +const PULSE_REFRESH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE4MDIwMjY5NDgsImlhdCI6MTc3MDQ5MDk0OCwidHlwZSI6InJlZnJlc2giLCJ1c2VyX2lkIjoxfQ.zPJJB7o9vtnfIBFl7rNygEEXd9h-5YZeAxRIvWcRlXY"; +const PULSE_API = "https://api.digital-home.site"; + +async function getAccessToken(): Promise { + const res = await fetch(`${PULSE_API}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: PULSE_REFRESH_TOKEN }), + signal: AbortSignal.timeout(5000), + }); + const data = await res.json(); + if (!data.access_token) throw new Error("Failed to get token"); + return data.access_token; +} + +export async function GET() { + try { + const token = await getAccessToken(); + + const res = await fetch(`${PULSE_API}/finance/transactions?limit=200&type=expense`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(8000), + }); + const txs = await res.json(); + + // Группируем накопления по цели + const groups: Record = {}; + for (const t of txs) { + if (t.category_emoji === "💎") { + // Нормализуем название + const desc = t.description as string; + let key = "Другое"; + if (desc.toLowerCase().includes("квартир")) key = "🏠 Квартира"; + else if (desc.toLowerCase().includes("отпуск") || desc.toLowerCase().includes("путешеств")) key = "✈️ Отпуск"; + else if (desc.toLowerCase().includes("машин") || desc.toLowerCase().includes("авто")) key = "🚗 Машина"; + else key = desc; + groups[key] = (groups[key] || 0) + (t.amount as number); + } + } + + const goals = Object.entries(groups) + .map(([name, amount]) => ({ name, amount })) + .sort((a, b) => b.amount - a.amount); + + const total = goals.reduce((s, g) => s + g.amount, 0); + + return NextResponse.json({ goals, total }); + } catch (e) { + return NextResponse.json({ error: String(e), goals: [], total: 0 }, { status: 500 }); + } +} diff --git a/src/components/widgets/GitActivityWidget.tsx b/src/components/widgets/GitActivityWidget.tsx new file mode 100644 index 0000000..0d584f5 --- /dev/null +++ b/src/components/widgets/GitActivityWidget.tsx @@ -0,0 +1,118 @@ +"use client"; +import { useEffect, useState, useCallback } from "react"; +import { RefreshCw, GitCommit, ExternalLink } from "lucide-react"; + +interface Commit { + sha: string; + message: string; + author: string; + date: string; +} + +interface RepoActivity { + repo: string; + full_name: string; + commits: Commit[]; + updated_at: string; +} + +interface GitData { + repos: RepoActivity[]; + error?: string; +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}м назад`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}ч назад`; + return `${Math.floor(hours / 24)}д назад`; +} + +export function GitActivityWidget() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/git-activity", { cache: "no-store" }); + setData(await res.json()); + } catch { + setData({ repos: [], error: "Ошибка загрузки" }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + return ( +
+
+
+
+ +
+ Git активность +
+ +
+ + {loading ? ( +
+ {[1,2,3,4].map(i =>
)} +
+ ) : data?.error ? ( +
{data.error}
+ ) : ( +
+ {(data?.repos || []).map(repo => ( +
+ + + {expanded === repo.repo && ( +
+ {repo.commits.map(c => ( +
+ {c.sha} +
+
{c.message}
+
{c.author} · {timeAgo(c.date)}
+
+
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/widgets/SavingsWidget.tsx b/src/components/widgets/SavingsWidget.tsx new file mode 100644 index 0000000..7cf8b34 --- /dev/null +++ b/src/components/widgets/SavingsWidget.tsx @@ -0,0 +1,107 @@ +"use client"; +import { useEffect, useState, useCallback } from "react"; +import { RefreshCw, PiggyBank } from "lucide-react"; + +interface Goal { + name: string; + amount: number; +} + +interface SavingsData { + goals: Goal[]; + total: number; + error?: string; +} + +function formatMoney(n: number): string { + return n.toLocaleString("ru-RU") + " ₽"; +} + +// Целевые суммы (максимум для прогресс-бара) +const TARGETS: Record = { + "🏠 Квартира": 500000, + "✈️ Отпуск": 200000, +}; + +export function SavingsWidget() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/savings", { cache: "no-store" }); + setData(await res.json()); + } catch { + setData({ goals: [], total: 0, error: "Ошибка загрузки" }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + return ( +
+
+
+
+ +
+ Накопления +
+ +
+ + {loading ? ( +
+ {[1, 2].map(i =>
)} +
+ ) : data?.error ? ( +
{data.error}
+ ) : ( + <> + {/* Total */} +
+ Итого накоплено + {formatMoney(data?.total || 0)} +
+ + {/* Goals */} +
+ {(data?.goals || []).map(goal => { + const target = TARGETS[goal.name]; + const pct = target ? Math.min(100, (goal.amount / target) * 100) : null; + const color = goal.name.includes("Квартир") ? "#6366f1" : + goal.name.includes("Отпуск") ? "#f59e0b" : "#10b981"; + return ( +
+
+ {goal.name} +
+ {formatMoney(goal.amount)} + {target && / {formatMoney(target)}} +
+
+ {pct !== null && ( +
+
+
+ )} + {target && ( +
{pct?.toFixed(0)}% от цели
+ )} +
+ ); + })} +
+ + )} +
+ ); +}