feat(git): Commit-Heatmap ueber ein Jahr
Ein Feld je Tag auf der Projektseite, Wochen als Spalten, mit Farbskala und Datum im Tooltip. Woher die Zahlen kommen, unterscheidet sich je Anbieter. GitHub fuehrt die Statistik selbst und liefert 52 Wochen mit Tageswerten in einem einzigen Aufruf. GitLab und Gitea kennen nichts Vergleichbares; dort werden Commits seitenweise geholt, gedeckelt auf fuenf Seiten - genug fuer die allermeisten Repositories und wenig genug, um kein fremdes Kontingent zu sprengen. Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen. Reichen die Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an und sagt es dazu. Ein leeres Feld wuerde sonst behaupten, an dem Tag sei nicht gearbeitet worden, obwohl darueber nichts bekannt ist - genau diesen Fehler hatte die Verlaufslinie schon einmal. Geholt wird hoechstens einmal am Tag und getrennt vom uebrigen Abgleich: an einem Jahresueberblick aendert sich von Stunde zu Stunde nichts Sichtbares, der Abgleich laeuft aber im Zweifel stuendlich. Der Kalender benutzt getJson statt getJsonSafe. Waere ein Rate-Limit als "keine Daten" durchgegangen, haette der gesetzte Zeitstempel die Heatmap einen ganzen Tag lang unterdrueckt. Jetzt bleibt er bei einem Fehlschlag ungesetzt, und der naechste Abgleich versucht es erneut - das deckt auch den Fall ab, dass GitHub die Statistik gerade erst berechnet und mit 202 antwortet. Nachgewiesen an vercel/swr: 49 Commits an 33 Tagen ueber ein volles Jahr aus der Statistik, und mit kurzer Historie 90 statt 365 Felder samt Hinweis auf den Deckel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RepoSnapshot" ADD COLUMN "commitCalendar" JSONB NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN "commitCalendarAt" TIMESTAMP(3);
|
||||
@@ -127,6 +127,7 @@ model User {
|
||||
activities Activity[]
|
||||
recoveryCodes RecoveryCode[]
|
||||
pendingLogins PendingLogin[]
|
||||
templates ProjectTemplate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -466,6 +467,41 @@ model Task {
|
||||
@@index([projectId, status, position])
|
||||
}
|
||||
|
||||
/// Eine Vorlage fuer neue Projekte: Stammdaten, Tags, Notizen und Aufgaben.
|
||||
///
|
||||
/// Bewusst benutzereigen und nicht fest eingebaut. Welche Tags und welche
|
||||
/// Startaufgaben sinnvoll sind, weiss nur, wer damit arbeitet – eine
|
||||
/// mitgelieferte Liste waere geraten.
|
||||
model ProjectTemplate {
|
||||
id String @id @default(cuid())
|
||||
ownerId String
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
/// Wozu die Vorlage gut ist – erscheint in der Auswahl.
|
||||
description String?
|
||||
|
||||
// ── Was die Vorlage in das neue Projekt schreibt ───────────
|
||||
summary String?
|
||||
projectDescription String?
|
||||
status ProjectStatus @default(IDEA)
|
||||
priority Int @default(2)
|
||||
accent String @default("violet")
|
||||
tags String[] @default([])
|
||||
progressFromTasks Boolean @default(false)
|
||||
|
||||
/// [{ title, content, pinned }]
|
||||
notes Json @default("[]")
|
||||
/// [{ title, description, status, labels }]
|
||||
tasks Json @default("[]")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([ownerId, name])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
@@ -550,6 +586,13 @@ model RepoSnapshot {
|
||||
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
|
||||
commitActivity Json @default("[]")
|
||||
|
||||
/// Commits je Tag ueber bis zu ein Jahr – die Heatmap.
|
||||
/// { from, to, days: { "2026-07-26": 3 }, source, truncated }
|
||||
/// Getrennt vom uebrigen Stand, weil das Holen zusaetzliche Aufrufe kostet
|
||||
/// und deshalb hoechstens einmal am Tag geschieht.
|
||||
commitCalendar Json @default("{}")
|
||||
commitCalendarAt DateTime?
|
||||
|
||||
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
|
||||
/// success | failure | running | pending | cancelled
|
||||
ciState String?
|
||||
|
||||
@@ -24,10 +24,11 @@ import {
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { CiBadge } from "@/components/CiBadge";
|
||||
import { Heatmap } from "@/components/Heatmap";
|
||||
import { RepositoryDialog } from "@/components/RepositoryDialog";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { repoTitle } from "@/lib/git/meta";
|
||||
import { asArray, asRecord } from "@/types";
|
||||
import { asArray, asCalendar, asRecord } from "@/types";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CommitInfo,
|
||||
@@ -109,6 +110,7 @@ export function GitPanel({
|
||||
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
|
||||
const issues = asArray<TicketInfo>(snapshot?.issues);
|
||||
const activity = asArray<number>(snapshot?.commitActivity);
|
||||
const calendar = asCalendar(snapshot?.commitCalendar);
|
||||
|
||||
/** Ein einzelnes Repository oder – ohne Argument – alle. */
|
||||
async function sync(nurDieses?: string) {
|
||||
@@ -416,6 +418,9 @@ export function GitPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Der Jahresueberblick, sobald er einmal geholt wurde. */}
|
||||
{calendar ? <Heatmap calendar={calendar} /> : null}
|
||||
|
||||
{/* Sprachen */}
|
||||
{languageTotal > 0 ? (
|
||||
<div className="glass p-4">
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { CalendarRange } from "lucide-react";
|
||||
import type { CommitCalendar } from "@/lib/git/calendar";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Commits je Tag als Kalender – ein Feld pro Tag, Wochen als Spalten.
|
||||
*
|
||||
* Wie die Verlaufslinie ohne Diagrammbibliothek: es sind Quadrate in einem
|
||||
* Raster, dafuer braucht es keine Achsen und keine Legende ausser der
|
||||
* Farbskala.
|
||||
*
|
||||
* Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen.
|
||||
* Wo die Seiten des Anbieters nur ein paar Monate zurueckreichen, faengt die
|
||||
* Heatmap dort an – ein leeres Feld wuerde sonst behaupten, an dem Tag sei
|
||||
* nicht gearbeitet worden, obwohl darueber nichts bekannt ist.
|
||||
*/
|
||||
|
||||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
const MONATE = [
|
||||
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
|
||||
];
|
||||
|
||||
/** Fuenf Stufen, damit ein einzelner Ausreisser nicht alles andere platt macht. */
|
||||
function stufe(anzahl: number, hoch: number): number {
|
||||
if (anzahl <= 0) return 0;
|
||||
if (hoch <= 1) return 4;
|
||||
const anteil = anzahl / hoch;
|
||||
if (anteil > 0.75) return 4;
|
||||
if (anteil > 0.5) return 3;
|
||||
if (anteil > 0.25) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const FARBEN = [
|
||||
"fill-white/6",
|
||||
"fill-violet-500/25",
|
||||
"fill-violet-500/45",
|
||||
"fill-violet-400/70",
|
||||
"fill-violet-300",
|
||||
];
|
||||
|
||||
/** Montag der Woche, in der dieser Tag liegt (UTC). */
|
||||
function wochenanfang(tag: Date): Date {
|
||||
const kopie = new Date(tag);
|
||||
// getUTCDay: 0 = Sonntag. Die Woche soll am Montag beginnen.
|
||||
const versatz = (kopie.getUTCDay() + 6) % 7;
|
||||
kopie.setUTCDate(kopie.getUTCDate() - versatz);
|
||||
kopie.setUTCHours(0, 0, 0, 0);
|
||||
return kopie;
|
||||
}
|
||||
|
||||
export function Heatmap({ calendar }: { calendar: CommitCalendar }) {
|
||||
const von = new Date(`${calendar.from}T00:00:00.000Z`);
|
||||
const bis = new Date(`${calendar.to}T00:00:00.000Z`);
|
||||
if (Number.isNaN(von.getTime()) || Number.isNaN(bis.getTime()) || von > bis) return null;
|
||||
|
||||
const start = wochenanfang(von);
|
||||
const wochen: Array<Array<{ key: string; anzahl: number; ausserhalb: boolean }>> = [];
|
||||
|
||||
let hoechstwert = 0;
|
||||
for (const wert of Object.values(calendar.days)) {
|
||||
if (wert > hoechstwert) hoechstwert = wert;
|
||||
}
|
||||
|
||||
const zeiger = new Date(start);
|
||||
while (zeiger <= bis) {
|
||||
const woche: Array<{ key: string; anzahl: number; ausserhalb: boolean }> = [];
|
||||
for (let tag = 0; tag < 7; tag += 1) {
|
||||
const key = zeiger.toISOString().slice(0, 10);
|
||||
// Vor dem ersten bekannten Tag und nach heute wird nichts gezeichnet.
|
||||
const ausserhalb = zeiger < von || zeiger > bis;
|
||||
woche.push({ key, anzahl: calendar.days[key] ?? 0, ausserhalb });
|
||||
zeiger.setUTCDate(zeiger.getUTCDate() + 1);
|
||||
}
|
||||
wochen.push(woche);
|
||||
}
|
||||
|
||||
const gesamt = Object.values(calendar.days).reduce((sum, wert) => sum + wert, 0);
|
||||
const tageMitCommits = Object.keys(calendar.days).length;
|
||||
|
||||
// Masse in SVG-Einheiten; die Breite ergibt sich aus der Zahl der Wochen.
|
||||
const feld = 11;
|
||||
const luecke = 3;
|
||||
const kopf = 14;
|
||||
const spalte = feld + luecke;
|
||||
const breite = wochen.length * spalte - luecke;
|
||||
const hoehe = kopf + 7 * spalte - luecke;
|
||||
|
||||
// Monatsbeschriftung an der Woche, in der ein neuer Monat beginnt.
|
||||
const monate: Array<{ x: number; label: string }> = [];
|
||||
let letzterMonat = -1;
|
||||
wochen.forEach((woche, index) => {
|
||||
const ersterTag = new Date(`${woche[0].key}T00:00:00.000Z`);
|
||||
const monat = ersterTag.getUTCMonth();
|
||||
if (monat !== letzterMonat) {
|
||||
letzterMonat = monat;
|
||||
monate.push({ x: index * spalte, label: MONATE[monat] });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="glass p-4">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<CalendarRange className="h-3 w-3" />
|
||||
Commits je Tag
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{gesamt} an {tageMitCommits} {tageMitCommits === 1 ? "Tag" : "Tagen"} ·{" "}
|
||||
{/* Der Zeitraum gehoert dazu: er ist nicht immer ein Jahr. */}
|
||||
seit {formatDate(calendar.from)}
|
||||
{calendar.truncated ? " (Älteres nicht abgerufen)" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${breite + 22} ${hoehe}`}
|
||||
width={breite + 22}
|
||||
height={hoehe}
|
||||
className="max-w-full"
|
||||
role="img"
|
||||
aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`}
|
||||
>
|
||||
{monate.map((monat, index) => (
|
||||
<text
|
||||
key={`${monat.label}-${index}`}
|
||||
x={monat.x + 22}
|
||||
y={9}
|
||||
className="fill-slate-500 text-[8px]"
|
||||
>
|
||||
{monat.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Nur jeder zweite Wochentag, sonst steht es zu eng. */}
|
||||
{WOCHENTAGE.map((tag, index) =>
|
||||
index % 2 === 1 ? (
|
||||
<text
|
||||
key={tag}
|
||||
x={0}
|
||||
y={kopf + index * spalte + feld - 2}
|
||||
className="fill-slate-600 text-[7px]"
|
||||
>
|
||||
{tag}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
{wochen.map((woche, x) =>
|
||||
woche.map((tag, y) =>
|
||||
tag.ausserhalb ? null : (
|
||||
<rect
|
||||
key={tag.key}
|
||||
x={x * spalte + 22}
|
||||
y={kopf + y * spalte}
|
||||
width={feld}
|
||||
height={feld}
|
||||
rx={2}
|
||||
className={FARBEN[stufe(tag.anzahl, hoechstwert)]}
|
||||
>
|
||||
<title>
|
||||
{`${formatDate(tag.key)}: ${tag.anzahl} ${tag.anzahl === 1 ? "Commit" : "Commits"}`}
|
||||
</title>
|
||||
</rect>
|
||||
),
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-end gap-1.5 text-[10px] text-slate-600">
|
||||
<span>weniger</span>
|
||||
{FARBEN.map((farbe, index) => (
|
||||
<svg key={index} width={9} height={9} aria-hidden>
|
||||
<rect width={9} height={9} rx={2} className={farbe} />
|
||||
</svg>
|
||||
))}
|
||||
<span>mehr</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CALENDAR_MAX_PAGES,
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
calendarSince,
|
||||
calendarTotal,
|
||||
dayKey,
|
||||
} from "./calendar";
|
||||
|
||||
/**
|
||||
* Der Kalender darf ueber den Zeitraum nicht luegen. Reichen die geholten
|
||||
* Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an – sie zeigt
|
||||
* dann keine leeren Felder fuer Zeiten, ueber die schlicht nichts bekannt ist.
|
||||
* Genau diesen Fehler hatte die Verlaufslinie schon einmal.
|
||||
*/
|
||||
|
||||
const JETZT = new Date("2026-07-26T12:00:00.000Z");
|
||||
const TAG = 86_400_000;
|
||||
|
||||
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
|
||||
function commits(anzahl: number, abTagen: number): string[] {
|
||||
return Array.from({ length: anzahl }, (_, index) =>
|
||||
new Date(JETZT.getTime() - (abTagen + index) * TAG).toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
describe("dayKey", () => {
|
||||
it("liefert das Datum ohne Uhrzeit", () => {
|
||||
expect(dayKey(new Date("2026-07-26T23:59:00.000Z"))).toBe("2026-07-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendarSince", () => {
|
||||
it("liegt ein Jahr zurueck", () => {
|
||||
const seit = calendarSince(JETZT);
|
||||
expect(Math.round((JETZT.getTime() - seit.getTime()) / TAG)).toBe(365);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendarByPaging", () => {
|
||||
it("liefert null, wenn es keine Commits gibt", async () => {
|
||||
expect(await calendarByPaging(async () => [], JETZT)).toBeNull();
|
||||
});
|
||||
|
||||
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
|
||||
const heute = JETZT.toISOString();
|
||||
const kalender = await calendarByPaging(async () => [heute, heute, heute], JETZT);
|
||||
expect(kalender?.days["2026-07-26"]).toBe(3);
|
||||
expect(calendarTotal(kalender!)).toBe(3);
|
||||
});
|
||||
|
||||
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
|
||||
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
|
||||
const kalender = await calendarByPaging(async () => commits(10, 0), JETZT);
|
||||
expect(kalender?.from).toBe("2026-07-17");
|
||||
expect(kalender?.to).toBe("2026-07-26");
|
||||
expect(kalender?.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("hoert auf, sobald eine Seite nicht mehr voll ist", async () => {
|
||||
const ladeSeite = vi.fn(async (seite: number) =>
|
||||
seite === 1 ? commits(CALENDAR_PAGE_SIZE, 0) : commits(5, CALENDAR_PAGE_SIZE),
|
||||
);
|
||||
await calendarByPaging(ladeSeite, JETZT);
|
||||
expect(ladeSeite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deckelt die Zahl der Seiten und sagt es", async () => {
|
||||
// Immer volle Seiten innerhalb des Zeitraums: ohne Deckel liefe das ewig.
|
||||
const ladeSeite = vi.fn(async () =>
|
||||
Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
|
||||
new Date(JETZT.getTime() - 3 * TAG).toISOString(),
|
||||
),
|
||||
);
|
||||
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||
expect(ladeSeite).toHaveBeenCalledTimes(CALENDAR_MAX_PAGES);
|
||||
expect(kalender?.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("bricht ab, wenn eine ganze Seite aelter ist als der Zeitraum", async () => {
|
||||
const ladeSeite = vi.fn(async (seite: number) =>
|
||||
seite === 1
|
||||
? commits(CALENDAR_PAGE_SIZE, 0)
|
||||
: Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
|
||||
new Date(JETZT.getTime() - 400 * TAG).toISOString(),
|
||||
),
|
||||
);
|
||||
await calendarByPaging(ladeSeite, JETZT);
|
||||
expect(ladeSeite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uebergeht Commits mit Datum in der Zukunft", async () => {
|
||||
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
|
||||
const kalender = await calendarByPaging(
|
||||
async () => [
|
||||
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
|
||||
JETZT.toISOString(),
|
||||
],
|
||||
JETZT,
|
||||
);
|
||||
expect(kalender?.to).toBe("2026-07-26");
|
||||
expect(calendarTotal(kalender!)).toBe(1);
|
||||
});
|
||||
|
||||
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
|
||||
const kalender = await calendarByPaging(
|
||||
async () => [null, "gestern", "", JETZT.toISOString()],
|
||||
JETZT,
|
||||
);
|
||||
expect(calendarTotal(kalender!)).toBe(1);
|
||||
});
|
||||
|
||||
it("laesst Tage ohne Commits weg", async () => {
|
||||
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
|
||||
const kalender = await calendarByPaging(
|
||||
async () => [JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()],
|
||||
JETZT,
|
||||
);
|
||||
expect(Object.keys(kalender!.days)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Commits je Tag ueber ein Jahr – die Grundlage der Heatmap.
|
||||
*
|
||||
* Getrennt vom uebrigen Abgleich, weil es zusaetzliche Aufrufe kostet: der
|
||||
* Kalender wird hoechstens einmal am Tag geholt. Von Stunde zu Stunde
|
||||
* aendert sich an einem Jahresueberblick ohnehin nichts Sichtbares.
|
||||
*
|
||||
* Wichtig ist die Ehrlichkeit ueber den Zeitraum. Wo es keine
|
||||
* Jahresstatistik gibt, reichen die Daten nur so weit zurueck wie die
|
||||
* abgerufenen Seiten – dann faengt die Heatmap dort an und tut nicht so, als
|
||||
* waere ein Jahr lang nichts passiert. Diesen Fehler hatte die Verlaufslinie
|
||||
* schon einmal.
|
||||
*/
|
||||
|
||||
/** Wie weit die Heatmap hoechstens zurueckreicht. */
|
||||
export const CALENDAR_DAYS = 365;
|
||||
|
||||
/**
|
||||
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind
|
||||
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug,
|
||||
* um kein fremdes Kontingent zu sprengen.
|
||||
*/
|
||||
export const CALENDAR_MAX_PAGES = 5;
|
||||
export const CALENDAR_PAGE_SIZE = 100;
|
||||
|
||||
export interface CommitCalendar {
|
||||
/** Erster Tag, fuer den wirklich Daten vorliegen (yyyy-mm-dd). */
|
||||
from: string;
|
||||
/** Letzter beruecksichtigter Tag (yyyy-mm-dd). */
|
||||
to: string;
|
||||
/** Nur Tage mit Commits: { "2026-07-26": 3 }. Die Nullen ergaenzt die Anzeige. */
|
||||
days: Record<string, number>;
|
||||
/**
|
||||
* "stats" = Jahresstatistik des Anbieters, der Zeitraum ist vollstaendig.
|
||||
* "commits" = so weit, wie die Seiten zurueckreichten.
|
||||
*/
|
||||
source: "stats" | "commits";
|
||||
/** Der Deckel hat gegriffen; aeltere Commits fehlen. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Tag im Format yyyy-mm-dd, in UTC – so liefern es die Anbieter. */
|
||||
export function dayKey(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Der Tag, ab dem der Kalender hoechstens reicht. */
|
||||
export function calendarSince(now: Date = new Date()): Date {
|
||||
return new Date(now.getTime() - CALENDAR_DAYS * 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt seitenweise Commit-Zeitpunkte und zaehlt sie zu Tagen zusammen.
|
||||
*
|
||||
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
|
||||
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
|
||||
* Zeitraum stammt, oder wenn der Deckel greift.
|
||||
*/
|
||||
export async function calendarByPaging(
|
||||
ladeSeite: (seite: number) => Promise<Array<string | null>>,
|
||||
now: Date = new Date(),
|
||||
): Promise<CommitCalendar | null> {
|
||||
const grenze = calendarSince(now).getTime();
|
||||
const days: Record<string, number> = {};
|
||||
let aeltester: number | null = null;
|
||||
let gedeckelt = false;
|
||||
|
||||
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
|
||||
const daten = await ladeSeite(seite);
|
||||
if (daten.length === 0) break;
|
||||
|
||||
let innerhalb = 0;
|
||||
for (const value of daten) {
|
||||
if (!value) continue;
|
||||
const time = Date.parse(value);
|
||||
if (Number.isNaN(time)) continue;
|
||||
// Ein Commit-Datum in der Zukunft kommt vor – falsch gestellte Uhren.
|
||||
if (time > now.getTime()) continue;
|
||||
if (time < grenze) continue;
|
||||
|
||||
innerhalb += 1;
|
||||
const key = dayKey(new Date(time));
|
||||
days[key] = (days[key] ?? 0) + 1;
|
||||
if (aeltester === null || time < aeltester) aeltester = time;
|
||||
}
|
||||
|
||||
// Nicht mehr voll: das war die letzte Seite.
|
||||
if (daten.length < CALENDAR_PAGE_SIZE) break;
|
||||
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
|
||||
if (innerhalb === 0) break;
|
||||
|
||||
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
|
||||
}
|
||||
|
||||
if (aeltester === null) return null;
|
||||
|
||||
return {
|
||||
from: dayKey(new Date(aeltester)),
|
||||
to: dayKey(now),
|
||||
days,
|
||||
source: "commits",
|
||||
truncated: gedeckelt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wie viele Commits der Kalender insgesamt kennt. */
|
||||
export function calendarTotal(calendar: CommitCalendar): number {
|
||||
return Object.values(calendar.days).reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
calendarSince,
|
||||
type CommitCalendar,
|
||||
} from "./calendar";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
@@ -219,6 +225,27 @@ export class GiteaClient implements GitClient {
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea kennt keine Jahresstatistik ueber die API, also seitenweise.
|
||||
* `stat=false` und `verification=false` sparen dabei spuerbar Rechenzeit
|
||||
* auf der Gegenseite – gebraucht wird nur das Datum.
|
||||
*/
|
||||
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
|
||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
const seit = calendarSince().toISOString();
|
||||
|
||||
// getJson und nicht getJsonSafe: ein Rate-Limit darf nicht als "keine
|
||||
// Daten" durchgehen – siehe maybeRefreshCalendar.
|
||||
return calendarByPaging(async (seite) => {
|
||||
const commits = await getJson<GiteaCommit[]>(
|
||||
`${base}/commits?limit=${CALENDAR_PAGE_SIZE}&page=${seite}` +
|
||||
`&stat=false&verification=false&files=false&since=${encodeURIComponent(seit)}`,
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
return commits.map((commit) => toIso(commit.commit?.author?.date));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapCommitStatus(state: string): CiState {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
CiState,
|
||||
CommitCalendar,
|
||||
CommitInfo,
|
||||
ConnectionCheck,
|
||||
GitClient,
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
TagInfo,
|
||||
TicketInfo,
|
||||
} from "./types";
|
||||
import { dayKey } from "./calendar";
|
||||
import { GitApiError, bucketCommitActivity } from "./types";
|
||||
|
||||
/**
|
||||
@@ -206,6 +208,58 @@ export class GitHubClient implements GitClient {
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub fuehrt die Statistik selbst: /stats/commit_activity liefert 52
|
||||
* Wochen mit Tageswerten in einem einzigen Aufruf. Das ist deutlich
|
||||
* sparsamer, als ein Jahr Commits seitenweise abzuholen.
|
||||
*
|
||||
* Die Statistik wird bei Bedarf erst berechnet; dann antwortet GitHub mit
|
||||
* 202 und einem leeren Rumpf – daher der leere Fall unten.
|
||||
*
|
||||
* Bewusst getJson und nicht getJsonSafe: ein Rate-Limit oder ein Ausfall
|
||||
* darf nicht als "keine Daten" durchgehen. Der Fehler geht nach oben, und
|
||||
* der naechste Abgleich versucht es erneut.
|
||||
*/
|
||||
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
|
||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
const wochen = await getJson<GitHubWeek[]>(`${base}/stats/commit_activity`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!Array.isArray(wochen) || wochen.length === 0) {
|
||||
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
|
||||
// Ersatzweise aus den geholten Commits zu bauen waere irrefuehrend –
|
||||
// das taete so, als reichte der Zeitraum ein Jahr zurueck.
|
||||
return null;
|
||||
}
|
||||
|
||||
const days: Record<string, number> = {};
|
||||
let von: string | null = null;
|
||||
const heute = dayKey(new Date());
|
||||
|
||||
for (const woche of wochen) {
|
||||
if (!woche?.week || !Array.isArray(woche.days)) continue;
|
||||
woche.days.forEach((anzahl, index) => {
|
||||
const tag = dayKey(new Date((woche.week + index * 86_400) * 1000));
|
||||
if (tag > heute) return;
|
||||
if (von === null || tag < von) von = tag;
|
||||
if (anzahl > 0) days[tag] = anzahl;
|
||||
});
|
||||
}
|
||||
|
||||
if (von === null) return null;
|
||||
|
||||
return { from: von, to: heute, days, source: "stats", truncated: false };
|
||||
}
|
||||
}
|
||||
|
||||
interface GitHubWeek {
|
||||
/** Unix-Zeit des Wochenanfangs (Sonntag). */
|
||||
week: number;
|
||||
/** Sieben Werte, Sonntag bis Samstag. */
|
||||
days: number[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
|
||||
|
||||
+32
-3
@@ -1,4 +1,10 @@
|
||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||
import {
|
||||
CALENDAR_PAGE_SIZE,
|
||||
calendarByPaging,
|
||||
calendarSince,
|
||||
type CommitCalendar,
|
||||
} from "./calendar";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
@@ -76,10 +82,13 @@ export class GitLabClient implements GitClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** "gruppe/sub/projekt" URL-kodiert – so adressiert GitLab ein Projekt. */
|
||||
private projectPath(ref: RepoRef): string {
|
||||
return encodeURIComponent([ref.owner, ref.name].filter(Boolean).join("/"));
|
||||
}
|
||||
|
||||
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
|
||||
const path = [ref.owner, ref.name].filter(Boolean).join("/");
|
||||
const encoded = encodeURIComponent(path);
|
||||
const base = `${this.apiBase}/projects/${encoded}`;
|
||||
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
||||
const headers = this.headers();
|
||||
|
||||
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
|
||||
@@ -198,6 +207,26 @@ export class GitLabClient implements GitClient {
|
||||
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GitLab kennt keine fertige Jahresstatistik, also seitenweise mit `since`.
|
||||
* Der Deckel in calendarByPaging begrenzt das auf wenige Aufrufe.
|
||||
*/
|
||||
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
|
||||
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
||||
const seit = calendarSince().toISOString();
|
||||
|
||||
// getJson und nicht getJsonSafe: ein Rate-Limit darf nicht als "keine
|
||||
// Daten" durchgehen – siehe maybeRefreshCalendar.
|
||||
return calendarByPaging(async (seite) => {
|
||||
const commits = await getJson<GitLabCommit[]>(
|
||||
`${base}/repository/commits?per_page=${CALENDAR_PAGE_SIZE}&page=${seite}` +
|
||||
`&since=${encodeURIComponent(seit)}`,
|
||||
{ headers: this.headers() },
|
||||
);
|
||||
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapPipelineState(status: string | null): CiState {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { GitProvider } from "@prisma/client";
|
||||
import type { CommitCalendar } from "./calendar";
|
||||
|
||||
export type { CommitCalendar };
|
||||
|
||||
export type ProviderKind = GitProvider;
|
||||
|
||||
@@ -171,6 +174,13 @@ export interface GitClient {
|
||||
listRepos(query?: string): Promise<RepoListItem[]>;
|
||||
/** Vollstaendiger Stand eines Repositories. */
|
||||
fetchRepo(ref: RepoRef): Promise<RepoSnapshotData>;
|
||||
/**
|
||||
* Commits je Tag ueber ein Jahr – fuer die Heatmap.
|
||||
*
|
||||
* Getrennt von fetchRepo, weil es zusaetzliche Aufrufe kostet und nur
|
||||
* hoechstens einmal am Tag noetig ist. null heisst: nichts zu holen.
|
||||
*/
|
||||
fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null>;
|
||||
}
|
||||
|
||||
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Repository } from "@prisma/client";
|
||||
import { recordActivity, shorten } from "@/lib/activity";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
||||
import type { GitClient } from "@/lib/git/types";
|
||||
import { notifyAfterSync } from "@/lib/notify";
|
||||
import { repoTitle } from "@/lib/repositories";
|
||||
import type { RepoSnapshotData } from "@/lib/git/types";
|
||||
@@ -57,6 +58,9 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
||||
try {
|
||||
const data = await client.fetchRepo(ref);
|
||||
await persistSnapshot(repository, data);
|
||||
// Erst nach dem Snapshot: die Zeile muss existieren, bevor der Kalender
|
||||
// hineingeschrieben werden kann.
|
||||
await maybeRefreshCalendar(repository.id, client, ref);
|
||||
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||
await notifyAfterSync(repository.id);
|
||||
return {
|
||||
@@ -141,6 +145,57 @@ export async function syncProject(projectId: string): Promise<ProjectSyncResult>
|
||||
};
|
||||
}
|
||||
|
||||
/** Wie lange ein geholter Kalender genuegt. */
|
||||
const CALENDAR_TTL_MS = 20 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Holt die Jahresuebersicht fuer die Heatmap – hoechstens einmal am Tag.
|
||||
*
|
||||
* Sie kostet zusaetzliche Aufrufe, bei GitLab und Gitea sogar mehrere. An
|
||||
* einem Jahresueberblick aendert sich von Stunde zu Stunde aber nichts
|
||||
* Sichtbares, und der Abgleich laeuft im Zweifel stuendlich.
|
||||
*
|
||||
* Fehler bleiben hier: der Kalender ist Beiwerk. Wenn er nicht zu holen ist,
|
||||
* soll deshalb nicht der ganze Abgleich als gescheitert gelten.
|
||||
*/
|
||||
async function maybeRefreshCalendar(
|
||||
repositoryId: string,
|
||||
client: GitClient,
|
||||
ref: { owner: string; name: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const snapshot = await prisma.repoSnapshot.findUnique({
|
||||
where: { repositoryId },
|
||||
select: { commitCalendarAt: true },
|
||||
});
|
||||
|
||||
const zuletzt = snapshot?.commitCalendarAt?.getTime() ?? 0;
|
||||
if (Date.now() - zuletzt < CALENDAR_TTL_MS) return;
|
||||
|
||||
const calendar = await client.fetchCommitCalendar(ref);
|
||||
|
||||
// Nichts erhalten: entweder rechnet GitHub die Statistik gerade erst,
|
||||
// oder das Repository hat keine Commits. Der Zeitstempel bleibt dann
|
||||
// ungesetzt, damit es der naechste Abgleich erneut versucht – ihn hier zu
|
||||
// setzen wuerde die Heatmap fuer einen Tag unterdruecken, obwohl sie
|
||||
// vielleicht schon in Minuten bereitsteht.
|
||||
if (!calendar) return;
|
||||
|
||||
await prisma.repoSnapshot.update({
|
||||
where: { repositoryId },
|
||||
data: {
|
||||
commitCalendar: calendar as unknown as object,
|
||||
commitCalendarAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
|
||||
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
|
||||
// der naechste Durchgang von selbst wieder.
|
||||
console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
|
||||
export function resolveRepoRef(
|
||||
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
Task,
|
||||
TaskStatus,
|
||||
} from "@prisma/client";
|
||||
import type { CommitCalendar } from "@/lib/git/calendar";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CiInfo,
|
||||
@@ -83,6 +84,7 @@ export interface RepoListEntry {
|
||||
export type {
|
||||
ActivityKind,
|
||||
BranchInfo,
|
||||
CommitCalendar,
|
||||
CiInfo,
|
||||
CommitInfo,
|
||||
Note,
|
||||
@@ -101,6 +103,25 @@ export function asArray<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? (value as T[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Commit-Kalender aus dem Snapshot. Prisma liefert Json als unknown, und
|
||||
* ein Datensatz aus der Zeit vor der Heatmap hat dort ein leeres Objekt –
|
||||
* beides muss hier zu null werden, statt die Anzeige zu zerlegen.
|
||||
*/
|
||||
export function asCalendar(value: unknown): CommitCalendar | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const kandidat = value as Partial<CommitCalendar>;
|
||||
if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string") return null;
|
||||
if (!kandidat.days || typeof kandidat.days !== "object") return null;
|
||||
return {
|
||||
from: kandidat.from,
|
||||
to: kandidat.to,
|
||||
days: kandidat.days as Record<string, number>,
|
||||
source: kandidat.source === "stats" ? "stats" : "commits",
|
||||
truncated: Boolean(kandidat.truncated),
|
||||
};
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, number> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, number>;
|
||||
|
||||
Reference in New Issue
Block a user