feat: interest over time graph (#127)

* api

* ui

* test

* add margin to prevent clipping
This commit is contained in:
Gabe Farrell 2026-01-12 16:20:31 -05:00 committed by GitHub
parent e45099c71a
commit 231eb1b0fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1097 additions and 4 deletions

View file

@ -30,6 +30,7 @@ type DB interface {
GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*models.User, error)
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
GetUserByApiKey(ctx context.Context, key string) (*models.User, error)
GetInterest(ctx context.Context, opts GetInterestOpts) ([]InterestBucket, error)
// Save

View file

@ -153,3 +153,10 @@ type GetExportPageOpts struct {
TrackID int32
Limit int32
}
type GetInterestOpts struct {
Buckets int
AlbumID int32
ArtistID int32
TrackID int32
}

View file

@ -0,0 +1,70 @@
package psql
import (
"context"
"errors"
"fmt"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/repository"
)
func (d *Psql) GetInterest(ctx context.Context, opts db.GetInterestOpts) ([]db.InterestBucket, error) {
if opts.Buckets == 0 {
return nil, errors.New("GetInterest: bucket count must be provided")
}
ret := make([]db.InterestBucket, opts.Buckets)
if opts.ArtistID != 0 {
resp, err := d.q.GetGroupedListensFromArtist(ctx, repository.GetGroupedListensFromArtistParams{
ArtistID: opts.ArtistID,
BucketCount: opts.Buckets,
})
if err != nil {
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromArtist: %w", err)
}
for i, v := range resp {
ret[i] = db.InterestBucket{
BucketStart: v.BucketStart,
BucketEnd: v.BucketEnd,
ListenCount: v.ListenCount,
}
}
return ret, nil
} else if opts.AlbumID != 0 {
resp, err := d.q.GetGroupedListensFromRelease(ctx, repository.GetGroupedListensFromReleaseParams{
ReleaseID: opts.AlbumID,
BucketCount: opts.Buckets,
})
if err != nil {
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromRelease: %w", err)
}
for i, v := range resp {
ret[i] = db.InterestBucket{
BucketStart: v.BucketStart,
BucketEnd: v.BucketEnd,
ListenCount: v.ListenCount,
}
}
return ret, nil
} else if opts.TrackID != 0 {
resp, err := d.q.GetGroupedListensFromTrack(ctx, repository.GetGroupedListensFromTrackParams{
ID: opts.TrackID,
BucketCount: opts.Buckets,
})
if err != nil {
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromTrack: %w", err)
}
for i, v := range resp {
ret[i] = db.InterestBucket{
BucketStart: v.BucketStart,
BucketEnd: v.BucketEnd,
ListenCount: v.ListenCount,
}
}
return ret, nil
} else {
return nil, errors.New("GetInterest: artist id, album id, or track id must be provided")
}
}

View file

@ -0,0 +1,112 @@
package psql_test
import (
"context"
"testing"
"github.com/gabehf/koito/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// an llm wrote this because i didn't feel like it. it looks like it works, although
// it could stand to be more thorough
func TestGetInterest(t *testing.T) {
truncateTestData(t)
ctx := context.Background()
// --- Setup Data ---
// Insert Artists
err := store.Exec(ctx, `
INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000002')`)
require.NoError(t, err)
// Insert Releases (Albums)
err = store.Exec(ctx, `
INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000011')`)
require.NoError(t, err)
// Insert Tracks (Both on Release 1)
err = store.Exec(ctx, `
INSERT INTO tracks (musicbrainz_id, release_id)
VALUES ('11111111-1111-1111-1111-111111111111', 1),
('22222222-2222-2222-2222-222222222222', 1)`)
require.NoError(t, err)
// Link Artists to Tracks
// Artist 1 -> Track 1
// Artist 2 -> Track 2
err = store.Exec(ctx, `
INSERT INTO artist_tracks (artist_id, track_id)
VALUES (1, 1), (2, 2)`)
require.NoError(t, err)
// Insert Listens
// Track 1 (Artist 1, Release 1): 3 Listens
// Track 2 (Artist 2, Release 1): 2 Listens
err = store.Exec(ctx, `
INSERT INTO listens (user_id, track_id, listened_at) VALUES
(1, 1, NOW() - INTERVAL '1 hour'),
(1, 1, NOW() - INTERVAL '2 hours'),
(1, 1, NOW() - INTERVAL '3 hours'),
(1, 2, NOW() - INTERVAL '1 hour'),
(1, 2, NOW() - INTERVAL '2 hours')
`)
require.NoError(t, err)
// --- Test Validation ---
t.Run("Validation", func(t *testing.T) {
// Error: Missing Buckets
_, err := store.GetInterest(ctx, db.GetInterestOpts{ArtistID: 1})
assert.Error(t, err)
assert.Contains(t, err.Error(), "bucket count must be provided")
// Error: Missing ID
_, err = store.GetInterest(ctx, db.GetInterestOpts{Buckets: 10})
assert.Error(t, err)
assert.Contains(t, err.Error(), "must be provided")
})
// --- Test Data Retrieval ---
// Note: We use Buckets: 1 to ensure all listens are aggregated into a single result
// for easier assertion, avoiding complex date/time math in the test.
t.Run("Artist Interest", func(t *testing.T) {
// Artist 1 should have 3 listens (from Track 1)
buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
ArtistID: 1,
Buckets: 1,
})
require.NoError(t, err)
require.Len(t, buckets, 1)
assert.EqualValues(t, 3, buckets[0].ListenCount, "Artist 1 should have 3 listens")
})
t.Run("Album Interest", func(t *testing.T) {
// Album 1 contains Track 1 (3 listens) and Track 2 (2 listens) = 5 Total
buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
AlbumID: 1,
Buckets: 1,
})
require.NoError(t, err)
require.Len(t, buckets, 1)
assert.EqualValues(t, 5, buckets[0].ListenCount, "Album 1 should have 5 listens total")
})
t.Run("Track Interest", func(t *testing.T) {
// Track 2 should have 2 listens
buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
TrackID: 2,
Buckets: 1,
})
require.NoError(t, err)
require.Len(t, buckets, 1)
assert.EqualValues(t, 2, buckets[0].ListenCount, "Track 2 should have 2 listens")
})
}

View file

@ -44,3 +44,9 @@ type ExportItem struct {
ReleaseAliases []models.Alias
Artists []models.ArtistWithFullAliases
}
type InterestBucket struct {
BucketStart time.Time `json:"bucket_start"`
BucketEnd time.Time `json:"bucket_end"`
ListenCount int64 `json:"listen_count"`
}