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:
parent
578a0a41f7
commit
3a4523da3e
20 changed files with 1778 additions and 273 deletions
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue