Referenz-Plugin: Activity Heatmap
Dieses Kapitel dokumentiert die vollstaendige Entwicklung eines Prilog-Plugins am Beispiel der Activity Heatmap. Das Plugin zeigt eine GitHub-style Aktivitaetsgrafik im Space-Info-Panel an.
Es dient als Lehrbeispiel fuer Modul-Entwickler und deckt alle Phasen ab: Manifest, Backend, Datenbank, API, Frontend-Komponente und Integration.
Ueberblick
┌─────────────────────────────────────────────────────────┐
│ Activity Heatmap Plugin │
│ │
│ Typ: B (Backend + Frontend) │
│ Kategorie: Analytics │
│ Feature: GitHub-style Contribution Graph │
│ │
│ Backend: 2 API-Endpoints (GET + POST) │
│ DB: 1 Tabelle (space_activity_days) │
│ Frontend: 1 SVG-Komponente (ActivityHeatmap) │
│ │
└─────────────────────────────────────────────────────────┘1. Modul-Manifest (prilog-module.json)
Jedes Plugin beginnt mit dem Manifest. Es definiert Identitaet, Typ, Berechtigungen und Metadaten.
{
"id": "prilog-activity-heatmap",
"name": "Activity Heatmap",
"version": "1.0.0",
"type": "B",
"prilogCoreVersion": ">=1.0.0",
"description": "GitHub-style activity heatmap for spaces.",
"author": {
"name": "Prilog Team",
"email": "dev@prilog.chat"
},
"license": "proprietary",
"permissions": ["spaces:read", "events:subscribe"],
"eventSubscriptions": ["space.created"],
"featureFlag": "activity-heatmap",
"storeCategory": "analytics"
}Erklaerung der Felder:
| Feld | Bedeutung |
|---|---|
id | Eindeutige ID, Format prilog-{name}, unveraenderlich nach Veroeffentlichung |
type | B = Backend-Modul mit eigener DB und API-Routes |
permissions | Nur was das Modul tatsaechlich braucht. Hier: Spaces lesen + Events abonnieren |
featureFlag | Wird im Bootstrap geprüft — Frontend zeigt Modul nur wenn Flag aktiv |
storeCategory | Kategorie im Modul-Store |
2. Datenbankmodell
Das Plugin besitzt genau eine Tabelle: space_activity_days. Sie speichert einen Zaehler pro Space pro Tag.
Prisma-Schema
model SpaceActivityDay {
id String @id @default(cuid())
tenantId String @map("tenant_id") @db.VarChar(64)
spaceId String @map("space_id") @db.VarChar(50)
date DateTime @db.Date
count Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([tenantId, spaceId, date])
@@index([tenantId, spaceId])
@@map("space_activity_days")
}Design-Entscheidungen:
- Unique Constraint auf
(tenantId, spaceId, date)— maximal ein Eintrag pro Tag pro Space - Tenant-Isolation ueber
tenantId— Pflicht fuer alle Modul-Tabellen countstatt Events — Aggregation auf DB-Ebene, nicht im Frontend. Spart Speicher und Bandbreite@db.Datestatt DateTime — nur das Datum, keine Uhrzeit noetig
SQL-Migration
CREATE TABLE space_activity_days (
id VARCHAR(30) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
space_id VARCHAR(50) NOT NULL,
date DATE NOT NULL,
count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, space_id, date)
);
CREATE INDEX idx_activity_days_tenant_space
ON space_activity_days(tenant_id, space_id);3. Backend: Modul-Entry-Point (index.ts)
Der Entry-Point implementiert die drei Lifecycle-Methoden aus dem Handbuch:
// register() — Modul aktivieren
export async function register(ctx: PrilogModuleContext): Promise<void> {
logger.info({ tenantId: ctx.tenantId }, 'Activity Heatmap registered');
}
// unregister() — Modul deaktivieren (Daten bleiben 30 Tage)
export async function unregister(ctx: PrilogModuleContext): Promise<void> {
logger.info({ tenantId: ctx.tenantId }, 'Activity Heatmap unregistered');
}
// cleanup() — Nach 30 Tagen: Daten endgueltig loeschen
export async function cleanup(tenantId: string): Promise<void> {
const { prisma } = await import('../../config/database.js');
await prisma.spaceActivityDay.deleteMany({ where: { tenantId } });
}Und das Fastify-Plugin fuer die Route-Registrierung:
export const activityHeatmapModule = {
key: 'activity-heatmap',
name: manifest.name,
description: manifest.description,
async register(server: FastifyInstance) {
await server.register(heatmapRouter);
},
async onStartup() {
// Keine Startup-Tasks noetig
},
};Registrierung in src/modules/index.ts:
import { activityHeatmapModule } from './activity-heatmap/index.js';
const ALL_MODULES = [
projectModule,
chatModule,
calendarModule,
activityHeatmapModule, // ← Neues Modul hinzufuegen
];4. Backend: API-Router (heatmap.router.ts)
Der Router hat drei Aufgaben: zwei Endpoints bereitstellen und automatisches Tracking via Hook.
Auto-Tracking: onResponse Hook
Das Kernfeature des Plugins: Ein Fastify onResponse-Hook der automatisch alle Space-Aktivitaeten zaehlt — ohne andere Module zu veraendern.
server.addHook('onResponse', (request, reply, done) => {
// Nur Mutationen tracken (POST/PUT/PATCH/DELETE)
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
done(); return;
}
// Nur erfolgreiche Responses (2xx)
if (reply.statusCode < 200 || reply.statusCode >= 300) {
done(); return;
}
// SpaceId aus URL extrahieren: /spaces/:id/...
const match = url.match(/^\/spaces\/([^/]+)\//);
if (!match) { done(); return; }
const spaceId = decodeURIComponent(match[1]);
const tenantId = request.user?.tenantId;
// Fire-and-forget: blockiert nie die Response
trackActivity(tenantId, spaceId).catch(() => {});
done();
});Was wird automatisch gezaehlt?
| Aktion | Route | Methode |
|---|---|---|
| Aufgabe erstellen | /spaces/:id/boards/:boardId/items | POST |
| Aufgabe bearbeiten | /spaces/:id/items/:itemId | PATCH |
| Aufgabe verschieben | /spaces/:id/items/:itemId/move | PATCH |
| Aufgabe aus Nachricht | /spaces/:id/items/from-message | POST |
| Datei hochladen | /spaces/:id/files/upload | POST |
| Datei verschieben | /spaces/:id/files/:fileId/move | PATCH |
| Ordner erstellen | /spaces/:id/files/folders | POST |
| Board erstellen | /spaces/:id/boards | POST |
| Kalender-Event | /spaces/:id/calendar/... | POST |
Warum onResponse statt Event-Bus?
- Self-contained: Das Plugin aendert keinen Code in anderen Modulen
- Vollstaendig: Jede Space-Mutation wird erfasst, auch zukuenftige
- Fire-and-forget: Die eigentliche Response wird nie verzoegert
- Fehlertolerant: Tracking-Fehler werden geloggt, aber nie zum User propagiert
GET /spaces/:id/activity/heatmap
Liefert 365 Tage Aktivitaetsdaten als Array. Leere Tage werden mit count: 0 aufgefuellt.
server.get('/spaces/:id/activity/heatmap', async (request, reply) => {
// 1. JWT pruefen
const jwt = getPrilogJwt(request, reply);
if (!jwt || !jwt.tenantId) return;
// 2. Space-Berechtigung pruefen
const scope = await getAuthScope(jwt, spaceId);
if (!ensurePermission(scope, 'space:view', reply)) return;
// 3. Letzte 365 Tage aus DB laden
const rows = await prisma.spaceActivityDay.findMany({
where: { tenantId, spaceId, date: { gte: since } },
orderBy: { date: 'asc' },
});
// 4. Luecken mit count=0 fuellen
const days = fillAllDays(rows, since, today);
// 5. Summary berechnen
return reply.send({ days, summary: { total, activeDays, maxCount } });
});Response-Format:
{
"days": [
{ "date": "2025-04-03", "count": 0 },
{ "date": "2025-04-04", "count": 7 },
...
],
"summary": {
"total": 1842,
"activeDays": 287,
"maxCount": 13,
"periodDays": 365
}
}POST /spaces/:id/activity/track (manuell)
Fuer Faelle die nicht ueber die Platform-API laufen (z.B. externe Integrationen). Inkrementiert den Tages-Zaehler um 1:
await prisma.spaceActivityDay.upsert({
where: {
tenantId_spaceId_date: { tenantId, spaceId, date: today },
},
create: { tenantId, spaceId, date: today, count: 1 },
update: { count: { increment: 1 } },
});Warum Upsert? Der erste Aufruf am Tag erzeugt den Eintrag, alle weiteren inkrementieren nur. Race-Condition-sicher durch den Unique Constraint.
5. Frontend: Heatmap-Komponente
Die Komponente rendert ein SVG mit 53 Spalten (Wochen) x 7 Zeilen (Tage):
Architektur
┌──────────────────────────────────────────────────┐
│ Jan Feb Mär Apr Mai Jun ... ← Monate │
│ Mo ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░▓▓░░░░░░▓░░░░░░▓▓▓░░░░░░░░░░░░ │
│ Mi ░░▓▓▓░░░░▓▓░░░░░▓▓▓▓░░░░░░░░░░░░ ← Zellen │
│ ░░░▓░░░░░░▓░░░░░░▓▓░░░░░░░░░░░░░ │
│ Fr ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │
│ Weniger ░▒▓█ Mehr ← Legende │
└──────────────────────────────────────────────────┘Farbpalette
5 Stufen, angepasst an Light- und Dark-Mode:
| Level | Light Mode | Dark Mode | Bedeutung |
|---|---|---|---|
| 0 | #ebedf0 | #161b22 | Keine Aktivitaet |
| 1 | #9be9a8 | #0e4429 | Wenig |
| 2 | #40c463 | #006d32 | Mittel |
| 3 | #30a14e | #26a641 | Hoch |
| 4 | #216e39 | #39d353 | Sehr hoch |
Level-Berechnung
function getLevel(count: number, maxCount: number): number {
if (count === 0) return 0;
const ratio = count / maxCount;
if (ratio <= 0.25) return 1;
if (ratio <= 0.50) return 2;
if (ratio <= 0.75) return 3;
return 4;
}Die Levels sind relativ zum Maximum — nicht absolut. Ein Space mit max. 5 Aktivitaeten/Tag zeigt genauso viel Kontrast wie einer mit 50.
Integration im Space-Info-Panel
// space-info-panel.tsx
import { ActivityHeatmap } from './panels/activity-heatmap';
// Im JSX, nach Beschreibung und vor Mitgliedern:
<div>
<h4>Aktivitaet</h4>
<ActivityHeatmap spaceId={space.id} />
</div>6. Dateistruktur
prilog-backend-api/
└── src/modules/activity-heatmap/
├── prilog-module.json ← Manifest
├── index.ts ← register/unregister/cleanup + Fastify-Plugin
└── heatmap.router.ts ← GET heatmap + POST track
prilog-web-client/
└── src/features/spaces/panels/
└── activity-heatmap.tsx ← SVG-Heatmap-Komponente
prisma/schema.prisma ← SpaceActivityDay Model7. Checkliste fuer eigene Plugins
Wenn du ein eigenes Plugin nach diesem Muster baust, pruefe:
- [ ]
prilog-module.jsonmit korrekter ID, Typ und Berechtigungen - [ ] Prisma-Modell mit
tenantIdfuer Tenant-Isolation - [ ]
@@uniqueConstraint wo noetig (verhindert Duplikate) - [ ]
register()/unregister()/cleanup()implementiert - [ ] Fastify-Plugin in
ALL_MODULESregistriert - [ ] Routes pruefen JWT via
getPrilogJwt() - [ ] Routes pruefen Space-Zugriff via
getAuthScope()+ensurePermission() - [ ] Frontend-Komponente laedt Daten ueber Platform-Gateway
- [ ] Light- und Dark-Mode unterstuetzt
- [ ] Keine Cross-Tenant-Datenzugriffe moeglich
- [ ] Auto-Tracking via
onResponse-Hook (fire-and-forget, nie blockierend) - [ ] Eigene Endpoints von Tracking ausgeschlossen (IGNORE_SUFFIXES)
8. Erweiterungsideen
Das Heatmap-Plugin kann als Basis fuer weitere Features dienen:
- Benutzer-Heatmap: Individuelle Aktivitaet pro User statt pro Space
- Detailansicht: Klick auf einen Tag zeigt die konkreten Aktivitaeten
- Export: CSV/PDF-Export der Aktivitaetsdaten
- Vergleich: Mehrere Spaces nebeneinander vergleichen
- Zeitraum-Auswahl: Letzter Monat, Quartal, Jahr umschalten
- Chat-Tracking: Matrix-Nachrichten via Synapse-Webhook zaehlen (laeuft nicht ueber Platform-API)