Der Server wusste, was du zuletzt gesehen hast — und sonst nichts
Für „wie viel habe ich letzte Woche geschaut" gab es keine Daten. `UserItemState` ist eine Zeile je Titel: der letzte Stand. Wer einen Film zweimal sieht, überschreibt den ersten Durchgang; wann etwas lief, wusste der Server nur fürs jeweils letzte Mal. Nachträglich erfinden lässt sich das nicht. Jetzt gibt es ein Sehprotokoll: **eine Zeile je Nutzer, Tag und Titel.** Keine Sitzungen — die brauchen ein Ende, und das kommt oft nie an, wenn der Browser zugemacht oder das Telefon zugeklappt wird. Tagesbündel haben diesen Lebenslauf nicht, es wird nur addiert. **Gezählt wird, was wirklich lief.** Der Zuwachs zwischen zwei Meldungen, gedeckelt auf die Zeit, die dazwischen verstrichen ist (mal zwei als Toleranz für doppelte Geschwindigkeit und ausgefallene Meldungen). Sonst ergäbe ein Sprung von Minute 5 auf Minute 90 anderthalb Stunden Sehzeit in zehn Sekunden. Gemessen an fünf Meldungen im Abstand von elf Sekunden: 88 Sekunden statt 190. Dabei fiel ein zweiter Fall auf: war **gar keine** Zeit verstrichen, griff kein Deckel statt eines engen — eine frisch angelegte Zeile hätte ihre Anfangsstelle voll mitgezählt. Wer einen Film öffnet und sofort auf Minute fünf springt, hat keine fünf Minuten gesehen. Im Profil stehen drei Zahlen, und jede sagt, woher sie kommt: die aufgezeichnete Zeit fängt heute an, die Zahl gesehener Titel steht seit jeher fest. Dazu eine Wochenkurve, die häufigsten Genres und was zuletzt lief. **Jeder sieht nur sich selbst, auch ein Administrator** — es gibt keinen Weg, ein fremdes Konto zu meinen. Zwei Dinge auf dem Weg dorthin: **Eine stellbare Uhr für die Tests.** Zwei Minuten Film lassen sich nicht in fünf Millisekunden sehen, und das ist richtig so — prüfen liess es sich nur, indem die Zeit vergeht, ohne sie zu verbringen. **Ein Namenskonflikt im CSS.** Die leeren Wochenbalken trugen die Klasse `leer`, und die gehört längst der Fläche „hier ist nichts" — mitsamt 24 Pixeln Polster. Jeder leere Balken war dadurch 48 Pixel hoch, egal welche Höhe dastand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d8a1633cf4
commit
579a49b73b
@@ -853,6 +853,27 @@ export const wiedergabeSetzen = (werte: Partial<Wiedergabeeinstellung>) =>
|
||||
}),
|
||||
});
|
||||
|
||||
/** Was jemand geschaut hat — nur für ihn selbst. */
|
||||
export type Statistik = {
|
||||
/** Aus dem Sehprotokoll. Fängt an dem Tag an, an dem es eingebaut wurde. */
|
||||
loggedSeconds: number;
|
||||
/** Aus den Sehständen — für die ganze Vergangenheit vollständig. */
|
||||
watchedTitles: number;
|
||||
watchedRuntimeSeconds: number;
|
||||
/** Seit wann protokolliert wird. `null`, solange nichts darin steht. */
|
||||
since: string | null;
|
||||
weeks: { start: string; seconds: number }[];
|
||||
recent: {
|
||||
id: string; kind: 'movie' | 'episode';
|
||||
title: string; subTitle: string | null;
|
||||
posterImage: string | null; at: string; watched: boolean;
|
||||
}[];
|
||||
genres: { name: string; seconds: number }[];
|
||||
};
|
||||
|
||||
export const statistik = (wochen = 12) =>
|
||||
anfrage<Statistik>(`/users/me/statistics${abfrage({ wochen })}`);
|
||||
|
||||
export type Speicherort = {
|
||||
name: string; variable: string; pfad: string;
|
||||
belegt: number; frei: number | null; bilder: number | null;
|
||||
|
||||
@@ -1265,6 +1265,65 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
|
||||
.profil-kopf { flex-direction: column; align-items: flex-start; gap: var(--s-loose); }
|
||||
}
|
||||
|
||||
/* Zahlen im Profil. Drei Kacheln, eine Wochenkurve, zwei Listen — alles von
|
||||
Hand gezeichnet. Für zwölf Balken eine Diagrammbibliothek zu laden hiesse,
|
||||
dreihundert Kilobyte für etwas mitzuschleppen, das aus Rechtecken besteht. */
|
||||
|
||||
.profil-zahlen { margin-bottom: var(--s-section); }
|
||||
.profil h3 { font-size: 13px; font-weight: 600; color: var(--text-dim); margin: 0 0 var(--s-snug); }
|
||||
|
||||
.zahlenreihe {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--s-regular); margin-bottom: var(--s-section);
|
||||
}
|
||||
.zahlkachel {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
padding: var(--s-loose); border-radius: var(--r-karte); background: var(--elevated);
|
||||
}
|
||||
.zahlkachel strong { font-size: 22px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.zahlkachel > span { font-size: var(--t-normal); color: var(--text-dim); }
|
||||
.zahlkachel .winzig { color: var(--text-faint); }
|
||||
|
||||
.wochen {
|
||||
display: flex; align-items: flex-end; gap: 6px;
|
||||
margin-bottom: var(--s-section);
|
||||
}
|
||||
/* Hundert Pixel für den höchsten Balken, zwanzig für die Beschriftung — die
|
||||
Höhe steht in `profil.ts` und kommt in Pixeln, nicht in Prozent. */
|
||||
.woche { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.woche-balken {
|
||||
flex: none; border-radius: 3px 3px 0 0;
|
||||
background: var(--accent); min-height: 2px;
|
||||
}
|
||||
/* Eine Woche ohne Sehzeit bleibt sichtbar — sonst sähe die Kurve aus, als
|
||||
fehlten dort Daten, statt dass nichts lief. */
|
||||
.woche-balken.woche-leer { background: var(--line); }
|
||||
.woche-marke {
|
||||
flex: none; font-size: 10px; color: var(--text-faint); text-align: center;
|
||||
padding-top: 4px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.genres { display: flex; flex-direction: column; gap: var(--s-snug); margin-bottom: var(--s-section); }
|
||||
.genre { display: flex; align-items: center; gap: var(--s-regular); font-size: var(--t-normal); }
|
||||
.genre-name { flex: 0 0 110px; color: var(--text-dim); }
|
||||
.genre-balken { flex: 1; height: 8px; border-radius: 4px; background: var(--elevated); overflow: hidden; }
|
||||
.genre-balken i { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
|
||||
.genre-wert { flex: none; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.zuletzt { display: flex; flex-direction: column; gap: 2px; }
|
||||
.zuletzt-zeile {
|
||||
display: flex; align-items: center; gap: var(--s-regular);
|
||||
background: none; border: 0; text-align: left; color: var(--text);
|
||||
padding: var(--s-snug); border-radius: var(--r-control);
|
||||
}
|
||||
.zuletzt-zeile:hover { background: var(--elevated); }
|
||||
.zuletzt-zeile img, .zuletzt-zeile .platzhalter {
|
||||
flex: none; width: 32px; height: 48px; border-radius: 4px;
|
||||
object-fit: cover; background: var(--placeholder);
|
||||
}
|
||||
.zuletzt-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.zuletzt-text strong { font-size: var(--t-normal); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* --- Verwaltung ----------------------------------------------------------
|
||||
*
|
||||
* **Eine zweite Leiste, keine zweite Seite.** Die Verwaltung war elf
|
||||
|
||||
+139
-2
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
sitzung, meinKonto, profilbildSetzen, profilbildEntfernen, bildAdresse,
|
||||
spracheSetzen, sprachen, abmelden, istAdmin, type Rolle,
|
||||
spracheSetzen, sprachen, abmelden, istAdmin, statistik,
|
||||
type Rolle, type Statistik,
|
||||
} from '../api';
|
||||
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../util';
|
||||
import { dialog, nachfragen } from '../dialog';
|
||||
@@ -40,7 +41,135 @@ export function profilseite(): HTMLElement {
|
||||
if (sitzung.wer) Object.assign(sitzung.wer, konto);
|
||||
|
||||
wurzel.innerHTML = '';
|
||||
wurzel.append(kopf(konto), einstellungen(konto), abmeldeteil());
|
||||
const zahlen = document.createElement('section');
|
||||
zahlen.className = 'profil-zahlen';
|
||||
wurzel.append(kopf(konto), zahlen, einstellungen(konto), abmeldeteil());
|
||||
void zahlenLaden(zahlen);
|
||||
}
|
||||
|
||||
/* ---------- Was man geschaut hat ---------- */
|
||||
|
||||
async function zahlenLaden(ziel: HTMLElement): Promise<void> {
|
||||
ziel.innerHTML = '<div class="spinner"></div>';
|
||||
let stand: Statistik;
|
||||
try {
|
||||
stand = await statistik();
|
||||
} catch (fehler) {
|
||||
ziel.innerHTML = '';
|
||||
ziel.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return;
|
||||
}
|
||||
|
||||
ziel.innerHTML = '';
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Geschaut';
|
||||
ziel.appendChild(h);
|
||||
|
||||
// **Drei Zahlen, und jede sagt, woher sie kommt.**
|
||||
//
|
||||
// Die Gesamtzeit stammt aus dem Protokoll und fängt an dem Tag an, an dem
|
||||
// es eingebaut wurde. Die Zahl gesehener Titel steht seit jeher fest. Das
|
||||
// zu einer Zahl zu verrühren hiesse, die eine zu behaupten.
|
||||
const kacheln = document.createElement('div');
|
||||
kacheln.className = 'zahlenreihe';
|
||||
kacheln.append(
|
||||
zahl(stunden(stand.loggedSeconds), 'aufgezeichnet',
|
||||
stand.since
|
||||
? `seit ${new Date(stand.since).toLocaleDateString('de-CH', { dateStyle: 'medium' })}`
|
||||
: 'noch nichts aufgezeichnet'),
|
||||
zahl(String(stand.watchedTitles), stand.watchedTitles === 1 ? 'Titel gesehen' : 'Titel gesehen',
|
||||
'mit Häkchen, auch von früher'),
|
||||
zahl(stunden(stand.watchedRuntimeSeconds), 'Laufzeit davon',
|
||||
'die Länge aller abgehakten Titel'),
|
||||
);
|
||||
ziel.appendChild(kacheln);
|
||||
|
||||
ziel.appendChild(wochenkurve(stand));
|
||||
|
||||
if (stand.genres.length > 0) ziel.appendChild(genreliste(stand));
|
||||
if (stand.recent.length > 0) ziel.appendChild(zuletztliste(stand));
|
||||
}
|
||||
|
||||
/** Die Wochenkurve als Balken — zwölf Werte brauchen keine Bibliothek. */
|
||||
function wochenkurve(stand: Statistik): HTMLElement {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'wochen';
|
||||
|
||||
const hoechstwert = Math.max(...stand.weeks.map(w => w.seconds), 1);
|
||||
stand.weeks.forEach(w => {
|
||||
const saeule = document.createElement('div');
|
||||
saeule.className = 'woche';
|
||||
const balken = document.createElement('div');
|
||||
balken.className = 'woche-balken';
|
||||
// **In Pixeln, nicht in Prozent.** Eine Prozenthöhe in einer Flexspalte
|
||||
// löst sich nicht gegen das auf, was man erwartet — gemessen: aus „2 %"
|
||||
// wurden 48 Pixel. Hundert Pixel sind der Platz, der da ist.
|
||||
// Mindestens zwei, damit eine leere Woche als leer zu sehen ist und
|
||||
// nicht als fehlend.
|
||||
balken.style.height = `${Math.max(2, Math.round((w.seconds / hoechstwert) * 100))}px`;
|
||||
// **`woche-leer`, nicht `leer`.** Den kurzen Namen gibt es schon: er
|
||||
// gehört der Fläche „hier ist nichts" und bringt 24 Pixel Polster mit.
|
||||
// Der Balken wurde dadurch 48 Pixel hoch, egal welche Höhe dastand.
|
||||
if (w.seconds === 0) balken.classList.add('woche-leer');
|
||||
const datum = new Date(w.start);
|
||||
saeule.title = `Woche ab ${datum.toLocaleDateString('de-CH', { dateStyle: 'medium' })}: `
|
||||
+ (w.seconds > 0 ? stunden(w.seconds) : 'nichts');
|
||||
const marke = document.createElement('span');
|
||||
marke.className = 'woche-marke';
|
||||
marke.textContent = datum.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' });
|
||||
saeule.append(balken, marke);
|
||||
block.appendChild(saeule);
|
||||
});
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function genreliste(stand: Statistik): HTMLElement {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'genres';
|
||||
const groesste = Math.max(...stand.genres.map(g => g.seconds), 1);
|
||||
stand.genres.forEach(g => {
|
||||
const zeileG = document.createElement('div');
|
||||
zeileG.className = 'genre';
|
||||
zeileG.innerHTML = `<span class="genre-name">${esc(g.name)}</span>`
|
||||
+ `<span class="genre-balken"><i style="width:${Math.round((g.seconds / groesste) * 100)}%"></i></span>`
|
||||
+ `<span class="genre-wert">${esc(stunden(g.seconds))}</span>`;
|
||||
block.appendChild(zeileG);
|
||||
});
|
||||
return block;
|
||||
}
|
||||
|
||||
function zuletztliste(stand: Statistik): HTMLElement {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'zuletzt';
|
||||
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = 'Zuletzt gesehen';
|
||||
block.appendChild(h);
|
||||
|
||||
stand.recent.slice(0, 8).forEach(e => {
|
||||
const k = document.createElement('button');
|
||||
k.type = 'button';
|
||||
k.className = 'zuletzt-zeile';
|
||||
const adresse = bildAdresse(e.posterImage);
|
||||
k.innerHTML = (adresse ? `<img src="${adresse}" alt="">` : '<span class="platzhalter"></span>')
|
||||
+ `<span class="zuletzt-text"><strong>${esc(e.title)}</strong>`
|
||||
+ (e.subTitle ? `<span class="winzig">${esc(e.subTitle)}</span>` : '')
|
||||
+ `</span>`
|
||||
+ `<span class="winzig">${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}</span>`;
|
||||
k.onclick = () => { location.hash = `#/${e.kind === 'episode' ? 'serien' : 'filme'}/${e.id}`; };
|
||||
block.appendChild(k);
|
||||
});
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function zahl(wert: string, was: string, hinweis: string): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'zahlkachel';
|
||||
d.innerHTML = `<strong>${esc(wert)}</strong><span>${esc(was)}</span>`
|
||||
+ `<span class="winzig">${esc(hinweis)}</span>`;
|
||||
return d;
|
||||
}
|
||||
|
||||
/* ---------- Bild und Name ---------- */
|
||||
@@ -199,6 +328,14 @@ function kopfleisteAuffrischen(): void {
|
||||
window.dispatchEvent(new CustomEvent('flaro:konto'));
|
||||
}
|
||||
|
||||
/** „12 Std. 30 Min." — oder „noch nichts", solange nichts dasteht. */
|
||||
function stunden(sekunden: number): string {
|
||||
if (sekunden < 60) return '0 Min.';
|
||||
const h = Math.floor(sekunden / 3600);
|
||||
const m = Math.round((sekunden % 3600) / 60);
|
||||
return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
|
||||
}
|
||||
|
||||
export function anfangsbuchstabe(name: string): string {
|
||||
return (name.slice(0, 1) || '?').toUpperCase();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user