Make dashboard learning status meaningful
mindflux-ci / build (push) Successful in 23s

This commit is contained in:
AliceDarkest
2026-07-14 15:03:18 +02:00
parent 56cd17520e
commit bd76cd11de
13 changed files with 233 additions and 27 deletions
+13
View File
@@ -7,6 +7,19 @@ Versionierung [semantisch](https://semver.org/lang/de/).
## [Unreleased] – 2026-07-14 · SysnaptiK Neural Rebrand
### Marke & Plattform
- **Echter Lernnetz-Status:** Der Dashboard-Status prüft nun serverseitig den
Fortschrittsspeicher und zeigt nachvollziehbar „bereit“ oder „eingeschränkt“ samt
Zeitstempel und registrierten Lernmodulen. Der dekorative 100%-Ring wurde entfernt.
- **Akzente klar getrennt:** MindMatch ist nun Magenta, SyntaxSaga ein kühleres Violett,
SystemEscape Gelb-Bernstein und FinalProtocol Koralle. Die Plattformkarten sind damit
auf einen Blick unterscheidbar, ohne die Farben in den Spielen selbst anzutasten.
- **Fortschrittsdiagramme korrigiert:** Donut- und Wochenchart lesen den Neural-Akzent
nun von der Plattformhülle statt den alten globalen Orange-Token zu verwenden.
- **Alte Platzhalter-Copy entfernt:** Die Registrierungsrakete und der Zeitreise-Spruch
auf der Vorbereitungsseite wurden durch neutrale SysnaptiK-Neural-Texte ersetzt.
- **Admin-Umfang transparent:** Das Admin-Dashboard benennt jetzt klar, welche
Inhaltsverwaltungen vorhanden sind und dass ServiceClash, NetWorker und FinalProtocol
noch direkt mit der Anwendung ausgeliefert werden.
- **Bestehendes Emblem bewahrt:** Die kurzfristig ergänzte CSS-Knotenmarke wurde
zurückgenommen. Das vorhandene Emblem bleibt als Wiedererkennungszeichen bestehen,
weil es die neue neuronale Metapher bereits passend transportiert.
+2 -1
View File
@@ -103,7 +103,8 @@ Accessibility-Widget soll anschließend jeweils den lokalen Akzent übernehmen.
> Plattformseiten und FinalProtocol. Die Spielwurzeln sind explizit vom Plattform-Scope
> ausgeschlossen und behalten ihre jeweiligen internen Skins. NeuralPause ersetzt die
> sichtbare FluxTimer-/Zeitreise-Copy. Domain-/DNS-Umzug und technische Paket-/Tabellennamen
> bleiben bewusst separate Schritte.
> bleiben bewusst separate Schritte. Der Dashboard-Status ist als echter Lernnetz-Status
> umgesetzt: Er prüft den Fortschrittsspeicher statt eine feste Verfügbarkeit vorzutäuschen.
### Ziel und Abgrenzung
@@ -0,0 +1,24 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.PlatformStatus;
import com.example.mindflux.service.PlatformStatusService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PlatformStatusController {
private final PlatformStatusService platformStatusService;
public PlatformStatusController(PlatformStatusService platformStatusService) {
this.platformStatusService = platformStatusService;
}
@GetMapping("/api/platform/status")
public ResponseEntity<PlatformStatus> status() {
PlatformStatus status = platformStatusService.currentStatus();
return ResponseEntity.status(status.ready() ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE).body(status);
}
}
@@ -0,0 +1,18 @@
package com.example.mindflux.dto;
import java.time.Instant;
/**
* Ein bewusst kleiner, nachvollziehbarer Plattformzustand für das Dashboard.
* Er behauptet keine fiktive Verfügbarkeit, sondern prüft den Speicher, der
* Spielstände und Fortschritt tatsächlich trägt.
*/
public record PlatformStatus(
boolean storageConnected,
int registeredModules,
Instant checkedAt
) {
public boolean ready() {
return storageConnected;
}
}
@@ -0,0 +1,37 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.PlatformStatus;
import com.example.mindflux.repository.GameScoreRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import java.time.Instant;
@Service
public class PlatformStatusService {
private static final Logger log = LoggerFactory.getLogger(PlatformStatusService.class);
private static final int REGISTERED_MODULES = 9;
private final GameScoreRepository gameScoreRepository;
public PlatformStatusService(GameScoreRepository gameScoreRepository) {
this.gameScoreRepository = gameScoreRepository;
}
/**
* Die Abfrage ist absichtlich echt: Nur wenn der Fortschrittsspeicher lesbar
* ist, meldet das Dashboard das Lernnetz als bereit.
*/
public PlatformStatus currentStatus() {
try {
gameScoreRepository.count();
return new PlatformStatus(true, REGISTERED_MODULES, Instant.now());
} catch (DataAccessException exception) {
log.warn("Fortschrittsspeicher ist für den Lernnetz-Status nicht erreichbar: {}", exception.getMessage());
return new PlatformStatus(false, REGISTERED_MODULES, Instant.now());
}
}
}
@@ -38,15 +38,15 @@ body.sn-platform .mf-panel, body.sn-platform .mf-card, body.sn-platform .mf-prof
body.sn-platform .mf-card { overflow:hidden; }
body.sn-platform .mf-card::after { content:""; position:absolute; inset:0; pointer-events:none; opacity:.55; background:radial-gradient(circle at 90% 8%,color-mix(in srgb,var(--accent) 22%,transparent) 0 1px,transparent 2px),linear-gradient(132deg,transparent 0 45%,color-mix(in srgb,var(--accent) 12%,transparent) 45.2% 45.4%,transparent 45.6%); }
body.sn-platform .mf-card > * { position:relative; z-index:1; }
body.sn-platform .mf-card-mindmatch { --accent:#c078f2; --accent-2:#ebc0ff; --glow-s:rgba(192,120,242,.40); }
body.sn-platform .mf-card-mindmatch { --accent:#df68b9; --accent-2:#ffc0e7; --glow-s:rgba(223,104,185,.40); }
body.sn-platform .mf-card-techtacular { --accent:#d6db47; --accent-2:#f2f58a; --glow-s:rgba(214,219,71,.36); }
body.sn-platform .mf-card-devlab { --accent:#78d7a0; --accent-2:#b4ebc8; --glow-s:rgba(120,215,160,.40); }
body.sn-platform .mf-card-syntaxsaga { --accent:#b176ef; --accent-2:#dfbfff; --glow-s:rgba(177,118,239,.40); }
body.sn-platform .mf-card-codeescape { --accent:#e5ae5b; --accent-2:#f8d09b; --glow-s:rgba(229,174,91,.38); }
body.sn-platform .mf-card-syntaxsaga { --accent:#9276f0; --accent-2:#d1c8ff; --glow-s:rgba(146,118,240,.40); }
body.sn-platform .mf-card-codeescape { --accent:#edcb4c; --accent-2:#fff0a8; --glow-s:rgba(237,203,76,.38); }
body.sn-platform .mf-card-serviceclash { --accent:#3abec5; --accent-2:#9de9e6; --glow-s:rgba(58,190,197,.38); }
body.sn-platform .mf-card-bughunter { --accent:#e85e68; --accent-2:#ffb6bd; --glow-s:rgba(232,94,104,.38); }
body.sn-platform .mf-card-networker { --accent:#55bdf0; --accent-2:#b2e7ff; --glow-s:rgba(85,189,240,.38); }
body.sn-platform .mf-card-finalprotocol { --accent:#f09043; --accent-2:#ffd0a2; --glow-s:rgba(240,144,67,.38); }
body.sn-platform .mf-card-finalprotocol { --accent:#ed7849; --accent-2:#ffd0b5; --glow-s:rgba(237,120,73,.38); }
body.sn-platform .mf-footer { background:rgba(3,11,13,.72); border-color:#173438; }
body.sn-platform .mf-center { min-height:100vh; position:relative; }
body.sn-platform .auth-container { background:linear-gradient(145deg,rgba(10,29,32,.98),rgba(4,14,17,.98)); border-color:rgba(120,215,160,.46); }
@@ -35,7 +35,7 @@
<header style="margin-bottom:1.4rem">
<div class="mf-eyebrow">Control Center</div>
<h1 style="color:var(--heading); font-size:1.85rem; font-weight:800; margin-top:.35rem">✨ Admin-Dashboard ✨</h1>
<p style="color:var(--muted); margin-top:.4rem">Hier steuerst du die Plattform und ihre Lerninhalte.</p>
<p style="color:var(--muted); margin-top:.4rem">Hier steuerst du die verfügbaren Lerninhalte und die Benutzerverwaltung.</p>
</header>
<main>
@@ -52,7 +52,8 @@
<a th:href="@{/admin/bughunter}" class="admin-card">🕵️ BugHunter-Fallakten verwalten</a>
</div>
<p style="margin-top:1.1rem; color:var(--muted);">
Hier entsteht noch Großes... ✨ Bleib dran!
Für ServiceClash, NetWorker und FinalProtocol gibt es derzeit bewusst keine
Inhaltsverwaltung: Ihre Aufgaben werden noch direkt mit der Anwendung ausgeliefert.
</p>
</div>
+66 -12
View File
@@ -10,10 +10,21 @@
<script src="/js/theme.js"></script>
<style>
/* Dashboard-spezifische Widgets (rechte Spalte) */
.gauge { display:flex; flex-direction:column; align-items:center; gap:.2rem; }
.gauge svg { width:130px; height:130px; }
.gauge .ok { color:var(--accent); font-weight:800; font-size:1rem; letter-spacing:.05em; margin-top:.3rem; text-shadow:0 0 12px var(--glow-s); }
.gauge .op { color:var(--muted); font-size:.72rem; }
.network-status { display:grid; grid-template-columns:auto 1fr; gap:.85rem; align-items:start; }
.network-status svg { width:54px; height:54px; color:var(--accent); filter:drop-shadow(0 0 8px var(--glow-s)); }
.status-summary { color:var(--heading); font-size:.9rem; font-weight:800; letter-spacing:.04em; }
.status-summary::before { content:""; display:inline-block; width:.52rem; height:.52rem; margin-right:.42rem; border-radius:50%; background:var(--muted); box-shadow:0 0 8px color-mix(in srgb,var(--muted) 60%,transparent); }
.network-status.ready .status-summary { color:var(--accent); }
.network-status.ready .status-summary::before { background:var(--accent); box-shadow:0 0 8px var(--glow-s); }
.network-status.restricted .status-summary { color:#edcb4c; }
.network-status.restricted .status-summary::before { background:#edcb4c; box-shadow:0 0 8px rgba(237,203,76,.48); }
.status-caption { color:var(--muted); font-size:.72rem; line-height:1.45; margin:.25rem 0 0; }
.status-details { grid-column:1 / -1; display:none; gap:.45rem; padding-top:.2rem; color:var(--muted); font-size:.72rem; }
.status-details.open { display:grid; }
.status-details div { display:flex; justify-content:space-between; gap:1rem; border-top:1px solid var(--border); padding-top:.45rem; }
.status-details b { color:var(--text); font-weight:600; text-align:right; }
.status-toggle { grid-column:1 / -1; justify-self:start; border:0; padding:0; background:transparent; color:var(--secondary); font:inherit; font-size:.72rem; cursor:pointer; }
.status-toggle:hover { text-decoration:underline; }
.prog-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:.4rem; }
.pill { font-size:.68rem; color:var(--muted); border:1px solid var(--border); border-radius:7px; padding:.2rem .5rem; }
.prog-big { display:flex; align-items:baseline; gap:.5rem; }
@@ -68,15 +79,19 @@
<!-- ===== Rechte Spalte ===== -->
<aside class="mf-rightbar">
<div class="mf-panel">
<h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h4l2-7 4 14 2-7h6"/></svg> System Status</h4>
<div class="gauge">
<svg viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" stroke="var(--line)" stroke-width="6"/>
<circle cx="60" cy="60" r="50" stroke="var(--accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="300 314" transform="rotate(-90 60 60)" style="filter:drop-shadow(0 0 6px var(--glow-s))"/>
<path d="M37 52l17-13 20 8-8 21-22 8-12-15zM54 39l-2 22m22-14-22 14m-8 15 8-15m14 7-14-7" stroke="var(--accent)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="37" cy="52" r="3" fill="var(--accent)"/><circle cx="54" cy="39" r="3" fill="var(--accent)"/><circle cx="74" cy="47" r="3" fill="var(--accent)"/><circle cx="66" cy="68" r="3" fill="var(--accent)"/><circle cx="44" cy="76" r="3" fill="var(--accent)"/><circle cx="52" cy="61" r="3" fill="var(--accent)"/>
<h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h4l2-7 4 14 2-7h6"/></svg> Lernnetz-Status</h4>
<div class="network-status" id="platformStatus" aria-live="polite">
<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 26 29 14l18 8-5 20-20 8-9-13zM29 14l-2 22m20-14-20 14m-5 14 5-14m15 6-15-6"/>
<circle cx="15" cy="26" r="3" fill="currentColor"/><circle cx="29" cy="14" r="3" fill="currentColor"/><circle cx="47" cy="22" r="3" fill="currentColor"/><circle cx="42" cy="42" r="3" fill="currentColor"/><circle cx="22" cy="50" r="3" fill="currentColor"/><circle cx="27" cy="36" r="3" fill="currentColor"/>
</svg>
<div class="ok">ALLE SYSTEME STABIL</div><div class="op">100% OPERATIONAL</div>
<div><div class="status-summary" data-status-summary>Lernnetz wird geprüft</div><p class="status-caption" data-status-caption>Prüfe Fortschrittsspeicher und Lernmodule …</p></div>
<div class="status-details" data-status-details>
<div><span>Fortschrittsspeicher</span><b data-status-storage>Wird geprüft</b></div>
<div><span>Lernmodule</span><b data-status-modules>Wird geprüft</b></div>
<div><span>Letzte Prüfung</span><b data-status-checked>–</b></div>
</div>
<button class="status-toggle" type="button" data-status-toggle aria-expanded="false">Details anzeigen</button>
</div>
</div>
@@ -156,6 +171,45 @@
const points = values.map((value, index) => `${Math.round(index * 40)} ${64 - Math.round(value / max * 54)}`);
const line = `M${points.join(' L')}`;
weekChart.querySelector('svg').innerHTML = `<defs><linearGradient id="ar" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--accent)" stop-opacity=".35"/><stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs><path d="${line} L240 74 L0 74 Z" fill="url(#ar)"/><path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" style="filter:drop-shadow(0 0 5px var(--glow-s))"/>`;
// Der Status prüft den Fortschrittsspeicher serverseitig. Die Modulzahl ist
// der registrierte Lernkatalog, nicht ein erfundener Prozentwert.
const statusCard = document.getElementById('platformStatus');
const statusSummary = statusCard.querySelector('[data-status-summary]');
const statusCaption = statusCard.querySelector('[data-status-caption]');
const statusStorage = statusCard.querySelector('[data-status-storage]');
const statusModules = statusCard.querySelector('[data-status-modules]');
const statusChecked = statusCard.querySelector('[data-status-checked]');
const details = statusCard.querySelector('[data-status-details]');
const toggle = statusCard.querySelector('[data-status-toggle]');
toggle.addEventListener('click', () => {
const open = details.classList.toggle('open');
toggle.setAttribute('aria-expanded', String(open));
toggle.textContent = open ? 'Details ausblenden' : 'Details anzeigen';
});
fetch('/api/platform/status', { headers: { Accept: 'application/json' } })
.then(async response => ({ response, status: await response.json() }))
.then(({ response, status }) => {
const ready = response.ok && status.storageConnected;
statusCard.classList.toggle('ready', ready);
statusCard.classList.toggle('restricted', !ready);
statusSummary.textContent = ready ? 'Bereit für deine Session' : 'Lernnetz eingeschränkt';
statusCaption.textContent = ready
? 'Fortschritt wird sicher gespeichert.'
: 'Fortschritt kann gerade nicht zuverlässig gespeichert werden.';
statusStorage.textContent = ready ? 'Verbunden' : 'Nicht erreichbar';
statusModules.textContent = `${status.registeredModules} registriert`;
statusChecked.textContent = new Date(status.checkedAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
})
.catch(() => {
statusCard.classList.add('restricted');
statusSummary.textContent = 'Statusprüfung nicht möglich';
statusCaption.textContent = 'Der aktuelle Speicherzustand konnte nicht bestätigt werden.';
statusStorage.textContent = 'Unbekannt';
statusModules.textContent = 'Nicht bestätigt';
});
</script>
</body>
</html>
+2 -4
View File
@@ -8,7 +8,6 @@
<script src="/js/theme.js"></script>
<style>
.mf-cardbox { max-width:560px; text-align:center; }
.mf-rocket { font-size:3rem; margin-bottom:.6rem; filter:drop-shadow(0 0 14px var(--glow-s)); }
.mf-cardbox h1 { color:var(--heading); font-size:1.5rem; font-weight:800; margin-bottom:.5rem; }
.mf-cardbox .slogan { color:var(--muted); font-size:.95rem; margin-bottom:1.6rem; }
.mf-cardbox .quote { color:var(--accent); font-size:1.05rem; line-height:1.6; font-style:italic;
@@ -23,9 +22,8 @@
<div class="mf-center">
<div class="mf-cardbox">
<div class="mf-rocket">🚀</div>
<h1>Zukunft in Arbeit</h1>
<p class="slogan">Hier entsteht etwas, das eure Zeitlinie verändern wird...</p>
<h1>Bereich in Vorbereitung</h1>
<p class="slogan">Hier entsteht ein weiterer Knoten für dein Lernnetz.</p>
<p class="quote">
"Dieser Bereich wird gerade für dein Lernnetz vorbereitet."
+1 -1
View File
@@ -80,7 +80,7 @@
<input id="reg-password2" class="mf-input" type="password" name="confirmPassword" placeholder="Passwort wiederholen" required>
</div>
<p id="reg-pw-msg" class="mf-alert err" style="display:none">Die Passwörter stimmen nicht überein.</p>
<button class="neon-btn" type="submit"><span class="lock">🚀</span> Account erstellen</button>
<button class="neon-btn" type="submit">Account erstellen</button>
</form>
<p class="mobile-switch">Schon dabei? <a data-goto="login">Zum Login</a></p>
+3 -2
View File
@@ -81,8 +81,9 @@
<!-- Diagramme -->
<script th:inline="javascript">
// Theme-Tokens auslesen, damit die Charts in Dark (Grün) & Light (Magenta) passen
const css = getComputedStyle(document.documentElement);
// Die Plattform-Tokens liegen auf body.sn-platform. Von dort lesen, damit die
// Diagramme nicht auf die alte globale Akzentfarbe zurückfallen.
const css = getComputedStyle(document.body);
const accent = css.getPropertyValue('--accent').trim() || '#2bd96a';
const track = css.getPropertyValue('--surface-2').trim() || '#2c2c2c';
const tick = css.getPropertyValue('--text').trim() || '#f1f1f1';
@@ -4,11 +4,14 @@ import com.example.mindflux.config.SecurityConfig;
import com.example.mindflux.controller.AdminUserController;
import com.example.mindflux.controller.DashboardController;
import com.example.mindflux.controller.ProgressController;
import com.example.mindflux.controller.PlatformStatusController;
import com.example.mindflux.dto.ProgressData;
import com.example.mindflux.dto.PlatformStatus;
import com.example.mindflux.dto.WeeklyActivity;
import com.example.mindflux.repository.UserRepository;
import com.example.mindflux.service.LoginAttemptService;
import com.example.mindflux.service.ProgressService;
import com.example.mindflux.service.PlatformStatusService;
import com.example.mindflux.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -27,7 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Absichert die zentralen Security-Regeln: Login-Zwang, Admin-Schutz, gelöschter Legacy-Endpunkt. */
@WebMvcTest(controllers = {DashboardController.class, ProgressController.class, AdminUserController.class})
@WebMvcTest(controllers = {DashboardController.class, ProgressController.class, PlatformStatusController.class, AdminUserController.class})
@Import(SecurityConfig.class)
class SecurityRulesTest {
@@ -37,6 +40,9 @@ class SecurityRulesTest {
@MockitoBean
private ProgressService progressService;
@MockitoBean
private PlatformStatusService platformStatusService;
@MockitoBean
private UserService userService;
@@ -60,6 +66,21 @@ class SecurityRulesTest {
.andExpect(status().isOk());
}
@Test
void lernnetzStatusIstNichtOeffentlich() throws Exception {
mockMvc.perform(get("/api/platform/status"))
.andExpect(status().is3xxRedirection());
}
@Test
@WithMockUser(username = "alice")
void eingeloggteNutzerKoennenDenLernnetzStatusPruefen() throws Exception {
when(platformStatusService.currentStatus()).thenReturn(new PlatformStatus(true, 9, java.time.Instant.now()));
mockMvc.perform(get("/api/platform/status"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "alice")
void eingeloggteNutzerSehenDieFortschrittsseite() throws Exception {
@@ -0,0 +1,38 @@
package com.example.mindflux.service;
import com.example.mindflux.repository.GameScoreRepository;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PlatformStatusServiceTest {
@Test
void reportsReadyOnlyWhenTheProgressStoreCanBeRead() {
GameScoreRepository repository = mock(GameScoreRepository.class);
when(repository.count()).thenReturn(12L);
var status = new PlatformStatusService(repository).currentStatus();
assertThat(status.ready()).isTrue();
assertThat(status.storageConnected()).isTrue();
assertThat(status.registeredModules()).isEqualTo(9);
verify(repository).count();
}
@Test
void reportsRestrictedWhenTheProgressStoreIsUnavailable() {
GameScoreRepository repository = mock(GameScoreRepository.class);
doThrow(new DataAccessResourceFailureException("database unavailable")).when(repository).count();
var status = new PlatformStatusService(repository).currentStatus();
assertThat(status.ready()).isFalse();
assertThat(status.storageConnected()).isFalse();
}
}