Feat : QOL - better filter
All checks were successful
release / build (push) Successful in 27s
release / verify-windows (push) Successful in 1m24s

This commit is contained in:
jeanotx32
2026-08-14 21:17:02 -04:00
parent da9952d756
commit 9a019447e7
8 changed files with 565 additions and 86 deletions

View File

@@ -45,6 +45,8 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
profil en direct, agrandie au survol. profil en direct, agrandie au survol.
- [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la - [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la
liste par étiquette. liste par étiquette.
- [Recherche et tri](#recherche-et-tri) : un champ de recherche sur les deux listes (nom,
pseudo ou étiquette, accents et casse ignorés) et quatre ordres de tri sur la frise.
- [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil - [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil
suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous
ensemble dans l'onglet « Timeline ». Une ensemble dans l'onglet « Timeline ». Une
@@ -794,6 +796,64 @@ Une étiquette survit à la suppression du dernier profil qui la portait — ell
pour la suite. Inversement, ne plus suivre un profil n'emporte que ses rattachements pour la suite. Inversement, ne plus suivre un profil n'emporte que ses rattachements
(`ON DELETE CASCADE` des deux côtés de la table de liaison). (`ON DELETE CASCADE` des deux côtés de la table de liaison).
## Recherche et tri
### Chercher un streamer
Un champ de recherche ouvre la barre de filtres des onglets **Streamers** et **Timeline**. Il
filtre **à la frappe**, sans bouton ni validation : les profils sont déjà chargés côté
navigateur, il n'y a rien à attendre d'un aller-retour serveur — et en imposer un
transformerait un tri visuel en formulaire.
Il cherche **à la fois dans le nom affiché, le pseudo et les étiquettes**. Ce sont les trois
façons dont on désigne un profil ; exiger de savoir laquelle s'applique reviendrait à demander
de chercher ce qu'on a déjà trouvé.
**Ni la casse ni les accents ne comptent** : taper `lea` trouve « Léa ». On saisit le pseudo
qu'on a en tête, pas celui qu'on a sous les yeux, et un accent oublié n'est pas une faute qui
mérite une liste vide. Plusieurs mots se cumulent, **dans n'importe quel ordre**`blonde lea`
retrouve « Léa » étiquetée « blonde ».
La recherche **se cumule** avec les autres filtres (statut, étiquettes, « Enregistrés ») : ce
sont des critères de nature différente, les laisser s'élargir les uns les autres ne filtrerait
plus rien. Seules les étiquettes entre elles forment une union (voir
[Étiquettes](#étiquettes)).
Une **croix** apparaît dès que le champ n'est plus vide, et la touche **Échap** fait la même
chose : un filtre qu'on ne sait pas défaire finit par mentir sur ce qu'on croit voir. Pour la
même raison, quand une liste est vide **le message nomme la recherche en cours** plutôt que de
parler d'un « filtre » indéterminé — c'est le critère qu'on oublie le plus facilement, les
autres se voyant à l'écran.
### Trier la frise
L'onglet **Timeline** propose quatre ordres, sélecteur *Trier par* :
| Ordre | Ce qu'il classe | À quoi il répond |
| --- | --- | --- |
| **Activité récente** (défaut) | Dernier signe de vie, diffusion ou capture | « Que s'est-il passé récemment ? » |
| **Temps enregistré** | Durée capturée, **pauses déduites** | « Sur quoi ai-je réellement du fichier ? » |
| **Temps de diffusion** | Durée en public, capturée ou non | « Qui diffuse le plus ? » |
| **Nom (A→Z)** | Libellé, ou pseudo à défaut | Retrouver une voie précise |
Les deux tris par durée **retombent sur l'activité récente à égalité** : des voies à zéro
seconde enregistrée sont fréquentes, et les laisser dans un ordre arbitraire donnerait une
liste qui se réorganise sans raison visible d'un rafraîchissement à l'autre. L'instant de
référence est figé une fois pour toutes au début du tri — relu à chaque comparaison, il ferait
bouger la durée des voies encore ouvertes en plein classement, et l'ordre obtenu pourrait alors
se contredire lui-même.
**Le tri par temps enregistré déduit les pauses**, comme les totaux. Sans ça, un profil souvent
en show privé passerait devant un profil réellement capturé, sur la foi d'un intervalle qui
n'écrivait rien.
**Les totaux de la légende suivent les filtres** : le résumé est posé sous la barre, il décrit
la frise qu'on a sous les yeux et non les données brutes — annoncer un total qui ne correspond
à rien de visible ferait douter du filtre autant que du chiffre. Le libellé le dit (« sur la
sélection » plutôt que « au total »). Une capture jamais rattachée à un profil suivi (lancée à
la main depuis l'onglet **Agents**) n'entre dans aucun total : elle n'a pas de voie où
s'afficher.
## Vignette du flux en direct ## Vignette du flux en direct
Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du

View File

@@ -0,0 +1,57 @@
interface Props {
value: string;
onChange: (value: string) => void;
placeholder: string;
/** Libellé lu par les lecteurs d'écran : le champ n'a pas d'étiquette visible. */
label: string;
}
/**
* Champ de recherche à filtrage immédiat.
*
* Pas de bouton « Chercher » ni de validation : la liste se réduit à la frappe.
* Sur une poignée de profils déjà chargés côté navigateur, il n'y a rien à
* attendre — et un aller-retour serveur transformerait un tri visuel en
* formulaire.
*
* La croix et la touche Échap font la même chose : revenir à la liste entière.
* Un filtre qu'on ne sait pas défaire est un filtre qui finit par mentir sur ce
* qu'on croit voir.
*/
export function SearchField({ value, onChange, placeholder, label }: Props) {
return (
<div className="search-field">
<span className="search-icon" aria-hidden="true">
</span>
<input
// `search` plutôt que `text` : le clavier mobile propose « Rechercher »
// au lieu de « Entrée », et l'historique de saisie reste propre.
type="search"
value={value}
placeholder={placeholder}
aria-label={label}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && value) {
// Sans ça, Firefox vide le champ sans que React le sache : l'écran
// afficherait un champ vide et une liste toujours filtrée.
event.preventDefault();
onChange('');
}
}}
/>
{value && (
<button
type="button"
className="search-clear"
onClick={() => onChange('')}
title="Effacer la recherche"
aria-label="Effacer la recherche"
>
</button>
)}
</div>
);
}

View File

@@ -6,18 +6,21 @@ import { formatDateTime, formatDuration, formatRelative } from '../format';
import { import {
PREVIEW_SIZES, PREVIEW_SIZES,
loadPreviewSize, loadPreviewSize,
matchesSearch,
matchesTags,
preemption, preemption,
previewSrc, previewSrc,
recordingContext, recordingContext,
savePreviewSize, savePreviewSize,
stateLabel, stateLabel,
tagStyle, toggleId,
usedTags, usedTags,
type PreviewSize, type PreviewSize,
type RecordingContext, type RecordingContext,
} from '../streamers'; } from '../streamers';
import { SearchField } from './SearchField';
import { StreamerDetail } from './StreamerDetail'; import { StreamerDetail } from './StreamerDetail';
import { TagChip, TagManager } from './Tags'; import { TagChip, TagFilter, TagManager } from './Tags';
interface Props { interface Props {
targets: WatchTarget[]; targets: WatchTarget[];
@@ -45,6 +48,7 @@ export function StreamersPage({
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState<FilterId>('all'); const [filter, setFilter] = useState<FilterId>('all');
const [query, setQuery] = useState('');
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set()); const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [managingTags, setManagingTags] = useState(false); const [managingTags, setManagingTags] = useState(false);
@@ -63,12 +67,7 @@ export function StreamersPage({
const tags = useMemo(() => usedTags(targets), [targets]); const tags = useMemo(() => usedTags(targets), [targets]);
function toggleTag(id: string) { function toggleTag(id: string) {
setSelectedTags((current) => { setSelectedTags((current) => toggleId(current, id));
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
} }
async function add(event: FormEvent) { async function add(event: FormEvent) {
@@ -107,13 +106,12 @@ export function StreamersPage({
); );
const visible = ordered.filter((target) => { const visible = ordered.filter((target) => {
// Les deux filtres se cumulent — « en direct » ET portant l'une des // Les trois filtres se cumulent — la recherche ET « en direct » ET portant
// étiquettes retenues. Entre étiquettes en revanche, c'est une union : en // l'une des étiquettes retenues. C'est une union entre étiquettes seulement
// cocher une seconde élargit la sélection, ce qui est le geste attendu // (voir `matchesTags`) : des critères de nature différente qui s'élargiraient
// quand on empile des critères de même nature. // les uns les autres ne filtreraient plus rien.
if (selectedTags.size > 0 && !target.tags.some((tag) => selectedTags.has(tag.id))) { if (!matchesSearch(target, query)) return false;
return false; if (!matchesTags(target, selectedTags)) return false;
}
if (filter === 'favorites') return target.favorite; if (filter === 'favorites') return target.favorite;
if (filter === 'live') return target.state === 'public'; if (filter === 'live') return target.state === 'public';
return true; return true;
@@ -144,6 +142,12 @@ export function StreamersPage({
{targets.length > 0 && ( {targets.length > 0 && (
<div className="row filter-row"> <div className="row filter-row">
<SearchField
value={query}
onChange={setQuery}
placeholder="Chercher un streamer…"
label="Chercher un streamer par nom, pseudo ou étiquette"
/>
{FILTERS.map((entry) => ( {FILTERS.map((entry) => (
<button <button
key={entry.id} key={entry.id}
@@ -169,34 +173,29 @@ export function StreamersPage({
{size.label} {size.label}
</button> </button>
))} ))}
<span className="muted small">{visible.length} profil(s)</span> <span className="muted small">
{visible.length} profil(s)
{/* Le total n'a d'intérêt qu'une fois la liste réduite : sans filtre,
il répète le nombre déjà affiché. */}
{visible.length !== targets.length && ` sur ${targets.length}`}
</span>
</div> </div>
)} )}
{targets.length > 0 && ( {targets.length > 0 && (
<div className="row filter-row tag-filters"> <div className="row filter-row tag-filters">
{tags.map((tag) => ( <TagFilter
<button tags={tags}
key={tag.id} selected={selectedTags}
className={selectedTags.has(tag.id) ? 'tag tag-filter active' : 'tag tag-filter'} onToggle={toggleTag}
style={tagStyle(tag.name)} onClear={() => setSelectedTags(new Set())}
onClick={() => toggleTag(tag.id)} />
title={`Ne garder que les profils portant « ${tag.name} »`}
>
{tag.name}
</button>
))}
{tags.length === 0 && ( {tags.length === 0 && (
<span className="muted small"> <span className="muted small">
Aucune étiquette pose-en une depuis la fiche d'un streamer pour trier la liste Aucune étiquette pose-en une depuis la fiche d'un streamer pour trier la liste
d'ici. d'ici.
</span> </span>
)} )}
{selectedTags.size > 0 && (
<button className="link-button" onClick={() => setSelectedTags(new Set())}>
tout afficher
</button>
)}
<div className="spacer" /> <div className="spacer" />
<button className="chip" onClick={() => setManagingTags(true)} title="Renommer, supprimer"> <button className="chip" onClick={() => setManagingTags(true)} title="Renommer, supprimer">
Étiquettes Étiquettes
@@ -213,7 +212,14 @@ export function StreamersPage({
</p> </p>
</div> </div>
) : visible.length === 0 ? ( ) : visible.length === 0 ? (
<p className="muted small">Aucun profil pour ce filtre.</p> // La recherche est nommée dans le message : c'est le filtre qu'on oublie
// le plus facilement — les autres se voient, une chaîne tapée puis
// laissée dans le champ passe inaperçue.
<p className="muted small">
{query.trim()
? `Aucun profil ne correspond à « ${query.trim()} ».`
: 'Aucun profil pour ce filtre.'}
</p>
) : ( ) : (
<div className={`streamer-grid preview-${previewSize}`}> <div className={`streamer-grid preview-${previewSize}`}>
{visible.map((target) => ( {visible.map((target) => (

View File

@@ -35,6 +35,46 @@ export function TagChip({
); );
} }
/**
* Rangée d'étiquettes servant de filtre, suivie de quoi tout réafficher.
*
* Rendue en fragment plutôt qu'en bloc : chaque page compose sa propre barre
* autour — la liste des streamers y ajoute la gestion des étiquettes, la frise
* son sélecteur de tri.
*/
export function TagFilter({
tags,
selected,
onToggle,
onClear,
}: {
tags: Array<{ id: string; name: string }>;
selected: ReadonlySet<string>;
onToggle: (id: string) => void;
onClear: () => void;
}) {
return (
<>
{tags.map((tag) => (
<button
key={tag.id}
className={selected.has(tag.id) ? 'tag tag-filter active' : 'tag tag-filter'}
style={tagStyle(tag.name)}
onClick={() => onToggle(tag.id)}
title={`Ne garder que les profils portant « ${tag.name} »`}
>
{tag.name}
</button>
))}
{selected.size > 0 && (
<button className="link-button" onClick={onClear}>
tout afficher
</button>
)}
</>
);
}
/** /**
* Pose et retire les étiquettes d'un profil. * Pose et retire les étiquettes d'un profil.
* *

View File

@@ -2,15 +2,11 @@ import { useMemo, useState } from 'react';
import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared'; import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared';
import { sessionPreviewSrc } from '../api'; import { sessionPreviewSrc } from '../api';
import { formatDuration } from '../format'; import { formatDuration } from '../format';
// Décrite là où elle est construite et filtrée, plutôt qu'ici où elle n'est que
// dessinée : le composant reçoit ses voies, il ne les compose pas.
import type { TimelineLane } from '../streamers';
export interface TimelineLane { export type { TimelineLane };
id: string;
label: string;
avatarUrl?: string | null;
sessions: StreamSession[];
spans: RecordingSpan[];
pauses: RecordingPause[];
}
interface Props { interface Props {
from: number; from: number;

View File

@@ -2,9 +2,20 @@ import { useEffect, useMemo, useState } from 'react';
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared'; import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
import { api } from '../api'; import { api } from '../api';
import { formatDuration } from '../format'; import { formatDuration } from '../format';
import { TIMELINE_RANGES } from '../streamers'; import {
TIMELINE_RANGES,
TIMELINE_SORTS,
buildTimelineLanes,
recordedMs,
toggleId,
usedTags,
type TimelineLane,
type TimelineSort,
} from '../streamers';
import { SearchField } from './SearchField';
import { StreamerDetail } from './StreamerDetail'; import { StreamerDetail } from './StreamerDetail';
import { Timeline, type TimelineLane } from './Timeline'; import { Timeline } from './Timeline';
import { TagFilter } from './Tags';
interface Props { interface Props {
targets: WatchTarget[]; targets: WatchTarget[];
@@ -27,6 +38,9 @@ export function TimelinePage({ targets, agents, notify }: Props) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [onlyRecorded, setOnlyRecorded] = useState(false); const [onlyRecorded, setOnlyRecorded] = useState(false);
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [sort, setSort] = useState<TimelineSort>('activity');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -51,49 +65,35 @@ export function TimelinePage({ targets, agents, notify }: Props) {
const now = Date.now(); const now = Date.now();
const from = now - days * 86_400_000; const from = now - days * 86_400_000;
const lanes = useMemo<TimelineLane[]>(() => { // Tirées des profils et non du serveur : ils arrivent entiers par le flux
const byTarget = new Map<string, TimelineLane>(); // temps réel, donc toujours à jour.
for (const target of targets) { const tags = useMemo(() => usedTags(targets), [targets]);
byTarget.set(target.id, {
id: target.id,
label: target.label ?? target.username,
avatarUrl: target.avatarUrl,
sessions: [],
spans: [],
pauses: [],
});
}
for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session);
for (const span of data.spans) {
if (span.targetId) byTarget.get(span.targetId)?.spans.push(span);
}
for (const pause of data.pauses) {
if (pause.targetId) byTarget.get(pause.targetId)?.pauses.push(pause);
}
// Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne const lanes = useMemo<TimelineLane[]>(
// vide : la frise sert à comparer des activités, pas à recenser les suivis. () =>
return [...byTarget.values()] buildTimelineLanes(targets, data, { query, tags: selectedTags, onlyRecorded, sort }),
.filter((lane) => lane.sessions.length > 0 || lane.spans.length > 0) [data, targets, onlyRecorded, query, selectedTags, sort],
.filter((lane) => !onlyRecorded || lane.spans.length > 0)
.sort((a, b) => lastActivity(b) - lastActivity(a));
}, [data, targets, onlyRecorded]);
// Pauses déduites : la capture tournait, mais n'écrivait rien pendant ce
// temps-là. L'annoncer comme enregistré surestimerait ce qu'on a réellement.
const elapsed = (interval: { startedAt: number; endedAt: number | null }) =>
(interval.endedAt ?? now) - interval.startedAt;
const totalRecorded = Math.max(
0,
data.spans.reduce((sum, span) => sum + elapsed(span), 0) -
data.pauses.reduce((sum, pause) => sum + elapsed(pause), 0),
); );
// Comptés sur les voies affichées et non sur les données brutes : le résumé
// est posé sous les filtres, il doit décrire la frise qu'on a sous les yeux.
// Annoncer un total qui ne correspond à rien de visible ferait douter du
// filtre autant que du chiffre.
const shownSessions = lanes.reduce((sum, lane) => sum + lane.sessions.length, 0);
const totalRecorded = lanes.reduce((sum, lane) => sum + recordedMs(lane), 0);
const filtered = query.trim() !== '' || selectedTags.size > 0 || onlyRecorded;
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null; const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
return ( return (
<div className="page"> <div className="page">
<div className="row filter-row"> <div className="row filter-row">
<h2>Frise des diffusions</h2> <h2>Frise des diffusions</h2>
<SearchField
value={query}
onChange={setQuery}
placeholder="Chercher un streamer…"
label="Chercher un streamer par nom, pseudo ou étiquette"
/>
<div className="spacer" /> <div className="spacer" />
<button <button
className={onlyRecorded ? 'chip active' : 'chip'} className={onlyRecorded ? 'chip active' : 'chip'}
@@ -113,16 +113,50 @@ export function TimelinePage({ targets, agents, notify }: Props) {
))} ))}
</div> </div>
<div className="row filter-row">
<label className="sort-field muted small">
Trier par
<select
className="sort-select"
value={sort}
onChange={(event) => setSort(event.target.value as TimelineSort)}
>
{TIMELINE_SORTS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
</label>
<TagFilter
tags={tags}
selected={selectedTags}
onToggle={(id) => setSelectedTags((current) => toggleId(current, id))}
onClear={() => setSelectedTags(new Set())}
/>
</div>
<p className="muted small timeline-legend"> <p className="muted small timeline-legend">
<span className="legend-swatch live" /> diffusion publique <span className="legend-swatch live" /> diffusion publique
<span className="legend-swatch rec" /> enregistré <span className="legend-swatch rec" /> enregistré
<span className="legend-swatch paused" /> en pause <span className="legend-swatch paused" /> en pause
<span className="spacer" /> <span className="spacer" />
{data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total {shownSessions} diffusion(s) · {formatDuration(totalRecorded)} enregistré
{filtered ? ' sur la sélection' : ' au total'}
</p> </p>
{loading ? ( {loading ? (
<p className="muted small">Chargement</p> <p className="muted small">Chargement</p>
) : lanes.length === 0 ? (
// Une frise sans voie n'affiche qu'une règle graduée : rien n'y
// distingue « personne n'a diffusé » de « le filtre a tout écarté ».
<p className="muted small">
{query.trim()
? `Aucun streamer ne correspond à « ${query.trim()} » sur cette période.`
: filtered
? 'Aucune diffusion pour ce filtre sur cette période.'
: 'Aucune diffusion observée sur cette période.'}
</p>
) : ( ) : (
<div className="timeline-scroll"> <div className="timeline-scroll">
<Timeline from={from} to={now} lanes={lanes} onSelectLane={setOpenId} /> <Timeline from={from} to={now} lanes={lanes} onSelectLane={setOpenId} />
@@ -142,10 +176,3 @@ export function TimelinePage({ targets, agents, notify }: Props) {
); );
} }
/** Dernier signe d'activité, diffusion ou capture confondues. */
function lastActivity(lane: TimelineLane): number {
let latest = 0;
for (const session of lane.sessions) latest = Math.max(latest, session.endedAt ?? Date.now());
for (const span of lane.spans) latest = Math.max(latest, span.endedAt ?? Date.now());
return latest;
}

View File

@@ -1,6 +1,9 @@
import type { import type {
AgentView, AgentView,
PreemptionVerdict, PreemptionVerdict,
RecordingPause,
RecordingSpan,
StreamSession,
StreamState, StreamState,
WatchTarget, WatchTarget,
} from '@stream-control/shared'; } from '@stream-control/shared';
@@ -157,6 +160,64 @@ export function tagStyle(name: string): { background: string; color: string; bor
}; };
} }
/**
* Forme comparable d'un texte : sans casse, sans accent, sans espaces autour.
*
* Taper « lea » doit trouver « Léa ». On saisit le pseudo qu'on a en tête, pas
* celui qu'on a sous les yeux, et un accent oublié n'est pas une faute qu'il
* faut sanctionner par une liste vide.
*/
export function normalizeSearch(text: string): string {
return text
.normalize('NFD')
// Les diacritiques isolés par NFD, qu'on retire pour comparer « e » à « é ».
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
/**
* Le profil répond-il à la recherche ?
*
* Cherche à la fois dans le nom affiché, le pseudo et les étiquettes : ce sont
* les trois façons dont on désigne un profil, et exiger de savoir laquelle
* s'applique reviendrait à demander de chercher ce qu'on a déjà trouvé.
*
* Chaque mot saisi doit correspondre, mais n'importe où et dans n'importe quel
* ordre — « blonde lea » retrouve « Léa » étiquetée « blonde ».
*/
export function matchesSearch(
target: Pick<WatchTarget, 'label' | 'username' | 'tags'>,
query: string,
): boolean {
const needle = normalizeSearch(query);
if (!needle) return true;
const haystack = normalizeSearch(
[target.label ?? '', target.username, ...target.tags.map((tag) => tag.name)].join(' '),
);
return needle.split(/\s+/).every((term) => haystack.includes(term));
}
/** Ajoute ou retire un identifiant d'une sélection, sans muter l'original. */
export function toggleId(current: ReadonlySet<string>, id: string): Set<string> {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}
/** Le profil porte-t-il au moins une des étiquettes retenues ? */
export function matchesTags(
target: Pick<WatchTarget, 'tags'>,
selected: ReadonlySet<string>,
): boolean {
// Aucune étiquette cochée : le filtre ne s'applique pas. Entre étiquettes
// c'est une union — en cocher une seconde élargit la sélection, ce qui est le
// geste attendu quand on empile des critères de même nature.
if (selected.size === 0) return true;
return target.tags.some((tag) => selected.has(tag.id));
}
/** Toutes les étiquettes posées, sans doublon, dans l'ordre alphabétique. */ /** Toutes les étiquettes posées, sans doublon, dans l'ordre alphabétique. */
export function usedTags(targets: WatchTarget[]): Array<{ id: string; name: string }> { export function usedTags(targets: WatchTarget[]): Array<{ id: string; name: string }> {
const byId = new Map<string, { id: string; name: string }>(); const byId = new Map<string, { id: string; name: string }>();
@@ -202,3 +263,158 @@ export const TIMELINE_RANGES = [
{ days: 7, label: '7 jours' }, { days: 7, label: '7 jours' },
{ days: 30, label: '30 jours' }, { days: 30, label: '30 jours' },
] as const; ] as const;
/**
* Ordres de tri des voies de la frise.
*
* L'activité récente reste la valeur par défaut — c'est ce qu'on vient voir
* neuf fois sur dix. Les deux tris par durée répondent à une autre question :
* qui occupe réellement les machines, et sur quels profils la capture décroche
* face au temps de diffusion.
*/
export const TIMELINE_SORTS = [
{ id: 'activity', label: 'Activité récente' },
{ id: 'recorded', label: 'Temps enregistré' },
{ id: 'live', label: 'Temps de diffusion' },
{ id: 'name', label: 'Nom (A→Z)' },
] as const;
export type TimelineSort = (typeof TIMELINE_SORTS)[number]['id'];
/** Intervalle daté, encore ouvert quand `endedAt` vaut `null`. */
interface Interval {
startedAt: number;
endedAt: number | null;
}
/**
* Ce qu'il faut d'une voie de frise pour la classer.
*
* Décrit par sa forme et non par la voie complète : le tri ne regarde que des
* durées et un nom, il n'a aucune raison de dépendre de ce qui dessine les
* barres.
*/
export interface SortableLane {
label: string;
sessions: Interval[];
spans: Interval[];
pauses: Interval[];
}
/** Une ligne de la frise : un profil et tout ce qui le concerne sur la période. */
export interface TimelineLane extends SortableLane {
id: string;
avatarUrl?: string | null;
sessions: StreamSession[];
spans: RecordingSpan[];
pauses: RecordingPause[];
}
/** Critères appliqués aux voies de la frise. */
export interface TimelineFilters {
query: string;
tags: ReadonlySet<string>;
onlyRecorded: boolean;
sort: TimelineSort;
}
/**
* Répartit les diffusions, captures et pauses en voies, filtrées et triées.
*
* Le filtre s'applique aux profils, avant la répartition : les diffusions d'un
* profil écarté n'ont alors aucune voie où se ranger et disparaissent avec lui,
* sans traitement particulier. Une capture sans profil identifié (lancée à la
* main depuis l'onglet Agents) n'a de même nulle part où aller — la frise
* n'affiche que ce qu'elle sait rattacher à quelqu'un.
*/
export function buildTimelineLanes(
targets: WatchTarget[],
data: { sessions: StreamSession[]; spans: RecordingSpan[]; pauses: RecordingPause[] },
filters: TimelineFilters,
now = Date.now(),
): TimelineLane[] {
const byTarget = new Map<string, TimelineLane>();
for (const target of targets) {
if (!matchesSearch(target, filters.query) || !matchesTags(target, filters.tags)) continue;
byTarget.set(target.id, {
id: target.id,
label: target.label ?? target.username,
avatarUrl: target.avatarUrl,
sessions: [],
spans: [],
pauses: [],
});
}
for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session);
for (const span of data.spans) {
if (span.targetId) byTarget.get(span.targetId)?.spans.push(span);
}
for (const pause of data.pauses) {
if (pause.targetId) byTarget.get(pause.targetId)?.pauses.push(pause);
}
// Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne
// vide : la frise sert à comparer des activités, pas à recenser les suivis.
return [...byTarget.values()]
.filter((lane) => lane.sessions.length > 0 || lane.spans.length > 0)
.filter((lane) => !filters.onlyRecorded || lane.spans.length > 0)
.sort(laneComparator(filters.sort, now));
}
/** Durée cumulée d'intervalles, ceux encore ouverts comptés jusqu'à maintenant. */
export function totalMs(intervals: Interval[], now = Date.now()): number {
return intervals.reduce(
(sum, interval) => sum + ((interval.endedAt ?? now) - interval.startedAt),
0,
);
}
/**
* Temps réellement capturé sur une voie : les pauses retranchées.
*
* La capture tournait pendant une pause, mais n'écrivait rien. La compter
* surestimerait ce qu'on a effectivement gardé — et fausserait le classement
* par temps enregistré, où un profil souvent en show privé passerait devant un
* profil réellement capturé.
*/
export function recordedMs(lane: SortableLane, now = Date.now()): number {
return Math.max(0, totalMs(lane.spans, now) - totalMs(lane.pauses, now));
}
/** Dernier signe d'activité, diffusion ou capture confondues. */
export function lastActivity(lane: SortableLane, now = Date.now()): number {
let latest = 0;
for (const session of lane.sessions) latest = Math.max(latest, session.endedAt ?? now);
for (const span of lane.spans) latest = Math.max(latest, span.endedAt ?? now);
return latest;
}
/**
* Ordre des voies pour le tri demandé.
*
* Les tris par durée retombent sur l'activité récente à égalité : deux profils
* à zéro seconde enregistrée sont fréquents, et les laisser dans un ordre
* arbitraire donnerait une liste qui se réorganise sans raison visible d'un
* rafraîchissement à l'autre.
*
* `now` est figé une fois pour toutes à la construction du comparateur : lu à
* chaque comparaison, il ferait bouger les durées des voies encore ouvertes en
* plein tri, et l'ordre obtenu pourrait alors se contredire lui-même.
*/
export function laneComparator(
sort: TimelineSort,
now = Date.now(),
): (a: SortableLane, b: SortableLane) => number {
const recent = (a: SortableLane, b: SortableLane) => lastActivity(b, now) - lastActivity(a, now);
switch (sort) {
case 'recorded':
return (a, b) => recordedMs(b, now) - recordedMs(a, now) || recent(a, b);
case 'live':
return (a, b) => totalMs(b.sessions, now) - totalMs(a.sessions, now) || recent(a, b);
case 'name':
return (a, b) => a.label.localeCompare(b.label);
default:
return recent;
}
}

View File

@@ -642,6 +642,76 @@ fieldset.group > legend {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* --- Recherche et tri ---
*
* Le champ est habillé par son conteneur et non par l'`input` lui-même : la
* loupe et la croix vivent dans la même bordure que le texte, et le liseré de
* mise au point entoure l'ensemble plutôt que le seul champ, qui paraîtrait
* alors flotter dans sa propre boîte.
*/
.search-field {
display: flex;
align-items: center;
gap: 4px;
flex: 1 1 180px;
max-width: 260px;
padding: 0 8px;
border-radius: 8px;
border: 1px solid var(--border);
background: #0f141a;
}
.search-field:focus-within {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.search-field input {
min-width: 0;
padding: 6px 0;
border: none;
border-radius: 0;
background: transparent;
}
.search-field input:focus {
/* Déjà porté par le conteneur : deux liserés imbriqués feraient du bruit. */
outline: none;
}
/* La croix native de WebKit doublonnerait avec la nôtre, sans en partager la
couleur ni la position. */
.search-field input[type='search']::-webkit-search-cancel-button {
display: none;
}
.search-icon {
color: var(--muted);
font-size: 15px;
line-height: 1;
}
.search-clear {
padding: 2px 4px;
border: none;
background: transparent;
color: var(--muted);
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.search-clear:hover {
color: var(--text);
}
.sort-field {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.sort-select {
/* Les champs sont pleine largeur par défaut ; dans une rangée de filtres, un
sélecteur doit se contenter de ce que son libellé demande. */
width: auto;
padding: 4px 8px;
font-size: 12px;
}
/* --- Étiquettes --- /* --- Étiquettes ---
* *
* Les couleurs sont posées en style inline, dérivées du nom (voir `tagStyle`) : * Les couleurs sont posées en style inline, dérivées du nom (voir `tagStyle`) :
@@ -1291,6 +1361,13 @@ fieldset.group > legend {
flex: 1 1 100px; flex: 1 1 100px;
} }
/* Le champ de recherche prend sa propre ligne : coincé entre les puces de
filtre, il se réduirait à quelques caractères visibles. */
.search-field {
flex: 1 1 100%;
max-width: none;
}
/* Trois colonnes fixes (heure, agent, message) laissent trop peu de place /* Trois colonnes fixes (heure, agent, message) laissent trop peu de place
au message une fois l'écran étroit : chaque entrée devient une pile. */ au message une fois l'écran étroit : chaque entrée devient une pile. */
.log-line, .log-line,