Zeitplan in der Oberflaeche einstellbar
Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung -> Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht gelaufen wird. Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet. Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu, die der Community gehoeren. Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen Werte, sie stehen in der Migration. SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
This commit is contained in:
+3
-8
@@ -30,14 +30,9 @@ BOOTSTRAP_ADMIN_PASSWORD=
|
||||
# (Burning Board) zugeschnitten.
|
||||
# FORUM_BASE_URL=https://senseigaming.de/forum
|
||||
|
||||
# Wiederkehrende Abgleiche. Die Käufe werden alle zwei Stunden geprüft, bei
|
||||
# Spielern ohne Kauf in den letzten drei Jahren alle sechs; das Forum einmal
|
||||
# nachts. Gebucht wird dabei nichts von allein - neue Pakete landen in der
|
||||
# Import-Vorschau, neue Anträge in der Liste.
|
||||
# SCHEDULE_ENABLED=true
|
||||
|
||||
# Stunde des nächtlichen Forumslaufs (Ortszeit des Servers, Standard: 4).
|
||||
# FORUM_SCAN_HOUR=4
|
||||
# Die wiederkehrenden Abgleiche werden in der Anwendung eingestellt, unter
|
||||
# Verwaltung -> Zeitplan: ob sie laufen, in welchem Abstand, und beim Forum
|
||||
# ab welcher Stunde. Hier steht dazu nichts mehr.
|
||||
|
||||
# Aufbewahrung der Audit-Einträge in Tagen (Standard: 365, 0 = nie löschen).
|
||||
# AUDIT_RETENTION_DAYS=365
|
||||
|
||||
@@ -350,27 +350,31 @@ das Signal zu unzuverlässig.
|
||||
|
||||
### Was von allein läuft
|
||||
|
||||
Beide Abgleiche laufen nach Zeitplan, ohne dass jemand einen Knopf drücken muss:
|
||||
Beide Abgleiche laufen nach Zeitplan, ohne dass jemand einen Knopf drücken muss. Eingestellt
|
||||
wird das unter *Verwaltung → Zeitplan*, je Lauf einzeln:
|
||||
|
||||
| Lauf | Wann |
|
||||
| Einstellung | Bedeutung |
|
||||
| --- | --- |
|
||||
| **Käufe bei GExtension** | alle zwei Stunden |
|
||||
| dieselben, aber ruhende Konten | alle sechs Stunden |
|
||||
| **Forums-Anträge** | einmal nachts, gegen 4 Uhr |
|
||||
| **läuft automatisch** | Aus heißt: nur noch von Hand, über den Knopf auf der jeweiligen Seite. |
|
||||
| **Abstand** | In Minuten, mindestens 15 und höchstens eine Woche. Daneben steht, was das in Stunden bedeutet. |
|
||||
| **Ruhende Konten** | Nur beim Käufe-Abgleich: der seltenere Takt für Spieler, bei denen lange nichts gekauft wurde. |
|
||||
| **Ruhend ab** | Nach wie vielen Tagen ohne Kauf jemand als ruhend gilt. |
|
||||
| **Frühestens ab** | Nur beim Forumslauf: eine Stunde, vor der nicht gelaufen wird. Mit einem täglichen Abstand liegt der Lauf damit verlässlich nachts. |
|
||||
|
||||
Als *ruhend* gilt, wer in den letzten drei Jahren nichts gekauft hat. Das sind mit der Zeit die
|
||||
meisten Spieler, und wer seit Jahren nichts kauft, fängt selten von einer Stunde auf die andere
|
||||
damit an - der seltenere Takt spart deshalb den Großteil der Anfragen, ohne dass etwas liegen
|
||||
bleibt. Maßgeblich ist das Kaufdatum aus dem Shop, nicht der Zeitpunkt der Eingabe hier.
|
||||
Ab Werk: Käufe alle zwei Stunden, ruhende Konten alle sechs, ruhend nach drei Jahren; das Forum
|
||||
einmal täglich, frühestens ab vier Uhr.
|
||||
|
||||
Der seltenere Takt für ruhende Konten spart den Großteil der Anfragen: Wer seit Jahren nichts
|
||||
kauft, fängt selten von einer Stunde auf die andere damit an, und es sind mit der Zeit die
|
||||
meisten Spieler. Maßgeblich ist das Kaufdatum aus dem Shop, nicht der Zeitpunkt der Eingabe hier.
|
||||
|
||||
**Gebucht wird dabei nichts von allein.** Neue Pakete landen in der Import-Vorschau, neue Anträge
|
||||
in der Liste - entschieden wird weiterhin von Hand. Auf der Übersicht steht, wie viele Pakete auf
|
||||
eine Buchung warten und bei wem.
|
||||
|
||||
Unter *Verwaltung → Zeitplan* steht, wann zuletzt gelaufen wurde und was dabei herauskam. Der
|
||||
Zeitplan lässt sich über `SCHEDULE_ENABLED=false` abschalten, die Stunde des Forumslaufs über
|
||||
`FORUM_SCAN_HOUR` verschieben. Der Stand steht in der Datenbank, nicht im Prozess: ein Neustart
|
||||
stößt deshalb nicht alles erneut an.
|
||||
Darunter steht, wann zuletzt gelaufen wurde und was dabei herauskam. Änderungen greifen sofort,
|
||||
ohne Neustart; der Stand der Läufe steht in der Datenbank, ein Neustart stößt also nicht alles
|
||||
erneut an.
|
||||
|
||||
### Wie lange ein Abgleich dauert
|
||||
|
||||
|
||||
+2
-3
@@ -17,10 +17,9 @@ services:
|
||||
FORUM_BASE_URL: ${FORUM_BASE_URL:-https://senseigaming.de/forum}
|
||||
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
|
||||
# Die Zeitzone bestimmt, wann "nachts um vier" ist - ohne sie liefe der
|
||||
# Forumslauf nach UTC und damit im Sommer zwei Stunden daneben.
|
||||
# Forumslauf nach UTC und damit im Sommer zwei Stunden daneben. Der
|
||||
# Takt selbst steht in der Anwendung unter Verwaltung -> Zeitplan.
|
||||
TZ: ${TZ:-Europe/Berlin}
|
||||
SCHEDULE_ENABLED: ${SCHEDULE_ENABLED:-true}
|
||||
FORUM_SCAN_HOUR: ${FORUM_SCAN_HOUR:-4}
|
||||
expose:
|
||||
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
|
||||
- "3000"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Der Zeitplan wird einstellbar. Bisher standen Takt und Schalter in der
|
||||
-- Umgebung und liessen sich nur mit einem Neustart aendern.
|
||||
CREATE TABLE IF NOT EXISTS "ScheduleSetting" (
|
||||
"name" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"intervalMinutes" INTEGER NOT NULL DEFAULT 120,
|
||||
"idleIntervalMinutes" INTEGER,
|
||||
"idleAfterDays" INTEGER,
|
||||
"onlyAfterHour" INTEGER,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ScheduleSetting_pkey" PRIMARY KEY ("name")
|
||||
);
|
||||
|
||||
-- Die bisherigen Werte als Ausgangspunkt: Kaeufe alle zwei Stunden, ruhende
|
||||
-- Konten alle sechs, ruhend ab drei Jahren; das Forum einmal taeglich,
|
||||
-- fruehestens ab vier Uhr.
|
||||
INSERT INTO "ScheduleSetting"
|
||||
("name", "enabled", "intervalMinutes", "idleIntervalMinutes", "idleAfterDays", "onlyAfterHour")
|
||||
VALUES
|
||||
('gextension', true, 120, 360, 1095, NULL),
|
||||
('forum', true, 1440, NULL, NULL, 4)
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
@@ -506,3 +506,26 @@ model CharacterLink {
|
||||
|
||||
@@index([characterId])
|
||||
}
|
||||
|
||||
/// Was der Zeitplan tun soll. Steht in der Datenbank statt in der Umgebung,
|
||||
/// damit sich der Takt ändern lässt, ohne den Dienst neu zu starten.
|
||||
model ScheduleSetting {
|
||||
/// "gextension" oder "forum".
|
||||
name String @id
|
||||
enabled Boolean @default(true)
|
||||
|
||||
/// Abstand zwischen zwei Läufen, in Minuten.
|
||||
intervalMinutes Int @default(120)
|
||||
|
||||
/// Nur beim Käufe-Abgleich: der seltenere Takt für Konten, bei denen lange
|
||||
/// nichts gekauft wurde, und ab wann ein Konto als ruhend gilt.
|
||||
idleIntervalMinutes Int?
|
||||
idleAfterDays Int?
|
||||
|
||||
/// Nur beim Forumslauf: frühestens ab dieser Stunde. Damit lässt sich ein
|
||||
/// täglicher Lauf in die Nacht legen, statt ihn irgendwann am Tag
|
||||
/// loszuschicken.
|
||||
onlyAfterHour Int?
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -35,15 +35,6 @@ const schema = z.object({
|
||||
// WoltLab (Burning Board) zugeschnitten.
|
||||
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
|
||||
|
||||
// Wiederkehrende Abgleiche: Käufe alle zwei Stunden (ruhende Konten alle
|
||||
// sechs), das Forum einmal nachts. Abschaltbar, etwa für eine zweite
|
||||
// Instanz, die nicht mitlaufen soll.
|
||||
SCHEDULE_ENABLED: z
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
FORUM_SCAN_HOUR: z.coerce.number().int().min(0).max(23).default(4),
|
||||
|
||||
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
|
||||
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||
|
||||
@@ -267,14 +267,33 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// --- Audit-Log ---
|
||||
|
||||
/** Stand der wiederkehrenden Abgleiche. */
|
||||
/** Einstellungen und Stand der wiederkehrenden Abgleiche. */
|
||||
app.get('/api/admin/schedule', async () => {
|
||||
const { listRuns } = await import('../services/schedule.js');
|
||||
return {
|
||||
enabled: env.SCHEDULE_ENABLED,
|
||||
forumHour: env.FORUM_SCAN_HOUR,
|
||||
runs: await listRuns(),
|
||||
};
|
||||
const { listRuns, listSettings } = await import('../services/schedule.js');
|
||||
const [settings, runs] = await Promise.all([listSettings(), listRuns()]);
|
||||
return { settings, runs };
|
||||
});
|
||||
|
||||
app.patch('/api/admin/schedule/:name', async (request) => {
|
||||
const { updateSetting, LAEUFE } = await import('../services/schedule.js');
|
||||
|
||||
const params = parseWith(
|
||||
z.object({ name: z.enum(['gextension', 'forum']) }),
|
||||
request.params,
|
||||
);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
intervalMinutes: z.number().int().optional(),
|
||||
idleIntervalMinutes: z.number().int().nullable().optional(),
|
||||
idleAfterDays: z.number().int().nullable().optional(),
|
||||
onlyAfterHour: z.number().int().nullable().optional(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
|
||||
if (!LAEUFE.includes(params.name)) throw notFound('Lauf');
|
||||
return updateSetting(params.name, body, contextOf(request));
|
||||
});
|
||||
|
||||
app.get('/api/admin/audit', async (request) => {
|
||||
|
||||
+183
-48
@@ -1,9 +1,9 @@
|
||||
import { CreditKind } from '@prisma/client';
|
||||
import { prisma } from '../db.js';
|
||||
import { env } from '../env.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import { knownSteamIds, scrapePlayers } from './scraper.js';
|
||||
import { scanForum } from './forum.js';
|
||||
import type { ActionContext } from './audit.js';
|
||||
import { recordAudit, type ActionContext } from './audit.js';
|
||||
|
||||
/**
|
||||
* Wiederkehrende Läufe.
|
||||
@@ -14,24 +14,52 @@ import type { ActionContext } from './audit.js';
|
||||
* bearbeitet zu werden - und weil ein voller Durchlauf gut zwei Dutzend
|
||||
* Seiten liest.
|
||||
*
|
||||
* Der Zeitplan steht hier, der Stand in der Datenbank. Nach einem Neustart
|
||||
* wird deshalb nicht alles erneut angestoßen.
|
||||
* Takt und Schalter stehen in der Datenbank, nicht in der Umgebung: so lässt
|
||||
* sich beides im Betrieb ändern, ohne den Dienst neu zu starten. Der Stand
|
||||
* der Läufe steht ebenfalls dort, damit ein Neustart nicht alles erneut
|
||||
* anstösst.
|
||||
*/
|
||||
|
||||
/// Wie oft überhaupt nachgesehen wird, ob etwas fällig ist.
|
||||
const TICK_MS = 5 * 60 * 1000;
|
||||
const TICK_MS = 60 * 1000;
|
||||
|
||||
/// Wer in letzter Zeit gekauft hat, wird häufiger geprüft.
|
||||
const AKTIVE_PRUEFUNG_MS = 2 * 60 * 60 * 1000;
|
||||
/// Wessen letzter Kauf lange zurückliegt, seltener - das spart die meisten
|
||||
/// Anfragen, denn das sind mit der Zeit die meisten Spieler.
|
||||
const RUHENDE_PRUEFUNG_MS = 6 * 60 * 60 * 1000;
|
||||
/// Ab wann jemand als ruhend gilt.
|
||||
const RUHEND_AB_MS = 3 * 365 * 24 * 60 * 60 * 1000;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const TAG_MS = 24 * 60 * MINUTE_MS;
|
||||
|
||||
/// Die beiden Läufe, die es gibt.
|
||||
export const LAEUFE = ['gextension', 'forum'] as const;
|
||||
export type LaufName = (typeof LAEUFE)[number];
|
||||
|
||||
/// Unter diesem Namen läuft, was der Zeitplan anstößt.
|
||||
const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' };
|
||||
|
||||
/// Fällt ein Eintrag, greifen diese Werte - dieselben, mit denen der
|
||||
/// Zeitplan ursprünglich fest verdrahtet war.
|
||||
const VORGABEN: Record<LaufName, ScheduleSettingInput> = {
|
||||
gextension: {
|
||||
enabled: true,
|
||||
intervalMinutes: 120,
|
||||
idleIntervalMinutes: 360,
|
||||
idleAfterDays: 1095,
|
||||
onlyAfterHour: null,
|
||||
},
|
||||
forum: {
|
||||
enabled: true,
|
||||
intervalMinutes: 1440,
|
||||
idleIntervalMinutes: null,
|
||||
idleAfterDays: null,
|
||||
onlyAfterHour: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export interface ScheduleSettingInput {
|
||||
enabled: boolean;
|
||||
intervalMinutes: number;
|
||||
idleIntervalMinutes: number | null;
|
||||
idleAfterDays: number | null;
|
||||
onlyAfterHour: number | null;
|
||||
}
|
||||
|
||||
export interface RunState {
|
||||
name: string;
|
||||
lastRunAt: Date | null;
|
||||
@@ -39,6 +67,91 @@ export interface RunState {
|
||||
lastNote: string | null;
|
||||
}
|
||||
|
||||
export async function getSetting(name: LaufName): Promise<ScheduleSettingInput> {
|
||||
const eintrag = await prisma.scheduleSetting.findUnique({ where: { name } });
|
||||
if (!eintrag) return VORGABEN[name];
|
||||
|
||||
return {
|
||||
enabled: eintrag.enabled,
|
||||
intervalMinutes: eintrag.intervalMinutes,
|
||||
idleIntervalMinutes: eintrag.idleIntervalMinutes,
|
||||
idleAfterDays: eintrag.idleAfterDays,
|
||||
onlyAfterHour: eintrag.onlyAfterHour,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSettings(): Promise<(ScheduleSettingInput & { name: LaufName })[]> {
|
||||
return Promise.all(
|
||||
LAEUFE.map(async (name) => ({ name, ...(await getSetting(name)) })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert die Einstellungen eines Laufs.
|
||||
*
|
||||
* Die Grenzen sind bewusst eng: unter einer Viertelstunde wäre der Abgleich
|
||||
* eine Belästigung für einen fremden Server, über einer Woche liefe er
|
||||
* praktisch nicht mehr.
|
||||
*/
|
||||
export async function updateSetting(
|
||||
name: LaufName,
|
||||
input: Partial<ScheduleSettingInput>,
|
||||
ctx: ActionContext,
|
||||
): Promise<ScheduleSettingInput> {
|
||||
const vorher = await getSetting(name);
|
||||
|
||||
const pruefe = (wert: number | null | undefined, feld: string): number | null | undefined => {
|
||||
if (wert === undefined || wert === null) return wert;
|
||||
if (!Number.isInteger(wert) || wert < 15 || wert > 10080) {
|
||||
throw badRequest(
|
||||
'INVALID_INTERVAL',
|
||||
`${feld}: zwischen 15 Minuten und einer Woche (10080 Minuten)`,
|
||||
);
|
||||
}
|
||||
return wert;
|
||||
};
|
||||
|
||||
const intervalMinutes = pruefe(input.intervalMinutes, 'Abstand') ?? vorher.intervalMinutes;
|
||||
const idleIntervalMinutes =
|
||||
input.idleIntervalMinutes === undefined
|
||||
? vorher.idleIntervalMinutes
|
||||
: (pruefe(input.idleIntervalMinutes, 'Abstand für ruhende Konten') ?? null);
|
||||
|
||||
if (input.idleAfterDays !== undefined && input.idleAfterDays !== null) {
|
||||
if (!Number.isInteger(input.idleAfterDays) || input.idleAfterDays < 1) {
|
||||
throw badRequest('INVALID_DAYS', 'Ruhend ab: mindestens ein Tag');
|
||||
}
|
||||
}
|
||||
if (input.onlyAfterHour !== undefined && input.onlyAfterHour !== null) {
|
||||
if (!Number.isInteger(input.onlyAfterHour) || input.onlyAfterHour < 0 || input.onlyAfterHour > 23) {
|
||||
throw badRequest('INVALID_HOUR', 'Frühestens ab: eine Stunde zwischen 0 und 23');
|
||||
}
|
||||
}
|
||||
|
||||
const daten = {
|
||||
enabled: input.enabled ?? vorher.enabled,
|
||||
intervalMinutes,
|
||||
idleIntervalMinutes,
|
||||
idleAfterDays: input.idleAfterDays === undefined ? vorher.idleAfterDays : input.idleAfterDays,
|
||||
onlyAfterHour: input.onlyAfterHour === undefined ? vorher.onlyAfterHour : input.onlyAfterHour,
|
||||
};
|
||||
|
||||
await prisma.scheduleSetting.upsert({
|
||||
where: { name },
|
||||
create: { name, ...daten },
|
||||
update: daten,
|
||||
});
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: 'schedule.updated',
|
||||
targetType: 'schedule',
|
||||
targetId: null,
|
||||
details: { name, ...daten },
|
||||
});
|
||||
|
||||
return daten;
|
||||
}
|
||||
|
||||
async function letzterLauf(name: string): Promise<Date | null> {
|
||||
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
|
||||
return eintrag?.lastRunAt ?? null;
|
||||
@@ -66,13 +179,21 @@ export async function listRuns(): Promise<RunState[]> {
|
||||
/**
|
||||
* Die Spieler, die jetzt an der Reihe sind.
|
||||
*
|
||||
* Wer in den letzten drei Jahren etwas gekauft hat, wird alle zwei Stunden
|
||||
* geprüft. Bei allen anderen reichen sechs - wer seit Jahren nichts kauft,
|
||||
* fängt selten von einer Stunde auf die andere damit an, und es sind mit
|
||||
* Abstand die meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt
|
||||
* der Eingabe hier.
|
||||
* Wer in letzter Zeit gekauft hat, wird im normalen Takt geprüft. Bei allen
|
||||
* anderen reicht der seltenere - wer seit Jahren nichts kauft, fängt selten
|
||||
* von einer Stunde auf die andere damit an, und es sind mit Abstand die
|
||||
* meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt der Eingabe
|
||||
* hier.
|
||||
*/
|
||||
export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
|
||||
export async function faelligeSpieler(
|
||||
jetzt = new Date(),
|
||||
einstellung?: ScheduleSettingInput,
|
||||
): Promise<number[]> {
|
||||
const regel = einstellung ?? (await getSetting('gextension'));
|
||||
const aktiverTakt = regel.intervalMinutes * MINUTE_MS;
|
||||
const ruhenderTakt = (regel.idleIntervalMinutes ?? regel.intervalMinutes) * MINUTE_MS;
|
||||
const ruhendAb = (regel.idleAfterDays ?? 0) * TAG_MS;
|
||||
|
||||
const [spieler, letzteKaeufe] = await Promise.all([
|
||||
prisma.player.findMany({
|
||||
where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] },
|
||||
@@ -91,8 +212,8 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
|
||||
.filter((p) => {
|
||||
const letzterKauf = kaufNach.get(p.id) ?? null;
|
||||
const ruhend =
|
||||
letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > RUHEND_AB_MS;
|
||||
const abstand = ruhend ? RUHENDE_PRUEFUNG_MS : AKTIVE_PRUEFUNG_MS;
|
||||
ruhendAb > 0 && (letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > ruhendAb);
|
||||
const abstand = ruhend ? ruhenderTakt : aktiverTakt;
|
||||
|
||||
if (p.gextensionSyncedAt === null) return true;
|
||||
return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand;
|
||||
@@ -100,18 +221,45 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
|
||||
.map((p) => p.id);
|
||||
}
|
||||
|
||||
/** Ob der nächtliche Lauf für heute noch aussteht. */
|
||||
export function forumLaufFaellig(letzter: Date | null, jetzt: Date, stunde: number): boolean {
|
||||
if (jetzt.getHours() < stunde) return false;
|
||||
/**
|
||||
* Ob ein Lauf jetzt ansteht.
|
||||
*
|
||||
* Ohne Stundenfenster genügt der Abstand zum letzten Lauf. Mit Fenster wird
|
||||
* zusätzlich verlangt, dass die Stunde erreicht ist - so lässt sich ein
|
||||
* täglicher Lauf verlässlich in die Nacht legen, statt ihn mit jedem
|
||||
* Neustart weiter in den Tag wandern zu lassen.
|
||||
*/
|
||||
export function istFaellig(
|
||||
letzter: Date | null,
|
||||
jetzt: Date,
|
||||
regel: Pick<ScheduleSettingInput, 'intervalMinutes' | 'onlyAfterHour'>,
|
||||
): boolean {
|
||||
if (letzter !== null) {
|
||||
const vergangen = jetzt.getTime() - letzter.getTime();
|
||||
if (vergangen < regel.intervalMinutes * MINUTE_MS) return false;
|
||||
}
|
||||
|
||||
const heuteUm = new Date(jetzt);
|
||||
heuteUm.setHours(stunde, 0, 0, 0);
|
||||
if (regel.onlyAfterHour === null || regel.onlyAfterHour === undefined) return true;
|
||||
if (jetzt.getHours() < regel.onlyAfterHour) return false;
|
||||
|
||||
return letzter === null || letzter < heuteUm;
|
||||
// Innerhalb desselben Tages nicht zweimal, wenn der Takt tageweise läuft.
|
||||
if (letzter !== null && regel.intervalMinutes >= 1440) {
|
||||
const heuteAb = new Date(jetzt);
|
||||
heuteAb.setHours(regel.onlyAfterHour, 0, 0, 0);
|
||||
if (letzter >= heuteAb) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function kaeufePruefen(log: Logger): Promise<void> {
|
||||
const faellig = await faelligeSpieler();
|
||||
interface Logger {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
debug: (msg: string) => void;
|
||||
}
|
||||
|
||||
async function kaeufePruefen(log: Logger, regel: ScheduleSettingInput): Promise<void> {
|
||||
const faellig = await faelligeSpieler(new Date(), regel);
|
||||
if (faellig.length === 0) {
|
||||
log.debug('Zeitplan: keine Spieler fällig');
|
||||
return;
|
||||
@@ -144,22 +292,11 @@ async function forumPruefen(log: Logger): Promise<void> {
|
||||
await laufNotieren('forum', ergebnis.failedBoards.length > 0 ? 'FAILED' : 'OK', note);
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
debug: (msg: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Zeitplan. Es läuft immer nur ein Abgleich - stockt einer, wird
|
||||
* der nächste Termin übersprungen statt zwei Läufe übereinanderzulegen.
|
||||
*/
|
||||
export function startSchedule(log: Logger): () => void {
|
||||
if (!env.SCHEDULE_ENABLED) {
|
||||
log.info('Zeitplan ist abgeschaltet (SCHEDULE_ENABLED=false)');
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let laeuft = false;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
@@ -169,16 +306,16 @@ export function startSchedule(log: Logger): () => void {
|
||||
try {
|
||||
const jetzt = new Date();
|
||||
|
||||
const forumZuletzt = await letzterLauf('forum');
|
||||
if (forumLaufFaellig(forumZuletzt, jetzt, env.FORUM_SCAN_HOUR)) {
|
||||
const forumRegel = await getSetting('forum');
|
||||
if (forumRegel.enabled && istFaellig(await letzterLauf('forum'), jetzt, forumRegel)) {
|
||||
await forumPruefen(log);
|
||||
return; // Der Forumslauf dauert; die Käufe kommen beim nächsten Takt.
|
||||
}
|
||||
|
||||
const kaeufeZuletzt = await letzterLauf('gextension');
|
||||
const abstandReicht =
|
||||
kaeufeZuletzt === null || jetzt.getTime() - kaeufeZuletzt.getTime() >= AKTIVE_PRUEFUNG_MS;
|
||||
if (abstandReicht) await kaeufePruefen(log);
|
||||
const kaufRegel = await getSetting('gextension');
|
||||
if (kaufRegel.enabled && istFaellig(await letzterLauf('gextension'), jetzt, kaufRegel)) {
|
||||
await kaeufePruefen(log, kaufRegel);
|
||||
}
|
||||
} catch (error) {
|
||||
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`);
|
||||
@@ -195,9 +332,7 @@ export function startSchedule(log: Logger): () => void {
|
||||
start.unref();
|
||||
timer.unref();
|
||||
|
||||
log.info(
|
||||
`Zeitplan läuft: Käufe alle 2 Stunden (ruhende Konten alle 6), Forum täglich um ${env.FORUM_SCAN_HOUR} Uhr`,
|
||||
);
|
||||
log.info('Zeitplan läuft; Takt und Schalter stehen unter Verwaltung → Zeitplan');
|
||||
|
||||
return () => {
|
||||
clearTimeout(start);
|
||||
|
||||
@@ -21,7 +21,6 @@ before(async () => {
|
||||
process.env.DATABASE_URL = database.url;
|
||||
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SCHEDULE_ENABLED = 'false';
|
||||
|
||||
db = await import('../src/db.js');
|
||||
backup = await import('../src/services/backup.js');
|
||||
|
||||
@@ -17,7 +17,6 @@ before(async () => {
|
||||
process.env.DATABASE_URL = database.url;
|
||||
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SCHEDULE_ENABLED = 'false';
|
||||
|
||||
db = await import('../src/db.js');
|
||||
characters = await import('../src/services/characters.js');
|
||||
|
||||
@@ -21,8 +21,6 @@ before(async () => {
|
||||
process.env.DATABASE_URL = database.url;
|
||||
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||
process.env.NODE_ENV = 'test';
|
||||
// Der Zeitplan selbst darf im Test nicht anlaufen, nur seine Logik zählt.
|
||||
process.env.SCHEDULE_ENABLED = 'false';
|
||||
|
||||
db = await import('../src/db.js');
|
||||
schedule = await import('../src/services/schedule.js');
|
||||
@@ -139,37 +137,110 @@ describe('Wer wann auf neue Pakete geprüft wird', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Der nächtliche Forumslauf', () => {
|
||||
const nacht = 4;
|
||||
describe('Wann ein Lauf ansteht', () => {
|
||||
const taeglichNachts = { intervalMinutes: 1440, onlyAfterHour: 4 };
|
||||
const allezweiStunden = { intervalMinutes: 120, onlyAfterHour: null };
|
||||
|
||||
it('läuft nicht, bevor die Stunde erreicht ist', () => {
|
||||
const frueh = new Date('2026-09-21T02:30:00');
|
||||
assert.equal(schedule.forumLaufFaellig(null, frueh, nacht), false);
|
||||
it('wartet den eingestellten Abstand ab', () => {
|
||||
const jetzt = new Date('2026-09-21T12:00:00');
|
||||
const vorEinerStunde = new Date('2026-09-21T11:00:00');
|
||||
const vorDreiStunden = new Date('2026-09-21T09:00:00');
|
||||
|
||||
assert.equal(schedule.istFaellig(vorEinerStunde, jetzt, allezweiStunden), false);
|
||||
assert.equal(schedule.istFaellig(vorDreiStunden, jetzt, allezweiStunden), true);
|
||||
});
|
||||
|
||||
it('läuft, sobald die Stunde erreicht ist und heute noch nichts lief', () => {
|
||||
const morgens = new Date('2026-09-21T04:10:00');
|
||||
const gestern = new Date('2026-09-20T04:05:00');
|
||||
assert.equal(schedule.forumLaufFaellig(gestern, morgens, nacht), true);
|
||||
it('läuft beim ersten Mal sofort', () => {
|
||||
assert.equal(schedule.istFaellig(null, new Date('2026-09-21T12:00:00'), allezweiStunden), true);
|
||||
});
|
||||
|
||||
it('hält sich beim Forum an die Stunde', () => {
|
||||
const frueh = new Date('2026-09-21T02:30:00');
|
||||
assert.equal(schedule.istFaellig(null, frueh, taeglichNachts), false, 'vor vier Uhr nicht');
|
||||
|
||||
const nachVier = new Date('2026-09-21T04:10:00');
|
||||
assert.equal(schedule.istFaellig(null, nachVier, taeglichNachts), true);
|
||||
});
|
||||
|
||||
it('läuft an einem Tag nur einmal', () => {
|
||||
const morgens = new Date('2026-09-21T04:10:00');
|
||||
const abends = new Date('2026-09-21T22:00:00');
|
||||
assert.equal(schedule.forumLaufFaellig(morgens, abends, nacht), false);
|
||||
assert.equal(schedule.istFaellig(morgens, abends, taeglichNachts), false);
|
||||
});
|
||||
|
||||
it('holt einen ausgefallenen Lauf am selben Tag nach', () => {
|
||||
// Der Dienst stand nachts still und kommt erst mittags hoch.
|
||||
const mittags = new Date('2026-09-21T12:00:00');
|
||||
const vorgestern = new Date('2026-09-19T04:00:00');
|
||||
assert.equal(schedule.forumLaufFaellig(vorgestern, mittags, nacht), true);
|
||||
assert.equal(schedule.istFaellig(vorgestern, mittags, taeglichNachts), true);
|
||||
});
|
||||
|
||||
it('stößt nach einem Neustart nicht erneut an', () => {
|
||||
it('stösst nach einem Neustart nicht erneut an', () => {
|
||||
const nachStart = new Date('2026-09-21T06:00:00');
|
||||
const heuteFrueh = new Date('2026-09-21T04:02:00');
|
||||
assert.equal(schedule.forumLaufFaellig(heuteFrueh, nachStart, nacht), false);
|
||||
assert.equal(schedule.istFaellig(heuteFrueh, nachStart, taeglichNachts), false);
|
||||
});
|
||||
|
||||
it('ignoriert die Stunde, wenn der Takt kürzer als ein Tag ist', () => {
|
||||
// Ein sechsstündlicher Lauf ab acht Uhr soll auch um vierzehn Uhr laufen.
|
||||
const regel = { intervalMinutes: 360, onlyAfterHour: 8 };
|
||||
const mittags = new Date('2026-09-21T14:00:00');
|
||||
const morgens = new Date('2026-09-21T08:00:00');
|
||||
assert.equal(schedule.istFaellig(morgens, mittags, regel), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Einstellungen des Zeitplans', () => {
|
||||
it('liefert Vorgaben, solange nichts eingestellt wurde', async () => {
|
||||
const kaeufe = await schedule.getSetting('gextension');
|
||||
assert.equal(kaeufe.intervalMinutes, 120);
|
||||
assert.equal(kaeufe.idleIntervalMinutes, 360);
|
||||
assert.equal(kaeufe.idleAfterDays, 1095);
|
||||
|
||||
const forum = await schedule.getSetting('forum');
|
||||
assert.equal(forum.intervalMinutes, 1440);
|
||||
assert.equal(forum.onlyAfterHour, 4);
|
||||
});
|
||||
|
||||
it('nimmt eine Änderung an und wendet sie sofort an', async () => {
|
||||
const ctx = { actorId: null, actorName: 'Test' };
|
||||
await schedule.updateSetting('gextension', { intervalMinutes: 45, enabled: false }, ctx);
|
||||
|
||||
const danach = await schedule.getSetting('gextension');
|
||||
assert.equal(danach.intervalMinutes, 45);
|
||||
assert.equal(danach.enabled, false);
|
||||
assert.equal(danach.idleIntervalMinutes, 360, 'unberührte Felder bleiben');
|
||||
|
||||
await schedule.updateSetting('gextension', { intervalMinutes: 120, enabled: true }, ctx);
|
||||
});
|
||||
|
||||
it('weist unsinnige Abstände ab', async () => {
|
||||
const ctx = { actorId: null, actorName: 'Test' };
|
||||
|
||||
for (const minuten of [0, 5, 20000]) {
|
||||
await assert.rejects(
|
||||
() => schedule.updateSetting('forum', { intervalMinutes: minuten }, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'INVALID_INTERVAL',
|
||||
`${minuten} Minuten hätten abgewiesen werden müssen`,
|
||||
);
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => schedule.updateSetting('forum', { onlyAfterHour: 25 }, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'INVALID_HOUR',
|
||||
);
|
||||
});
|
||||
|
||||
it('richtet sich beim Fälligsein nach der Einstellung', async () => {
|
||||
const ctx = { actorId: null, actorName: 'Test' };
|
||||
await schedule.updateSetting('gextension', { intervalMinutes: 30 }, ctx);
|
||||
|
||||
const regel = await schedule.getSetting('gextension');
|
||||
const jetzt = new Date('2026-09-21T12:00:00');
|
||||
assert.equal(schedule.istFaellig(new Date('2026-09-21T11:45:00'), jetzt, regel), false);
|
||||
assert.equal(schedule.istFaellig(new Date('2026-09-21T11:15:00'), jetzt, regel), true);
|
||||
|
||||
await schedule.updateSetting('gextension', { intervalMinutes: 120 }, ctx);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+14
-2
@@ -352,9 +352,21 @@ export interface ScheduleRun {
|
||||
lastNote: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleState {
|
||||
export type ScheduleName = 'gextension' | 'forum';
|
||||
|
||||
export interface ScheduleSetting {
|
||||
name: ScheduleName;
|
||||
enabled: boolean;
|
||||
forumHour: number;
|
||||
intervalMinutes: number;
|
||||
/// Nur beim Käufe-Abgleich.
|
||||
idleIntervalMinutes: number | null;
|
||||
idleAfterDays: number | null;
|
||||
/// Nur beim Forumslauf.
|
||||
onlyAfterHour: number | null;
|
||||
}
|
||||
|
||||
export interface ScheduleState {
|
||||
settings: ScheduleSetting[];
|
||||
runs: ScheduleRun[];
|
||||
}
|
||||
|
||||
|
||||
+214
-27
@@ -10,6 +10,7 @@ import type {
|
||||
ProductMapping,
|
||||
ProductTarget,
|
||||
Role,
|
||||
ScheduleSetting,
|
||||
ScheduleState,
|
||||
} from '../lib/types';
|
||||
|
||||
@@ -74,7 +75,7 @@ export default function Admin() {
|
||||
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
|
||||
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
|
||||
{tab === 'audit' && <AuditTab onError={setError} />}
|
||||
{tab === 'schedule' && <ScheduleTab onError={setError} />}
|
||||
{tab === 'schedule' && <ScheduleTab onError={setError} onDone={notify} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -522,51 +523,75 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Was die wiederkehrenden Abgleiche zuletzt getan haben. Sie laufen ohne
|
||||
* Zutun, deshalb muss nachlesbar sein, ob sie überhaupt laufen - ein still
|
||||
* stehender Zeitplan fällt sonst erst auf, wenn Wochen fehlen.
|
||||
* Zeitplan: was von allein laufen soll und wie oft.
|
||||
*
|
||||
* Die Abstände stehen in Minuten, weil sich damit alles ausdrücken lässt -
|
||||
* daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnen
|
||||
* muss. Beide Abgleiche greifen auf fremde Server zu; ein sehr kurzer Takt
|
||||
* ist deshalb keine gute Idee und unter einer Viertelstunde nicht möglich.
|
||||
*/
|
||||
function ScheduleTab({ onError }: { onError: (message: string) => void }) {
|
||||
function ScheduleTab({
|
||||
onError,
|
||||
onDone,
|
||||
}: {
|
||||
onError: (message: string) => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const [stand, setStand] = useState<ScheduleState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const laden = useCallback(async () => {
|
||||
try {
|
||||
setStand(await api<ScheduleState>('/api/admin/schedule'));
|
||||
} catch (err) {
|
||||
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||
}
|
||||
})();
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => {
|
||||
void laden();
|
||||
}, [laden]);
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
gextension: 'Käufe bei GExtension',
|
||||
forum: 'Forums-Anträge',
|
||||
letzter_fehler: 'Zuletzt gescheitert',
|
||||
};
|
||||
|
||||
if (stand === null) {
|
||||
return (
|
||||
<section className="block">
|
||||
<h2>Zeitplan</h2>
|
||||
|
||||
{stand === null ? (
|
||||
<p className="muted">Lade …</p>
|
||||
) : (
|
||||
<>
|
||||
{stand.enabled ? (
|
||||
<Notice kind="info">
|
||||
Die Käufe werden alle zwei Stunden geprüft, bei Spielern ohne Kauf in den letzten
|
||||
drei Jahren alle sechs. Das Forum wird täglich gegen {stand.forumHour} Uhr gelesen.
|
||||
Gebucht wird dabei nichts von allein: neue Pakete landen in der Import-Vorschau,
|
||||
neue Anträge in der Liste.
|
||||
</Notice>
|
||||
) : (
|
||||
<Notice kind="warn">
|
||||
Der Zeitplan ist abgeschaltet (<code className="mono">SCHEDULE_ENABLED=false</code>).
|
||||
Beide Abgleiche laufen nur, wenn sie von Hand angestoßen werden.
|
||||
</Notice>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="block">
|
||||
<h2>Zeitplan</h2>
|
||||
<Notice kind="info">
|
||||
Gebucht wird hier nichts von allein: neue Pakete landen in der Import-Vorschau, neue
|
||||
Anträge in der Liste. Beide Abgleiche greifen auf Server zu, die der Community gehören -
|
||||
ein knapper Takt bringt wenig und belastet sie ohne Not.
|
||||
</Notice>
|
||||
|
||||
{stand.settings.map((setting) => (
|
||||
<ScheduleForm
|
||||
key={setting.name}
|
||||
setting={setting}
|
||||
titel={LABEL[setting.name] ?? setting.name}
|
||||
onSaved={(text) => {
|
||||
onDone(text);
|
||||
void laden();
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="block">
|
||||
<h2>Letzte Läufe</h2>
|
||||
{stand.runs.length === 0 ? (
|
||||
<p className="muted">Noch kein Lauf verzeichnet.</p>
|
||||
) : (
|
||||
@@ -594,8 +619,170 @@ function ScheduleTab({ onError }: { onError: (message: string) => void }) {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minuten in eine Angabe, die sich lesen lässt. */
|
||||
function inStunden(minuten: number): string {
|
||||
if (minuten < 60) return `${minuten} Minuten`;
|
||||
if (minuten % 1440 === 0) {
|
||||
const tage = minuten / 1440;
|
||||
return tage === 1 ? 'einmal täglich' : `alle ${tage} Tage`;
|
||||
}
|
||||
const stunden = minuten / 60;
|
||||
const text = Number.isInteger(stunden) ? String(stunden) : stunden.toFixed(1).replace('.', ',');
|
||||
return `${text} Stunden`;
|
||||
}
|
||||
|
||||
function ScheduleForm({
|
||||
setting,
|
||||
titel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
setting: ScheduleSetting;
|
||||
titel: string;
|
||||
onSaved: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(setting.enabled);
|
||||
const [interval, setInterval] = useState(String(setting.intervalMinutes));
|
||||
const [idleInterval, setIdleInterval] = useState(
|
||||
setting.idleIntervalMinutes === null ? '' : String(setting.idleIntervalMinutes),
|
||||
);
|
||||
const [idleDays, setIdleDays] = useState(
|
||||
setting.idleAfterDays === null ? '' : String(setting.idleAfterDays),
|
||||
);
|
||||
const [hour, setHour] = useState(
|
||||
setting.onlyAfterHour === null ? '' : String(setting.onlyAfterHour),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const istKaeufe = setting.name === 'gextension';
|
||||
|
||||
const speichern = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/api/admin/schedule/${setting.name}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
enabled,
|
||||
intervalMinutes: Number(interval),
|
||||
...(istKaeufe
|
||||
? {
|
||||
idleIntervalMinutes: idleInterval ? Number(idleInterval) : null,
|
||||
idleAfterDays: idleDays ? Number(idleDays) : null,
|
||||
}
|
||||
: { onlyAfterHour: hour === '' ? null : Number(hour) }),
|
||||
},
|
||||
});
|
||||
onSaved(`Zeitplan für „${titel}" gespeichert.`);
|
||||
} catch (err) {
|
||||
onError(err instanceof ApiError ? err.message : 'Speichern fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={speichern} className="schedule-form">
|
||||
<div className="schedule-head">
|
||||
<h3>{titel}</h3>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{enabled ? 'läuft automatisch' : 'nur von Hand'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="schedule-fields">
|
||||
<div>
|
||||
<label htmlFor={`${setting.name}-interval`}>Abstand (Minuten)</label>
|
||||
<input
|
||||
id={`${setting.name}-interval`}
|
||||
type="number"
|
||||
min={15}
|
||||
max={10080}
|
||||
value={interval}
|
||||
onChange={(event) => setInterval(event.target.value)}
|
||||
disabled={!enabled}
|
||||
required
|
||||
/>
|
||||
<span className="muted small">
|
||||
{Number(interval) >= 15 ? `= ${inStunden(Number(interval))}` : 'mindestens 15 Minuten'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{istKaeufe ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor={`${setting.name}-idle`}>Ruhende Konten (Minuten)</label>
|
||||
<input
|
||||
id={`${setting.name}-idle`}
|
||||
type="number"
|
||||
min={15}
|
||||
max={10080}
|
||||
value={idleInterval}
|
||||
onChange={(event) => setIdleInterval(event.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="leer = derselbe Abstand"
|
||||
/>
|
||||
<span className="muted small">
|
||||
{idleInterval && Number(idleInterval) >= 15
|
||||
? `= ${inStunden(Number(idleInterval))}`
|
||||
: 'wie oben'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`${setting.name}-days`}>Ruhend ab (Tage ohne Kauf)</label>
|
||||
<input
|
||||
id={`${setting.name}-days`}
|
||||
type="number"
|
||||
min={1}
|
||||
value={idleDays}
|
||||
onChange={(event) => setIdleDays(event.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="leer = alle gleich"
|
||||
/>
|
||||
<span className="muted small">
|
||||
{idleDays ? `= rund ${Math.round(Number(idleDays) / 365)} Jahre` : 'kein Unterschied'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor={`${setting.name}-hour`}>Frühestens ab (Uhr)</label>
|
||||
<input
|
||||
id={`${setting.name}-hour`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={hour}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="leer = jederzeit"
|
||||
/>
|
||||
<span className="muted small">
|
||||
{hour === ''
|
||||
? 'sobald der Abstand um ist'
|
||||
: `nicht vor ${hour} Uhr - bei einem täglichen Takt also nachts`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Speichere …' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1212,3 +1212,37 @@ textarea {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Zeitplan --- */
|
||||
|
||||
.schedule-form {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 1rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.schedule-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.schedule-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* Die Umrechnung steht direkt unter dem Feld, nicht daneben. */
|
||||
.schedule-fields span {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user