Веб-UI: обзор жизненного цикла в карточке загрузки

Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «ID:» перед идентификатором, дата добавления
(абсолютная + относительная, всегда), размер раздачи и рейтинг отдачи.
Спойлер контекста убран — контекст смотрят на /download/{id}.

Данные:
- рейтинг и общий размер — из живого снимка воркера (qbt total_size →
  worker.Live.TotalSize); размер доступен для любой раздачи в снимке;
- размер-фолбэк, когда торрента нет в qBittorrent (orphaned) — сумма
  размеров разложенных файлов: новая колонка file_link.size, layouter
  пишет размер при линковке, ридер LayoutSizeByDownload суммирует по
  странице одним запросом (дедуп по dst_path);
- дата — source_added_at → фолбэк created_at, показ в TZ сервера.

handleIndex читает снимок для всех карточек (map-lookup), рейтинг/размер
статичны на рендере (без поллинга). Миграция 0007, ER-схема обновлена.
Change download-card-lifecycle-overview влит в спеки и заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 10:34:45 +03:00
co-authored by Claude Opus 4.8
parent 9ed732e49e
commit bb245a90a3
27 changed files with 845 additions and 49 deletions
+82
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
@@ -184,3 +185,84 @@ func fmtRatio(r float64) string {
}
return fmt.Sprintf("%.2f", r)
}
// mskLoc — TZ сервера (Europe/Moscow) для показа дат в веб-UI (инвариант «время
// с явным TZ»). Если zoneinfo недоступен в окружении — деградируем до UTC:
// дата остаётся корректной с точностью до смещения, UI не падает.
var mskLoc = func() *time.Location {
if loc, err := time.LoadLocation("Europe/Moscow"); err == nil {
return loc
}
return time.UTC
}()
// fmtDate — абсолютная дата добавления для карточки в TZ сервера.
func fmtDate(t time.Time) string {
return t.In(mskLoc).Format("2006-01-02")
}
// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
// (рассинхрон часов) схлопывается в «только что». Единицы огрубляются к
// минутам/часам/дням/месяцам/годам — для обзора возраста этого достаточно.
func humanizeAge(t, now time.Time) string {
d := now.Sub(t)
if d < time.Minute {
return "только что"
}
switch {
case d < time.Hour:
n := int(d / time.Minute)
return fmt.Sprintf("%d %s назад", n, plural(n, "минуту", "минуты", "минут"))
case d < 24*time.Hour:
n := int(d / time.Hour)
return fmt.Sprintf("%d %s назад", n, plural(n, "час", "часа", "часов"))
case d < 30*24*time.Hour:
n := int(d / (24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "день", "дня", "дней"))
case d < 365*24*time.Hour:
n := int(d / (30 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "месяц", "месяца", "месяцев"))
default:
n := int(d / (365 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "год", "года", "лет"))
}
}
// plural выбирает русскую форму по числу (1 файл / 2 файла / 5 файлов).
func plural(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if m := n % 100; m >= 11 && m <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// sizeText — размер раздачи для карточки: живой общий размер из снимка, иначе
// суммарный размер разложенных файлов (фолбэк для orphaned), иначе «—».
func sizeText(l worker.Live, ok bool, layoutSize int64) string {
if ok && l.TotalSize > 0 {
return fmtBytes(l.TotalSize)
}
if layoutSize > 0 {
return fmtBytes(layoutSize)
}
return "—"
}
// ratioText — рейтинг отдачи для карточки: из живого снимка, иначе «—» (торрента
// нет в qBittorrent).
func ratioText(l worker.Live, ok bool) string {
if !ok {
return "—"
}
return fmtRatio(l.Ratio)
}