This commit is contained in:
ponzischeme89
2026-08-12 08:25:15 +12:00
parent e30962245e
commit 4b31946635
28 changed files with 439 additions and 130 deletions
+33 -2
View File
@@ -96,10 +96,40 @@ a { color: var(--accent-ink); }
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
}
.rail-group {
margin: 16px 12px 6px; color: var(--quiet);
font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
width: calc(100% - 18px); margin: 14px 9px 5px; padding: 5px 7px; border: 0;
background: transparent; color: var(--quiet); font: inherit; font-size: 10px;
font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
display: flex; align-items: center; justify-content: space-between; cursor: pointer;
}
.rail-group:hover { color: var(--text); }
.rail-group-arrow { font-size: 15px; line-height: 1; transition: transform .16s ease; }
.rail-group[aria-expanded="false"] .rail-group-arrow { transform: rotate(-90deg); }
.rail-nav { display: grid; gap: 2px; }
.rail-nav[hidden] { display: none; }
.journey-summary-grid, .journey-report-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; margin-top:18px; }
.journey-summary { border:1px solid var(--line); background:var(--surface-lift); border-radius:14px; padding:16px 18px; }
.journey-summary > b, .journey-summary-head b { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.07em; }
.journey-summary > strong { display:block; font-size:20px; margin-top:9px; }
.journey-summary p { color:var(--muted); margin:8px 0 0; font-size:13px; }
.journey-summary-head { display:flex; align-items:center; justify-content:space-between; }
.journey-summary-head strong { font-size:22px; }
.journey-meter { height:8px; margin-top:13px; overflow:hidden; border-radius:99px; background:var(--line); }
.journey-meter span { display:block; height:100%; border-radius:inherit; background:var(--accent); }
.table-sub { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
.route-arrow { color:var(--accent); padding:0 5px; }
.journey-visits { display:grid; gap:14px; }
.journey-visit { border:1px solid var(--line); border-radius:14px; background:var(--surface-lift); overflow:hidden; }
.journey-visit header { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid var(--line); }
.journey-visit header b, .journey-visit header span { display:block; }
.journey-visit header span { color:var(--muted); font-size:12px; margin-top:3px; }
.journey-visit ol { list-style:none; margin:0; padding:10px 16px 14px; }
.journey-visit li { display:grid; grid-template-columns:12px minmax(0,1fr) auto; gap:10px; align-items:start; padding:8px 0; }
.journey-step-dot { width:8px; height:8px; margin-top:5px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px var(--accent-wash); }
.journey-visit li b, .journey-visit li span { display:block; }
.journey-visit li span { color:var(--muted); font-size:12px; margin-top:2px; }
.journey-visit time { color:var(--muted); font-size:11px; }
@media (max-width:900px) { .journey-summary-grid, .journey-report-grid { grid-template-columns:1fr; } }
.rail-link {
position: relative;
display: flex; align-items: center; gap: 11px; min-height: 36px;
@@ -490,6 +520,7 @@ details[open] summary { margin-bottom: 8px; }
.rail { padding: 16px 7px 10px; }
.rail-brand { justify-content: center; padding: 0 0 14px; }
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; }
.rail-nav[hidden] { display: grid !important; }
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
.rail-foot { justify-items: center; padding: 12px 0 0; }
.rail-logout { width: auto; padding: 5px; }
+23 -1
View File
@@ -281,7 +281,29 @@ const Admin = (() => {
if (!document.hidden) refresh();
});
ready(() => { decorate(); refresh(); schedule(); });
/* ---- collapsible navigation ----------------------------------------- */
function prepareNavigation() {
document.querySelectorAll('[data-nav-group]').forEach((button) => {
const id = button.dataset.navGroup;
const panel = document.querySelector('[data-nav-panel="' + id + '"]');
if (!panel) return;
const active = panel.querySelector('[aria-current="page"]');
let expanded = active || localStorage.getItem('memby-admin-nav-' + id) !== 'closed';
const apply = () => {
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
panel.hidden = !expanded;
};
apply();
button.addEventListener('click', () => {
expanded = !expanded;
localStorage.setItem('memby-admin-nav-' + id, expanded ? 'open' : 'closed');
apply();
});
});
}
ready(() => { prepareNavigation(); decorate(); refresh(); schedule(); });
return {
page, api, fmt, ui, $, error, settled, fill, check,
+31 -31
View File
@@ -16,8 +16,36 @@
</div>
</div>
<div class="tiles" id="journeys-tiles"></div>
<div class="journey-summary-grid">
<div class="journey-summary" id="journeys-completion"></div>
<div class="journey-summary" id="journeys-insight"></div>
</div>
</section>
<div class="journey-report-grid">
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="chart" data-icon-tone="info">What people do</h2>
<p class="card-note">Actions show total use and how many separate visits included them.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Action</th><th class="num">Uses</th><th class="num">Visits</th></tr></thead>
<tbody id="journeys-actions"></tbody>
</table></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Where people go</h2>
<p class="card-note">The most common steps between screens, including where quiet visits ended.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Route</th><th class="num">Times</th></tr></thead>
<tbody id="journeys-paths"></tbody>
</table></div>
</section>
</div>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
@@ -29,38 +57,10 @@
</table></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="chart" data-icon-tone="info">Significant actions</h2>
<p class="card-note">Actions are counted by event and by distinct journey, so repeated use
inside one visit remains visible without looking like more visits.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Category</th><th>Action</th><th class="num">Events</th><th class="num">Journeys</th></tr></thead>
<tbody id="journeys-actions"></tbody>
</table></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
<p class="card-note">Repeated transitions reveal routes into playback and places viewers
commonly leave a flow. A quiet unfinished journey becomes abandoned after 30 minutes.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
<tbody id="journeys-paths"></tbody>
</table></div>
</section>
<section class="card" id="journeys-events-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journeys</h2>
<p class="card-note">Choose one user to inspect visits. Newest visits appear first;
actions within each visit run from start to finish.</p>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Individual visits</h2>
<p class="card-note">Each card is one visit, read from its first step to its last.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
<tbody id="journeys-events"></tbody>
</table></div>
<div class="journey-visits" id="journeys-events"></div>
</section>
+40 -18
View File
@@ -19,22 +19,27 @@ function renderEvents(events) {
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
grouped.get(event.journeyId).push(event);
});
let journeyNumber = grouped.size;
const rows = [];
const visits = [];
grouped.forEach((steps) => {
steps.sort((a, b) => a.sequence - b.sequence);
const number = journeyNumber--;
steps.forEach((event) => {
const first = steps[0];
const completed = steps.some((event) => event.action === 'journey_end');
const actions = steps.filter((event) => !['journey_start', 'journey_end'].includes(event.action));
const timeline = actions.map((event) => {
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
: label(event.target || event.screen);
const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—';
rows.push('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
});
const content = event.itemName ? label(event.itemType) + ' · ' + event.itemName : '—';
return '<li><span class="journey-step-dot"></span><div><b>' + fmt.escape(label(event.action)) +
'</b><span>' + fmt.escape(path) + (content === '—' ? '' : ' · ' + fmt.escape(content)) +
'</span></div><time>' + fmt.when(event.occurredAt) + '</time></li>';
}).join('');
visits.push('<article class="journey-visit"><header><div><b>' + fmt.when(first.occurredAt) +
'</b><span>' + actions.length + ' significant step' + (actions.length === 1 ? '' : 's') +
'</span></div>' + ui.tag(completed ? 'completed' : 'unfinished', completed ? 'ok' : 'warn') +
'</header><ol>' + (timeline || '<li class="muted">No significant actions recorded.</li>') +
'</ol></article>');
});
$('journeys-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
$('journeys-events').innerHTML = visits.length ? visits.join('') : '<p class="empty">No visits in this window.</p>';
}
Admin.onRefresh(async () => {
@@ -58,9 +63,20 @@ Admin.onRefresh(async () => {
['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }],
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
]);
const completion = Math.round((stats.completionRate || 0) * 100);
$('journeys-completion').innerHTML = '<div class="journey-summary-head"><b>Visit completion</b><strong>' +
completion + '%</strong></div><div class="journey-meter"><span style="width:' + completion +
'%"></span></div><p>' + fmt.number(stats.completed || 0) + ' completed · ' +
fmt.number(stats.abandoned || 0) + ' abandoned · ' + fmt.number(stats.active || 0) + ' active</p>';
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
const features = [...new Set([...featureCatalogue, ...used.keys()])];
const cataloguePosition = new Map(featureCatalogue.map((name, index) => [name, index]));
const features = [...new Set([...featureCatalogue, ...used.keys()])].sort((left, right) => {
const useDifference = (used.get(right)?.uses || 0) - (used.get(left)?.uses || 0);
if (useDifference) return useDifference;
return (cataloguePosition.get(left) ?? Number.MAX_SAFE_INTEGER) -
(cataloguePosition.get(right) ?? Number.MAX_SAFE_INTEGER);
});
$('journeys-features').innerHTML = features.map((name) => {
const stat = used.get(name);
const uses = stat?.uses || 0;
@@ -71,15 +87,21 @@ Admin.onRefresh(async () => {
const actions = payload.actions || [];
$('journeys-actions').innerHTML = actions.length ? actions.map((action) =>
'<tr><td>' + fmt.escape(label(action.category)) + '</td><td>' + fmt.escape(label(action.action)) +
'</td><td class="num">' + fmt.number(action.events) + '</td><td class="num">' +
fmt.number(action.journeys) + '</td></tr>').join('') : ui.emptyRow(4, 'No significant actions in this window.');
'<tr><td><b>' + fmt.escape(label(action.action)) + '</b><span class="table-sub">' +
fmt.escape(label(action.category)) + '</span></td><td class="num">' + fmt.number(action.events) +
'</td><td class="num">' + fmt.number(action.journeys) + '</td></tr>').join('')
: ui.emptyRow(3, 'No significant actions in this window.');
const paths = payload.paths || [];
$('journeys-paths').innerHTML = paths.length ? paths.map((path) =>
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
: ui.emptyRow(3, 'No repeated paths in this window.');
'<tr><td>' + fmt.escape(label(path.from)) + ' <span class="route-arrow">→</span> ' +
fmt.escape(label(path.to)) + '</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
: ui.emptyRow(2, 'No repeated paths in this window.');
const topPath = paths[0];
$('journeys-insight').innerHTML = '<b>Most common route</b><strong>' +
(topPath ? fmt.escape(label(topPath.from)) + ' → ' + fmt.escape(label(topPath.to)) : 'Not enough data') +
'</strong><p>' + (topPath ? fmt.number(topPath.count) + ' times in this window' :
'Journeys will appear here as viewers move through Memby.') + '</p>';
$('journeys-events-card').hidden = !selected;
if (selected) renderEvents(payload.events || []);
+3 -2
View File
@@ -15,8 +15,9 @@
</a>
<div class="rail-scroll">
{{range .Nav}}
{{if .Label}}<p class="rail-group">{{.Label}}</p>{{end}}
<nav class="rail-nav">
{{if .Label}}<button class="rail-group" type="button" data-nav-group="{{.ID}}"
aria-expanded="true" aria-controls="rail-nav-{{.ID}}"><span>{{.Label}}</span><span class="rail-group-arrow" aria-hidden="true"></span></button>{{end}}
<nav class="rail-nav" id="rail-nav-{{.ID}}" data-nav-panel="{{.ID}}">
{{range .Items}}{{if not .Hidden}}
<a class="rail-link{{if eq .ID $.Page.ID}} active{{end}}" href="/admin/{{.ID}}"
{{if eq .ID $.Page.ID}}aria-current="page"{{end}} title="{{.Label}}">
+7
View File
@@ -35,6 +35,7 @@ type adminNavItem struct {
}
type adminNavGroup struct {
ID string
Label string
Items []adminNavItem
}
@@ -43,6 +44,7 @@ type adminNavGroup struct {
// Adding a page is an entry here plus admin/pages/<id>.html and admin/pages/<id>.js.
var adminNav = []adminNavGroup{
{
ID: "overview",
Items: []adminNavItem{
{
ID: "overview", Label: "Overview", Title: "Overview",
@@ -52,6 +54,7 @@ var adminNav = []adminNavGroup{
},
},
{
ID: "people",
Label: "People",
Items: []adminNavItem{
{
@@ -75,6 +78,7 @@ var adminNav = []adminNavGroup{
},
},
{
ID: "content",
Label: "Content",
Items: []adminNavItem{
{
@@ -95,6 +99,7 @@ var adminNav = []adminNavGroup{
},
},
{
ID: "personalisation",
Label: "Personalisation",
Items: []adminNavItem{
{
@@ -110,6 +115,7 @@ var adminNav = []adminNavGroup{
},
},
{
ID: "experience",
Label: "Experience",
Items: []adminNavItem{
{
@@ -140,6 +146,7 @@ var adminNav = []adminNavGroup{
},
},
{
ID: "operations",
Label: "Operations",
Items: []adminNavItem{
{
+9 -3
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -41,7 +43,7 @@ type journeyEventPayload struct {
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId"`
ItemName string `json:"itemName"`
ItemType string `json:"itemType"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
@@ -98,17 +100,21 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
!journeyOutcomes[payload.Outcome] {
return store.JourneyEvent{}, false
}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemType}
for _, field := range fields {
if !safeAnalyticsValue(field, 100) {
return store.JourneyEvent{}, false
}
}
if utf8.RuneCountInString(payload.ItemName) > 160 ||
strings.IndexFunc(payload.ItemName, unicode.IsControl) >= 0 {
return store.JourneyEvent{}, false
}
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType,
Target: payload.Target, ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
Outcome: payload.Outcome}, true
}
+10
View File
@@ -36,3 +36,13 @@ func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) {
t.Fatal("unknown action was accepted")
}
}
func TestJourneyEventRetainsTheItemName(t *testing.T) {
now := time.Now().UTC()
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
Action: "open", ItemName: "Hunt for the Wilderpeople", ItemType: "Movie"}
event, ok := toJourneyEvent(payload, "u1", now)
if !ok || event.ItemName != payload.ItemName {
t.Fatalf("item name was not retained: %+v", event)
}
}
+6
View File
@@ -403,6 +403,12 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
if row.ID == "continue" {
score = 1_000
}
// This recovery shelf is deliberately surfaced on Home. It may contain only one
// title, so its impression sample is naturally small and must not let the generic
// engagement sorter bury it below discovery rows.
if row.ID == "for-you:pick-up" {
score = 900
}
ranked = append(ranked, rankedRow{row: row, position: position, score: score})
}
sort.SliceStable(ranked, func(i, j int) bool {
+13
View File
@@ -158,6 +158,19 @@ func TestPersonalizeHomeRowsPreservesColdStartDefaults(t *testing.T) {
}
}
func TestPersonalizeHomeRowsKeepsAbandonedShowRecoveryNearTheTop(t *testing.T) {
rows := []recommend.Row{
{ID: "continue"}, {ID: "for-you:pick-up"}, {ID: "highly-engaged"}, {ID: "latest"},
}
stats := []store.RowStat{{
RowID: "highly-engaged", Impressions: 20, Focuses: 20, Selects: 20, DwellMs: 300_000,
}}
got := personalizeHomeRows(rows, stats)
if got[0].ID != "continue" || got[1].ID != "for-you:pick-up" {
t.Fatalf("recovery shelf was buried: %+v", got)
}
}
func TestPreparedHomeRowsPromotesAbandonedShowsAndTimeAwarePicks(t *testing.T) {
prepared := []recommend.Row{
{
+22
View File
@@ -23,6 +23,7 @@ type requestCandidate struct {
Overview string `json:"overview"`
PosterURL string `json:"posterUrl,omitempty"`
AlreadyAdded bool `json:"alreadyAdded"`
InLibrary bool `json:"inLibrary"`
}
type requestLookupResponse struct {
@@ -99,6 +100,27 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
}
wg.Wait()
candidates := append(movieCandidates, seriesCandidates...)
movieIDs, seriesIDs := []int{}, []int{}
for _, candidate := range candidates {
if candidate.MediaType == "movie" {
movieIDs = append(movieIDs, candidate.ForeignID)
} else {
seriesIDs = append(seriesIDs, candidate.ForeignID)
}
}
moviesInLibrary, movieErr := s.store.LibraryContainsProviderIDs(r.Context(), "Tmdb", movieIDs)
seriesInLibrary, seriesErr := s.store.LibraryContainsProviderIDs(r.Context(), "Tvdb", seriesIDs)
if movieErr != nil || seriesErr != nil {
s.loggerFor(r.Context()).Warn("request library status unavailable",
"movie_error", movieErr, "series_error", seriesErr)
}
for index := range candidates {
if candidates[index].MediaType == "movie" {
candidates[index].InLibrary = moviesInLibrary[candidates[index].ForeignID]
} else {
candidates[index].InLibrary = seriesInLibrary[candidates[index].ForeignID]
}
}
sort.SliceStable(candidates, func(i, j int) bool {
return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title)
})
+47 -3
View File
@@ -58,12 +58,16 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
return err
}
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
additions := make([]store.SonarrSeriesStatusChange, 0, len(changes))
for _, change := range changes {
if sonarrBecameCancelled(change.PreviousStatus, change.Current.Status) {
switch sonarrLifecycleNotificationKind(change.PreviousStatus, change.Current.Status) {
case "show-added":
additions = append(additions, change)
case "show-cancelled":
cancellations = append(cancellations, change)
}
}
if len(cancellations) == 0 {
if len(cancellations) == 0 && len(additions) == 0 {
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
return nil
}
@@ -75,6 +79,36 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
preferences := map[string]store.NotificationPreferences{}
preferenceErrors := map[string]bool{}
notifications := 0
for _, change := range additions {
for _, user := range users {
prefs, ok := preferences[user.ID]
if !ok && !preferenceErrors[user.ID] {
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
if err != nil {
preferenceErrors[user.ID] = true
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
"user", user.ID, "error", err)
continue
}
preferences[user.ID] = prefs
}
if preferenceErrors[user.ID] || !prefs.Enabled {
continue
}
eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
message := change.Current.Title + " was added to Sonarr."
if err := s.store.UpsertNotification(
ctx, user.ID, sourceKey, "show-added", "",
"Show added", message, &eventAt,
); err != nil {
s.log.Warn("Sonarr addition notification failed",
"user", user.ID, "show", change.Current.Title, "error", err)
continue
}
notifications++
}
}
for _, change := range cancellations {
for _, user := range users {
prefs, ok := preferences[user.ID]
@@ -107,10 +141,20 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
}
s.log.Info("Sonarr lifecycle scan complete",
"series", len(observations), "changes", len(changes),
"cancelled", len(cancellations), "notifications", notifications)
"added", len(additions), "cancelled", len(cancellations), "notifications", notifications)
return nil
}
func sonarrLifecycleNotificationKind(previous, current string) string {
if strings.TrimSpace(previous) == "" {
return "show-added"
}
if sonarrBecameCancelled(previous, current) {
return "show-cancelled"
}
return ""
}
func sonarrSeriesStatusKey(series sonarr.Series) string {
if series.TVDBID > 0 {
return "tvdb:" + strconv.Itoa(series.TVDBID)
+12
View File
@@ -38,3 +38,15 @@ func TestSonarrCancellationRequiresAnActiveHistory(t *testing.T) {
}
}
}
func TestSonarrLifecycleClassifiesNewSeriesAfterTheBaseline(t *testing.T) {
if got := sonarrLifecycleNotificationKind("", "continuing"); got != "show-added" {
t.Fatalf("new series notification = %q, want show-added", got)
}
if got := sonarrLifecycleNotificationKind("continuing", "ended"); got != "show-cancelled" {
t.Fatalf("cancelled series notification = %q, want show-cancelled", got)
}
if got := sonarrLifecycleNotificationKind("continuing", "continuing"); got != "" {
t.Fatalf("unchanged series notification = %q, want none", got)
}
}