From eb9eab949e87fa75fb1e91fed5c4d881d7ff7adf Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Sun, 4 Oct 2026 00:15:50 +0200 Subject: [PATCH] Liegestuetze: Zaehler aus Kanalpunkten, Overlay und Korrektur von Hand Neue Aktion `liegestuetze` fuer Belohnungen: jede Einloesung zaehlt um `anzahl` hoch (Vorgabe 5). Der Stand liegt in der Datenbank und uebersteht einen Neustart. Browser-Source /liegestuetze zeigt ihn, die Uebersichtsseite hat Knoepfe zum Herunterzaehlen und Setzen. Nebenbei: POST /tts landete bei der Overlay-Seite statt bei der Freigabe. Seiten werden jetzt nur noch bei GET ausgeliefert. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/hub/src/index.ts | 26 +++ apps/hub/src/liegestuetze.test.ts | 68 ++++++++ apps/hub/src/liegestuetze.ts | 36 +++++ apps/hub/src/rewards.ts | 5 +- apps/overlay-server/src/bus.test.ts | 8 + apps/overlay-server/src/bus.ts | 23 ++- apps/overlay-server/src/server.ts | 105 +++++++++++- apps/overlay-web/public/liegestuetze.html | 186 ++++++++++++++++++++++ config/config.example.yaml | 5 +- packages/core/src/config.ts | 8 +- packages/db/migrations/003_zaehler.sql | 8 + 11 files changed, 469 insertions(+), 9 deletions(-) create mode 100644 apps/hub/src/liegestuetze.test.ts create mode 100644 apps/hub/src/liegestuetze.ts create mode 100644 apps/overlay-web/public/liegestuetze.html create mode 100644 packages/db/migrations/003_zaehler.sql diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 4dd93cc..0cd5d7d 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -37,6 +37,7 @@ import { ausEventSub, createDrossel, createWarteschlange, entscheiden, } from './rewards.ts'; import { createAudioAblage } from './audio-ablage.ts'; +import { createLiegestuetze } from './liegestuetze.ts'; import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts'; import { createSpikeDetector } from '../../ingest/src/spike.ts'; import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts'; @@ -95,6 +96,10 @@ export async function runHub(): Promise { // ---- Kanalpunkte const warteschlange = createWarteschlange(); const drossel = createDrossel(); + const liegestuetze = createLiegestuetze(db); + // Gleich zu Beginn in den Bus, sonst zeigt eine frisch verbundene + // Browser-Source 0, bis die naechste Einloesung kommt. + bus.publish({ kind: 'liegestuetze', offen: liegestuetze.stand() }); /** * Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben @@ -189,6 +194,11 @@ export async function runHub(): Promise { }); if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 }); if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 }); + if (ent.aktion === 'liegestuetze') { + const dazu = ent.plan.anzahl ?? 5; + const offen = liegestuetze.aendern(dazu); + bus.publish({ kind: 'liegestuetze', offen, dazu, name: e.userName }); + } log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`); void einloesungSetzen(e.rewardId, e.id, 'FULFILLED'); break; @@ -321,6 +331,7 @@ export async function runHub(): Promise { goLive: announcer.lastResult() ?? 'noch nichts', overlayClients: bus.clients(), ttsWartend: warteschlange.liste().length, + liegestuetzeOffen: liegestuetze.stand(), ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit', spikeArmed: spikes.isArmed(), note: state.chatAnonymous @@ -360,6 +371,21 @@ export async function runHub(): Promise { log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert'); }, }, + liegestuetze: { + stand: () => liegestuetze.stand(), + aendern: (n) => { + const offen = liegestuetze.aendern(n); + bus.publish({ kind: 'liegestuetze', offen }); + log.info(`Liegestuetze von Hand ${n > 0 ? '+' : ''}${n} -> ${offen} offen`); + return offen; + }, + setzen: (n) => { + const offen = liegestuetze.setzen(n); + bus.publish({ kind: 'liegestuetze', offen }); + log.info(`Liegestuetze von Hand gesetzt -> ${offen} offen`); + return offen; + }, + }, }); void server; diff --git a/apps/hub/src/liegestuetze.test.ts b/apps/hub/src/liegestuetze.test.ts new file mode 100644 index 0000000..85a6325 --- /dev/null +++ b/apps/hub/src/liegestuetze.test.ts @@ -0,0 +1,68 @@ +// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben. Er +// darf weder beim Neustart vergessen noch ins Negative laufen. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { migrate } from '#db/index.ts'; +import { createLiegestuetze, HOECHSTENS } from './liegestuetze.ts'; +import { entscheiden, type RewardsConfig } from './rewards.ts'; +import { zahlAus } from '../../overlay-server/src/server.ts'; + +function datei(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-lz-')); + return path.join(dir, 't.db'); +} + +function oeffnen(file: string): DatabaseSync { + const db = new DatabaseSync(file); + migrate(db); + return db; +} + +test('Liegestuetze: zaehlt hoch und herunter, nie unter 0', () => { + const lz = createLiegestuetze(oeffnen(datei())); + assert.equal(lz.stand(), 0); + assert.equal(lz.aendern(10), 10); + assert.equal(lz.aendern(10), 20); + assert.equal(lz.aendern(-5), 15); + assert.equal(lz.aendern(-100), 0); + assert.equal(lz.setzen(42), 42); + assert.equal(lz.setzen(-3), 0); + assert.equal(lz.setzen(10_000_000), HOECHSTENS); +}); + +test('Liegestuetze: der Stand ueberlebt einen Neustart', () => { + const file = datei(); + const erst = oeffnen(file); + createLiegestuetze(erst).aendern(30); + erst.close(); + + assert.equal(createLiegestuetze(oeffnen(file)).stand(), 30); +}); + +test('Belohnung mit Aktion liegestuetze wird ausgefuehrt und traegt die Anzahl', () => { + const cfg: RewardsConfig = { + enabled: true, + belohnungen: [{ titel: '5 Liegestütze', aktion: 'liegestuetze', anzahl: 5 }], + tts: { freigabe: true, maxZeichen: 200, blockliste: [], proPersonProStunde: 3 }, + }; + const ent = entscheiden({ + id: 'r1', rewardId: 'x', rewardTitle: '5 liegestütze', userId: 'u1', userName: 'Kevin', + text: '', kosten: 2000, ts: '2026-10-04T20:00:00Z', + }, cfg); + assert.equal(ent.art, 'ausfuehren'); + assert.ok(ent.art === 'ausfuehren' && ent.aktion === 'liegestuetze' && ent.plan.anzahl === 5); +}); + +test('zahlAus: nur ganze Zahlen, alles andere ist null', () => { + assert.equal(zahlAus('10'), 10); + assert.equal(zahlAus(' -5 '), -5); + assert.equal(zahlAus('+3'), 3); + for (const x of ['', 'abc', '1.5', '1e3', '9999999', null, undefined]) { + assert.equal(zahlAus(x), null, String(x)); + } +}); diff --git a/apps/hub/src/liegestuetze.ts b/apps/hub/src/liegestuetze.ts new file mode 100644 index 0000000..cf3c127 --- /dev/null +++ b/apps/hub/src/liegestuetze.ts @@ -0,0 +1,36 @@ +// Offene Liegestuetze aus Kanalpunkten. +// +// Eine Einloesung zaehlt hoch, Abdu zaehlt auf der Uebersichtsseite wieder +// herunter, wenn er sie gemacht hat - oder korrigiert, wenn etwas nicht +// stimmt. Der Stand liegt in der Datenbank: was die Zuschauer bezahlt haben, +// darf ein Neustart nicht loeschen. + +import type { Db } from '#db/index.ts'; + +/** Mehr glaubt niemand, und ein Tippfehler mit sechs Nullen soll nicht im Bild stehen. */ +export const HOECHSTENS = 99_999; + +export type Liegestuetze = { + stand: () => number; + /** Zaehlt um `n` hoch oder (negativ) herunter. Nie unter 0. Gibt den neuen Stand zurueck. */ + aendern: (n: number) => number; + /** Setzt den Stand fest. Gibt den neuen Stand zurueck. */ + setzen: (n: number) => number; +}; + +export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze { + const lesen = db.prepare('SELECT wert FROM zaehler WHERE name = ?'); + const schreiben = db.prepare(` + INSERT INTO zaehler (name, wert, geaendert) VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert + `); + + const stand = (): number => (lesen.get(name) as { wert: number } | undefined)?.wert ?? 0; + const setzen = (n: number): number => { + const wert = Math.min(HOECHSTENS, Math.max(0, Math.trunc(n))); + schreiben.run(name, wert, new Date().toISOString()); + return wert; + }; + + return { stand, setzen, aendern: (n) => setzen(stand() + n) }; +} diff --git a/apps/hub/src/rewards.ts b/apps/hub/src/rewards.ts index 6fc8cff..636d1f9 100644 --- a/apps/hub/src/rewards.ts +++ b/apps/hub/src/rewards.ts @@ -31,7 +31,8 @@ export type Einloesung = { ts: string; }; -export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; +export type AktionsArt = + | 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts'; export type BelohnungsPlan = { /** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */ @@ -43,6 +44,8 @@ export type BelohnungsPlan = { freigabe?: boolean; /** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */ maxZeichen?: number; + /** Nur bei `liegestuetze`: so viele kommen je Einloesung dazu. Vorgabe 5. */ + anzahl?: number; }; export type RewardsConfig = { diff --git a/apps/overlay-server/src/bus.test.ts b/apps/overlay-server/src/bus.test.ts index c1a9d82..c911f84 100644 --- a/apps/overlay-server/src/bus.test.ts +++ b/apps/overlay-server/src/bus.test.ts @@ -108,3 +108,11 @@ test('abgemeldete Zuhoerer bekommen nichts mehr', () => { assert.equal(n, 1); assert.equal(bus.clients(), 0); }); + +test('der Schnappschuss traegt den letzten Stand der Liegestuetze', () => { + const bus = createBus(); + assert.equal(schnappschuss(bus).liegestuetze, 0); + bus.publish({ kind: 'liegestuetze', offen: 20, dazu: 10, name: 'Kevin' }); + bus.publish({ kind: 'liegestuetze', offen: 15 }); + assert.equal(schnappschuss(bus).liegestuetze, 15); +}); diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts index d389946..7d9f605 100644 --- a/apps/overlay-server/src/bus.ts +++ b/apps/overlay-server/src/bus.ts @@ -65,6 +65,17 @@ export type StreamEvent = { startedAt: string | null; }; +/** + * Neuer Stand der offenen Liegestuetze. `dazu` und `name` nur bei einer + * Einloesung - eine Korrektur von Hand soll nicht aussehen wie eine. + */ +export type LiegestuetzeEvent = { + kind: 'liegestuetze'; + offen: number; + dazu?: number; + name?: string; +}; + export type OverlayEvent = | ChatEvent | AlertEvent @@ -88,10 +99,15 @@ export type OverlayEvent = } /** Alles sofort still. Fuer den Panikschalter. */ | { kind: 'still' } + | LiegestuetzeEvent | { kind: 'scene'; scene: string } | { kind: 'clear'; userId?: string; messageId?: string } | { kind: 'beat'; t: number } - | { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] }; + | { + kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[]; + /** Offene Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */ + liegestuetze: number; + }; /** * So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr @@ -118,6 +134,7 @@ export function createBus(): Bus { let stream: StreamEvent = { kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null, }; + let liegestuetze = 0; return { publish(ev) { @@ -131,6 +148,8 @@ export function createBus(): Bus { if (alerts.length > KEEP_ALERTS) alerts.shift(); } else if (ev.kind === 'stream') { stream = ev; + } else if (ev.kind === 'liegestuetze') { + liegestuetze = ev.offen; } else if (ev.kind === 'clear') { // Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der // naechste Neustart der Browser-Source es zurueck ins Bild. @@ -153,7 +172,7 @@ export function createBus(): Bus { return () => { listeners.delete(fn); }; }, - snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }), + snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }), clients: () => listeners.size, }; } diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts index 4761665..8e110b9 100644 --- a/apps/overlay-server/src/server.ts +++ b/apps/overlay-server/src/server.ts @@ -64,6 +64,8 @@ export const PAGES = [ title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' }, { path: '/tts', file: 'tts.html', w: 600, h: 200, title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' }, + { path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200, + title: 'Liegestütze', hint: 'Offene Liegestütze aus Kanalpunkten. Zählt bei Einlösung hoch, korrigieren unten auf dieser Seite.' }, ] as const; export type OverlayServer = { @@ -96,6 +98,12 @@ export type OverlayOptions = { /** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */ audio: (id: string) => Buffer | undefined; }; + /** Der Liegestuetz-Zaehler. Ohne ihn fehlt der Abschnitt auf der Seite. */ + liegestuetze?: { + stand: () => number; + aendern: (n: number) => number; + setzen: (n: number) => number; + }; }; export function startOverlayServer(opts: OverlayOptions): OverlayServer { @@ -167,7 +175,11 @@ async function handle( if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, '')); // ---- Die Browser-Sources und ihr Datenstrom. - const page = PAGES.find((x) => x.path === p || `/${x.file}` === p); + // Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der + // Uebersicht (POST, mit Anmeldung). Ohne die Einschraenkung bekamen die + // statt einer Antwort die Overlay-Seite - oder ohne Schluessel ein 403. + const page = req.method === 'GET' || req.method === 'HEAD' + ? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined; const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1]; if (page || p === '/events' || audioId) { if (!zugang.darfOverlay(req, url)) { @@ -276,6 +288,27 @@ async function handle( JSON.stringify({ wartend: opts.tts.wartend() })); } + // ---- Liegestuetze von Hand korrigieren + if (p === '/liegestuetze' && req.method === 'POST' && opts.liegestuetze) { + const form = await readForm(req); + const was = form.get('was') ?? ''; + const n = zahlAus(form.get('wert')); + const antwort = (text: string, code = 200): void => + send(res, code, 'application/json; charset=utf-8', + JSON.stringify({ text, offen: opts.liegestuetze!.stand() })); + + if (n === null) return antwort('Keine ganze Zahl.', 400); + if (was === 'aendern') { + const offen = opts.liegestuetze.aendern(n); + return antwort(`${n > 0 ? '+' : ''}${n}, jetzt ${offen} offen.`); + } + if (was === 'setzen') { + if (n < 0) return antwort('Weniger als 0 geht nicht.', 400); + return antwort(`Auf ${opts.liegestuetze.setzen(n)} gesetzt.`); + } + return antwort('Unbekannt.', 400); + } + if (p === '/schluessel/neu' && req.method === 'POST') { zugang.neuerOverlayKey(); return redirect(res, '/'); @@ -351,6 +384,13 @@ function readForm(req: http.IncomingMessage): Promise { }); } +/** Eingabe von der Seite in eine ganze Zahl. Alles andere ist `null`. */ +export function zahlAus(roh: string | null | undefined): number | null { + if (roh === null || roh === undefined) return null; + const s = roh.trim(); + return /^[+-]?\d{1,6}$/.test(s) ? Number(s) : null; +} + function redirect(res: http.ServerResponse, to: string): void { res.writeHead(303, { location: to }); res.end(); @@ -427,6 +467,11 @@ const SEITEN_CSS = ` gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; } .wartend:first-child { border-top: 0; } .wartend .hint { max-width: 60ch; word-break: break-word; } + .lz-stand { font: 700 3rem/1 "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif; + font-variant-numeric: tabular-nums; margin: .4rem 0 1rem; } + .lz-stand small { font-size: 1rem; font-weight: 400; color: #8b84a3; margin-left: .5rem; } + button.lz { margin-left: 0; min-width: 3.2rem; } + input.lz { width: 6.5rem; padding: .2rem .6rem; font-size: .85rem; } button.tts { margin-left: .4rem; } `; @@ -466,6 +511,25 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.

„Sofort still" bricht auch ab, was gerade gesprochen wird.

 

` : ''; + const lzFeld = opts.liegestuetze ? ` +

Liegestütze

+

Jede Einlösung zählt hoch. Hier herunterzählen, wenn sie gemacht +sind, oder den Stand korrigieren. Die Zuschauer sehen jede Änderung.

+
${opts.liegestuetze.stand()}offen
+
+ + + + + + +
+
+ + + +
+

 

` : ''; const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`; const rows = PAGES.map((p) => { const id = `u${p.path.slice(1)}`; @@ -524,6 +588,8 @@ ${testfeld} ${ttsFeld} +${lzFeld} +

Schlüssel

@@ -565,11 +631,48 @@ ${ttsFeld} ? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel') : 'verbunden - kein Stream'; setzeLive(ev.stream.live); + zeigeLiegestuetze(ev.liegestuetze); } else if (ev.kind === 'stream') { setzeLive(ev.live); + } else if (ev.kind === 'liegestuetze') { + zeigeLiegestuetze(ev.offen); } }; + // Der Stand kommt über den Ereignisstrom, damit eine Einlösung auch hier + // sofort sichtbar ist und zwei offene Fenster nicht auseinanderlaufen. + function zeigeLiegestuetze(n) { + const el = document.getElementById('lz-offen'); + if (el && typeof n === 'number') el.textContent = n.toLocaleString('de-DE'); + } + const lzMeldung = document.getElementById('lz-meldung'); + async function lzRuf(was, wert) { + const res = await fetch('/liegestuetze', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ was, wert: String(wert) }), + }); + const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status })); + zeigeLiegestuetze(body.offen); + lzMeldung.textContent = body.text || ''; + lzMeldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler'); + setTimeout(() => { lzMeldung.innerHTML = ' '; lzMeldung.className = 'meldung'; }, 4000); + } + for (const b of document.querySelectorAll('button.lz[data-um]')) { + b.addEventListener('click', () => lzRuf('aendern', b.dataset.um)); + } + const lzForm = document.getElementById('lz-form'); + if (lzForm) { + lzForm.addEventListener('submit', (e) => { + e.preventDefault(); + const feld = document.getElementById('lz-wert'); + if (feld.value === '') return; + lzRuf('setzen', feld.value); + feld.value = ''; + }); + document.getElementById('lz-null').addEventListener('click', () => lzRuf('setzen', 0)); + } + let sagAb; async function loese(art) { const res = await fetch('/test', { diff --git a/apps/overlay-web/public/liegestuetze.html b/apps/overlay-web/public/liegestuetze.html new file mode 100644 index 0000000..ec1a91e --- /dev/null +++ b/apps/overlay-web/public/liegestuetze.html @@ -0,0 +1,186 @@ + + + + +abdulive - Liegestütze + + + + +
+
+ +
+
Liegestütze
+ 0 +
+
+ + + + + diff --git a/config/config.example.yaml b/config/config.example.yaml index da9987a..87997d7 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -130,7 +130,7 @@ loyalty: # Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe # gelassen - nicht jede Belohnung geht das Toolkit etwas an. # -# Aktionen: konfetti | emote-regen | einblendung | tts | nichts +# Aktionen: konfetti | emote-regen | einblendung | tts | liegestuetze | nichts rewards: enabled: false # aus, bis hier bewusst Belohnungen stehen belohnungen: [] @@ -141,6 +141,9 @@ rewards: # - titel: Nachricht vorlesen # aktion: tts # maxZeichen: 150 + # - titel: 5 Liegestütze + # aktion: liegestuetze + # anzahl: 5 # so viele kommen je Einloesung dazu tts: # Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte # sammeln sich beim Zugucken von allein an und sind damit kaum eine diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7ea17d3..6ff0793 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -60,8 +60,8 @@ export type Config = { enabled: boolean; belohnungen: Array<{ titel?: string; id?: string; - aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; - freigabe?: boolean; maxZeichen?: number; + aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts'; + freigabe?: boolean; maxZeichen?: number; anzahl?: number; }>; tts: { freigabe: boolean; maxZeichen: number; @@ -135,8 +135,8 @@ const DEFAULTS = { enabled: false, belohnungen: [] as Array<{ titel?: string; id?: string; - aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; - freigabe?: boolean; maxZeichen?: number; + aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts'; + freigabe?: boolean; maxZeichen?: number; anzahl?: number; }>, tts: { // Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim diff --git a/packages/db/migrations/003_zaehler.sql b/packages/db/migrations/003_zaehler.sql new file mode 100644 index 0000000..ec29aeb --- /dev/null +++ b/packages/db/migrations/003_zaehler.sql @@ -0,0 +1,8 @@ +-- Zaehler, die einen Neustart ueberleben muessen. Erster Fall: offene +-- Liegestuetze aus Kanalpunkten. Wer 2000 Punkte ausgegeben hat, will nicht +-- erleben, dass ein Update des Hubs seine Liegestuetze verschluckt. +CREATE TABLE zaehler ( + name TEXT PRIMARY KEY, + wert INTEGER NOT NULL, + geaendert TEXT NOT NULL +);