diff --git a/prisma/migrations/20260726250000_commit_calendar/migration.sql b/prisma/migrations/20260726250000_commit_calendar/migration.sql new file mode 100644 index 0000000..b4f1113 --- /dev/null +++ b/prisma/migrations/20260726250000_commit_calendar/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "RepoSnapshot" ADD COLUMN "commitCalendar" JSONB NOT NULL DEFAULT '{}', +ADD COLUMN "commitCalendarAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 181cea4..bf575a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 0a7caf1..422b900 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -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(snapshot?.pullRequests); const issues = asArray(snapshot?.issues); const activity = asArray(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({ ) : null} + {/* Der Jahresueberblick, sobald er einmal geholt wurde. */} + {calendar ? : null} + {/* Sprachen */} {languageTotal > 0 ? (
diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx new file mode 100644 index 0000000..ac10d9e --- /dev/null +++ b/src/components/Heatmap.tsx @@ -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> = []; + + 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 ( +
+
+

+ + Commits je Tag +

+

+ {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)" : ""} +

+
+ +
+ + {monate.map((monat, index) => ( + + {monat.label} + + ))} + + {/* Nur jeder zweite Wochentag, sonst steht es zu eng. */} + {WOCHENTAGE.map((tag, index) => + index % 2 === 1 ? ( + + {tag} + + ) : null, + )} + + {wochen.map((woche, x) => + woche.map((tag, y) => + tag.ausserhalb ? null : ( + + + {`${formatDate(tag.key)}: ${tag.anzahl} ${tag.anzahl === 1 ? "Commit" : "Commits"}`} + + + ), + ), + )} + +
+ +
+ weniger + {FARBEN.map((farbe, index) => ( + + + + ))} + mehr +
+
+ ); +} diff --git a/src/lib/git/calendar.test.ts b/src/lib/git/calendar.test.ts new file mode 100644 index 0000000..47838be --- /dev/null +++ b/src/lib/git/calendar.test.ts @@ -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); + }); +}); diff --git a/src/lib/git/calendar.ts b/src/lib/git/calendar.ts new file mode 100644 index 0000000..d500874 --- /dev/null +++ b/src/lib/git/calendar.ts @@ -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; + /** + * "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>, + now: Date = new Date(), +): Promise { + const grenze = calendarSince(now).getTime(); + const days: Record = {}; + 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); +} diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts index dc839b5..40529ae 100644 --- a/src/lib/git/gitea.ts +++ b/src/lib/git/gitea.ts @@ -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 { + 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( + `${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 { diff --git a/src/lib/git/github.ts b/src/lib/git/github.ts index c5156f5..1ad08e6 100644 --- a/src/lib/git/github.ts +++ b/src/lib/git/github.ts @@ -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 { + const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; + const wochen = await getJson(`${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 = {}; + 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. */ diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts index 9031671..984b089 100644 --- a/src/lib/git/gitlab.ts +++ b/src/lib/git/gitlab.ts @@ -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 { - 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(`${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 { + 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( + `${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 { diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts index 654d359..5eec8be 100644 --- a/src/lib/git/types.ts +++ b/src/lib/git/types.ts @@ -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; /** Vollstaendiger Stand eines Repositories. */ fetchRepo(ref: RepoRef): Promise; + /** + * 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; } /** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */ diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts index 94e17eb..437c7bc 100644 --- a/src/lib/sync/index.ts +++ b/src/lib/sync/index.ts @@ -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 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 }; } +/** 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 { + 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, diff --git a/src/types/index.ts b/src/types/index.ts index 91d2068..f3ac21f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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(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; + 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, + source: kandidat.source === "stats" ? "stats" : "commits", + truncated: Boolean(kandidat.truncated), + }; +} + export function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record;