feat(web): UI-Overhaul — Mobile-Nav, Sprachauswahl, 5 Sprachen, Stats-Karten

Mobile-Nav scrollt horizontal und ist auf der Login-Seite ausgeblendet.
Nav-Innere Container entfernt (PillTabGroup → flache Buttons). Sprachauswahl
von der Nav auf die Account-Page verschoben (eigene Karte mit Vollnamen,
vertikales Layout). 5 Locales: DE, EN, FR, IT, ES mit vollständigen
Übersetzungen. Account-Karte erlaubt Namensbearbeitung. Stats-Page komplett
auf Card-Aesthetic umgebaut (ChartBar, Fire, Brain, CalendarDots, Target,
CalendarCheck — keine Emojis). Zwei neue Stats-Karten: Retention-Rate
(lapses/reps) und Fälligkeitsvorschau (nächste 7 Tage). API um
retention_rate, retention_reps, retention_lapses, due_forecast erweitert.
84-Tage-Activity-Grid hinzugefügt. TS-Fehler aus Locale-Erweiterung behoben
(ClozeCardForm number[], decks/new + NewDeckCard Locale-Typ).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 14:20:01 +02:00
parent 578a0a41f7
commit 3a4523da3e
20 changed files with 1778 additions and 273 deletions

View file

@ -40,7 +40,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
const userId = c.get('userId');
const db = dbOf();
const now = new Date();
const thirtyAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const ninetyAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000);
const [deckCountRow] = await db
.select({ n: sql<number>`count(*)::int` })
@ -76,7 +76,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
};
for (const row of stateRows) stateCounts[row.state] = row.n;
// Reviews pro Tag — gruppiert auf UTC-Tag.
// Reviews pro Tag — 91-Tage-Fenster für Grid + Streak + 7-Tage-Chart.
const dayRows = await db
.select({
day: sql<string>`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`,
@ -84,7 +84,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
})
.from(reviews)
.where(
and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, thirtyAgo))
and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, ninetyAgo))
)
.groupBy(sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`);
@ -96,15 +96,53 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
return { day: key, n: byDay.get(key) ?? 0 };
});
// Streak: rückwärts ab heute (UTC) bis zum ersten Tag ohne Reviews.
// 84 Tage (12 Wochen) für das Activity-Grid, ältester Tag zuerst.
const activity84 = Array.from({ length: 84 }, (_, i) => {
const d = new Date(Date.now() - (83 - i) * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
return { day: key, n: byDay.get(key) ?? 0 };
});
// Streak: rückwärts ab heute bis zum ersten Tag ohne Reviews.
let streak = 0;
for (let i = 0; i < 30; i++) {
for (let i = 0; i < 91; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
if ((byDay.get(key) ?? 0) > 0) streak++;
else break;
}
// Retention: Verhältnis (reps - lapses) / reps über alle Reviews.
const [retentionRow] = await db
.select({
totalReps: sql<number>`coalesce(sum(${reviews.reps}), 0)::int`,
totalLapses: sql<number>`coalesce(sum(${reviews.lapses}), 0)::int`,
})
.from(reviews)
.where(eq(reviews.userId, userId));
const totalReps = retentionRow?.totalReps ?? 0;
const totalLapses = retentionRow?.totalLapses ?? 0;
const retentionRate = totalReps > 0 ? (totalReps - totalLapses) / totalReps : null;
// Fälligkeitsvorschau: nächste 7 Tage (ab jetzt).
const sevenAhead = new Date(Date.now() + 8 * 24 * 60 * 60 * 1000);
const forecastRows = await db
.select({
day: sql<string>`to_char(${reviews.due}, 'YYYY-MM-DD')`,
n: sql<number>`count(*)::int`,
})
.from(reviews)
.where(and(eq(reviews.userId, userId), gte(reviews.due, now), lte(reviews.due, sevenAhead)))
.groupBy(sql`to_char(${reviews.due}, 'YYYY-MM-DD')`);
const forecastMap = new Map(forecastRows.map((r) => [r.day, r.n]));
const dueForecast = Array.from({ length: 7 }, (_, i) => {
const d = new Date(Date.now() + i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
return { day: key, n: forecastMap.get(key) ?? 0 };
});
return c.json({
user_id: userId,
generated_at: now.toISOString(),
@ -114,7 +152,12 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
due_now: dueCountRow?.n ?? 0,
state_counts: stateCounts,
reviewed_per_day: reviewed7,
activity_days: activity84,
streak_days: streak,
retention_rate: retentionRate,
retention_reps: totalReps,
retention_lapses: totalLapses,
due_forecast: dueForecast,
});
});