Skip to content

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.

json
{
  "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:

FeldBedeutung
idEindeutige ID, Format prilog-{name}, unveraenderlich nach Veroeffentlichung
typeB = Backend-Modul mit eigener DB und API-Routes
permissionsNur was das Modul tatsaechlich braucht. Hier: Spaces lesen + Events abonnieren
featureFlagWird im Bootstrap geprüft — Frontend zeigt Modul nur wenn Flag aktiv
storeCategoryKategorie im Modul-Store

2. Datenbankmodell

Das Plugin besitzt genau eine Tabelle: space_activity_days. Sie speichert einen Zaehler pro Space pro Tag.

Prisma-Schema

prisma
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
  • count statt Events — Aggregation auf DB-Ebene, nicht im Frontend. Spart Speicher und Bandbreite
  • @db.Date statt DateTime — nur das Datum, keine Uhrzeit noetig

SQL-Migration

sql
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:

typescript
// 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:

typescript
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:

typescript
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.

typescript
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?

AktionRouteMethode
Aufgabe erstellen/spaces/:id/boards/:boardId/itemsPOST
Aufgabe bearbeiten/spaces/:id/items/:itemIdPATCH
Aufgabe verschieben/spaces/:id/items/:itemId/movePATCH
Aufgabe aus Nachricht/spaces/:id/items/from-messagePOST
Datei hochladen/spaces/:id/files/uploadPOST
Datei verschieben/spaces/:id/files/:fileId/movePATCH
Ordner erstellen/spaces/:id/files/foldersPOST
Board erstellen/spaces/:id/boardsPOST
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.

typescript
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:

json
{
  "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:

typescript
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:

LevelLight ModeDark ModeBedeutung
0#ebedf0#161b22Keine Aktivitaet
1#9be9a8#0e4429Wenig
2#40c463#006d32Mittel
3#30a14e#26a641Hoch
4#216e39#39d353Sehr hoch

Level-Berechnung

typescript
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

tsx
// 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 Model

7. Checkliste fuer eigene Plugins

Wenn du ein eigenes Plugin nach diesem Muster baust, pruefe:

  • [ ] prilog-module.json mit korrekter ID, Typ und Berechtigungen
  • [ ] Prisma-Modell mit tenantId fuer Tenant-Isolation
  • [ ] @@unique Constraint wo noetig (verhindert Duplikate)
  • [ ] register() / unregister() / cleanup() implementiert
  • [ ] Fastify-Plugin in ALL_MODULES registriert
  • [ ] 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)