corpus

Log | Files | Refs | README | LICENSE

commit 248edff954cb1d7e0f8d55b854b4426c9f24e560
parent d9bf40b8520765507c4c60d32717b22315c5f75a
Author: mtmn <miro@haravara.org>
Date:   Wed, 15 Apr 2026 20:35:56 +0200

feat: add filters, allow show all

Diffstat:
Mdevenv.lock | 18+++++++++---------
Msrc/Client.purs | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/Db.purs | 55+++++++++++++++++++++++++++++++++++++------------------
Msrc/Main.purs | 42++++++++++++++++++++++++++++++++++++------
Msrc/Types.purs | 10++++++++--
Mtest/Main.purs | 6+++++-
6 files changed, 213 insertions(+), 66 deletions(-)

diff --git a/devenv.lock b/devenv.lock @@ -3,11 +3,11 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1775997427, - "narHash": "sha256-JUXHTRFoHQanKoAKvHTM4Z0YdbCnmZUGqgMPw95KxUw=", + "lastModified": 1776080969, + "narHash": "sha256-2uZxF6q0KOIH8tq3r65ZCz7dM+XYP3avtRP8IBq5zYY=", "owner": "cachix", "repo": "devenv", - "rev": "88ac631cf8b6582ed372b8b22e3bd12240c61f64", + "rev": "8d558a84fa38242a7f13781670fee1a6a8902b48", "type": "github" }, "original": { @@ -22,11 +22,11 @@ "nixpkgs-src": "nixpkgs-src" }, "locked": { - "lastModified": 1774287239, - "narHash": "sha256-W3krsWcDwYuA3gPWsFA24YAXxOFUL6iIlT6IknAoNSE=", + "lastModified": 1776097194, + "narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=", "owner": "cachix", "repo": "devenv-nixpkgs", - "rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c", + "rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532", "type": "github" }, "original": { @@ -39,11 +39,11 @@ "nixpkgs-src": { "flake": false, "locked": { - "lastModified": 1773840656, - "narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=", + "lastModified": 1775888245, + "narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512", + "rev": "13043924aaa7375ce482ebe2494338e058282925", "type": "github" }, "original": { diff --git a/src/Client.purs b/src/Client.purs @@ -52,10 +52,12 @@ type State = , failedCovers :: Set String , hoveredCover :: Maybe Int , expandedSections :: Set String + , loadedSections :: Set String , offset :: Int , limit :: Int , activeTab :: Tab , activeFilter :: Maybe ActiveFilter + , statsPeriod :: Maybe Int } data Action @@ -67,11 +69,15 @@ data Action | NextPage | PrevPage | SwitchTab Tab + | SetStatsPeriod (Maybe Int) | FilterBy String String | ClearFilter | HoverCover Int | UnhoverCover - | ToggleSection String + | ExpandSection String + | ShowAllSection String + | CollapseSection String + | ReceiveSectionData String (Either String (Array StatsEntry)) component :: forall query input output m. MonadAff m => H.Component query input output m component = @@ -94,10 +100,12 @@ component = , failedCovers: Set.empty , hoveredCover: Nothing , expandedSections: Set.empty + , loadedSections: Set.empty , offset: 0 , limit: 25 , activeTab: ListensTab , activeFilter: Nothing + , statsPeriod: Nothing } render state = @@ -153,7 +161,10 @@ component = ] ] StatsTab -> - renderStatsView state.expandedSections state.stats + HH.div_ + [ renderPeriodSelector state.statsPeriod + , renderStatsView state.expandedSections state.loadedSections state.stats + ] , HH.div [ HP.id "footer" , HP.class_ (H.ClassName "small") @@ -162,46 +173,56 @@ component = ] ] - renderStatsView _ Nothing = + renderStatsView _ _ Nothing = HH.div [ HP.class_ (H.ClassName "loading") ] [ HH.text "Loading stats..." ] - renderStatsView expandedSections (Just (Stats { genres, labels, years })) = + renderStatsView expandedSections loadedSections (Just (Stats { genres, labels, years, artists, tracks })) = HH.div_ - [ renderStatSection expandedSections "genre" "genres" genres - , renderStatSection expandedSections "label" "labels" labels - , renderStatSection expandedSections "year" "years" years + [ renderStatSection expandedSections loadedSections (Just "artist") "top artists" artists + , renderStatSection expandedSections loadedSections Nothing "top tracks" tracks + , renderStatSection expandedSections loadedSections (Just "genre") "genres" genres + , renderStatSection expandedSections loadedSections (Just "label") "labels" labels + , renderStatSection expandedSections loadedSections (Just "year") "years" years ] - renderStatSection expandedSections field title entries = + renderStatSection expandedSections loadedSections mField title entries = let + sectionKey = fromMaybe title mField maxCount = fromMaybe 1 (maximum (map (\(StatsEntry e) -> e.count) entries)) - expanded = Set.member field expandedSections - visible = if expanded then entries else take 10 entries - hasMore = length entries > 10 + expanded = Set.member sectionKey expandedSections + loaded = Set.member sectionKey loadedSections + visible = if loaded then entries else if expanded then take 50 entries else take 10 entries + btn action label = + HH.button + [ HP.class_ (H.ClassName "show-all-btn"), HE.onClick \_ -> action ] + [ HH.text label ] + footer = + if loaded then + if length entries > 10 then btn (CollapseSection sectionKey) "show less" else HH.text "" + else if expanded then + -- we have up to 50; show "show all" only if there might be more + if length entries >= 50 then HH.span_ [ btn (ShowAllSection sectionKey) "show all", HH.text " ยท ", btn (CollapseSection sectionKey) "show less" ] + else btn (CollapseSection sectionKey) "show less" + else if length entries > 10 then btn (ExpandSection sectionKey) "show more" + else HH.text "" in HH.div [ HP.class_ (H.ClassName "stats-section") ] [ HH.h2_ [ HH.text title ] , if entries == [] then HH.div [ HP.class_ (H.ClassName "stats-empty") ] [ HH.text "no data yet โ€” enrichment in progress" ] else - HH.ul_ (map (renderStatEntry maxCount field) visible) - , if hasMore then - HH.button - [ HP.class_ (H.ClassName "show-all-btn") - , HE.onClick \_ -> ToggleSection field - ] - [ HH.text $ if expanded then "show less" else "show all (" <> show (length entries) <> ")" ] - else - HH.text "" + HH.ul_ (map (renderStatEntry maxCount mField) visible) + , footer ] - renderStatEntry maxCount field (StatsEntry { name, count }) = + renderStatEntry maxCount mField (StatsEntry { name, count }) = let barPct = floor (toNumber count * 100.0 / toNumber maxCount) + clickProps = case mField of + Just field -> [ HE.onClick \_ -> FilterBy field name ] + Nothing -> [] in HH.li - [ HP.class_ (H.ClassName "stat-row") - , HE.onClick \_ -> FilterBy field name - ] + ([ HP.class_ (H.ClassName $ "stat-row" <> if mField /= Nothing then " clickable" else "") ] <> clickProps) [ HH.div [ HP.class_ (H.ClassName "stat-bar") , HP.style $ "width: " <> show barPct <> "%" @@ -211,6 +232,21 @@ component = , HH.span [ HP.class_ (H.ClassName "stat-count") ] [ HH.text $ show count ] ] + renderPeriodSelector current = + HH.div + [ HP.class_ (H.ClassName "period-selector") ] + (map periodBtn [ Nothing, Just 7, Just 14, Just 30, Just 60, Just 90, Just 365 ]) + where + periodBtn target = + HH.button + [ HP.class_ (H.ClassName $ "period-btn" <> if current == target then " active" else "") + , HE.onClick \_ -> SetStatsPeriod target + ] + [ HH.text $ case target of + Nothing -> "all time" + Just n -> show n + ] + renderContent state | state.loading && state.listens == [] = HH.ul_ [ HH.li [ HP.class_ (H.ClassName "loading") ] [ HH.text "Loading recent tracks..." ] ] @@ -322,9 +358,13 @@ component = StatsTab -> do state <- H.get when (state.stats == Nothing) do - response <- H.liftAff fetchStats + response <- H.liftAff $ fetchStats state.statsPeriod handleAction (ReceiveStats response) ListensTab -> pure unit + SetStatsPeriod period -> do + H.modify_ _ { statsPeriod = period, stats = Nothing, expandedSections = Set.empty, loadedSections = Set.empty } + response <- H.liftAff $ fetchStats period + handleAction (ReceiveStats response) ImageError url -> do H.modify_ \state -> state { failedCovers = Set.insert url state.failedCovers } NextPage -> do @@ -339,8 +379,24 @@ component = H.modify_ _ { hoveredCover = Just url } UnhoverCover -> do H.modify_ _ { hoveredCover = Nothing } - ToggleSection field -> do - H.modify_ \s -> s { expandedSections = if Set.member field s.expandedSections then Set.delete field s.expandedSections else Set.insert field s.expandedSections } + ExpandSection section -> + H.modify_ \s -> s { expandedSections = Set.insert section s.expandedSections } + ShowAllSection section -> do + state <- H.get + response <- H.liftAff $ fetchSectionData state.statsPeriod section + handleAction (ReceiveSectionData section response) + CollapseSection section -> + H.modify_ \s -> s + { expandedSections = Set.delete section s.expandedSections + , loadedSections = Set.delete section s.loadedSections + } + ReceiveSectionData section result -> case result of + Left err -> H.modify_ _ { error = Just err } + Right entries -> H.modify_ \s -> s + { expandedSections = Set.insert section s.expandedSections + , loadedSections = Set.insert section s.loadedSections + , stats = map (updateStatSection section entries) s.stats + } updateUrl = do state <- H.get @@ -365,9 +421,41 @@ component = Left err -> pure $ Left $ "JSON decode error: " <> show err Right (ListenBrainzResponse { payload: Payload { listens } }) -> pure $ Right listens - fetchStats :: Aff (Either String Stats) - fetchStats = do - res <- AX.get ResponseFormat.json "/stats" + updateStatSection section entries (Stats s) = Stats $ case section of + "artist" -> s { artists = entries } + "track" -> s { tracks = entries } + "genre" -> s { genres = entries } + "label" -> s { labels = entries } + "year" -> s { years = entries } + _ -> s + + fetchSectionData :: Maybe Int -> String -> Aff (Either String (Array StatsEntry)) + fetchSectionData mDays section = do + let + periodParam = case mDays of + Nothing -> "" + Just n -> "&period=" <> show n + res <- AX.get ResponseFormat.json ("/stats?section=" <> section <> periodParam) + case res of + Left err -> pure $ Left $ "Network error: " <> AX.printError err + Right response -> + case decodeJson response.body of + Left err -> pure $ Left $ "JSON decode error: " <> show err + Right (Stats s) -> pure $ Right $ case section of + "artist" -> s.artists + "track" -> s.tracks + "genre" -> s.genres + "label" -> s.labels + "year" -> s.years + _ -> [] + + fetchStats :: Maybe Int -> Aff (Either String Stats) + fetchStats mDays = do + let + periodParam = case mDays of + Nothing -> "" + Just n -> "?period=" <> show n + res <- AX.get ResponseFormat.json ("/stats" <> periodParam) case res of Left err -> pure $ Left $ "Network error: " <> AX.printError err Right response -> diff --git a/src/Db.purs b/src/Db.purs @@ -19,6 +19,7 @@ import Data.Traversable (traverse) import Foreign.Object as Object import Data.Nullable (Nullable, toMaybe, toNullable) import Data.Array (mapMaybe, uncons, (!!)) +import Data.Int (fromString) import Data.String (lastIndexOf, Pattern(..), take) import Control.Monad.Rec.Class (forever) import Node.FS.Aff as FSA @@ -141,20 +142,26 @@ getScrobbles conn limit offset Nothing = do [ unsafeCoerce limit, unsafeCoerce offset ] pure $ fromMaybe [] $ traverse rowToListen rows getScrobbles conn limit offset (Just { field, value }) = do - let - col = case field of - "label" -> "rm.label" - "year" -> "rm.release_year::VARCHAR" - _ -> "rm.genre" - rows <- queryAll conn - ( "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre" - <> " FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid" - <> " WHERE " - <> col - <> " = ? ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" - ) - [ unsafeCoerce value, unsafeCoerce limit, unsafeCoerce offset ] + rows <- queryAll conn query [ unsafeCoerce value, unsafeCoerce limit, unsafeCoerce offset ] pure $ fromMaybe [] $ traverse rowToListen rows + where + query = case field of + "artist" -> + "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre" + <> " FROM scrobbles s LEFT JOIN release_metadata rm ON s.release_mbid = rm.release_mbid" + <> " WHERE s.artist_name = ? ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" + _ -> + let + col = case field of + "label" -> "rm.label" + "year" -> "rm.release_year::VARCHAR" + _ -> "rm.genre" + in + "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre" + <> " FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid" + <> " WHERE " + <> col + <> " = ? ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" initReleaseMetadata :: Connection -> Aff Unit initReleaseMetadata conn = do @@ -207,15 +214,27 @@ touchGenreCheckedAt conn mbid = do "UPDATE release_metadata SET genre_checked_at = CAST(epoch(now()) AS INTEGER) WHERE release_mbid = ?" [ unsafeCoerce mbid ] -getStats :: Connection -> Aff Stats -getStats conn = do - genreRows <- queryAll conn "SELECT rm.genre as name, COUNT(*) as count FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid WHERE rm.genre IS NOT NULL AND rm.genre != '' GROUP BY rm.genre ORDER BY count DESC LIMIT 50" [] - labelRows <- queryAll conn "SELECT rm.label as name, COUNT(*) as count FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid WHERE rm.label IS NOT NULL AND rm.label != '' GROUP BY rm.label ORDER BY count DESC LIMIT 50" [] - yearRows <- queryAll conn "SELECT CAST(rm.release_year AS VARCHAR) as name, COUNT(*) as count FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid WHERE rm.release_year IS NOT NULL GROUP BY rm.release_year ORDER BY rm.release_year DESC" [] +getStats :: Connection -> Maybe String -> Maybe String -> Aff Stats +getStats conn mPeriod mSection = do + let + timeFilter = case mPeriod >>= fromString of + Just days -> " AND s.listened_at >= CAST(epoch(now()) AS INTEGER) - " <> show (days * 86400) + Nothing -> "" + fetch name q = case mSection of + Nothing -> queryAll conn (q <> " LIMIT 50") [] + Just s | s == name -> queryAll conn q [] + Just _ -> pure [] + genreRows <- fetch "genre" ("SELECT rm.genre as name, COUNT(*) as count FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid WHERE rm.genre IS NOT NULL AND rm.genre != ''" <> timeFilter <> " GROUP BY rm.genre ORDER BY count DESC") + labelRows <- fetch "label" ("SELECT rm.label as name, COUNT(*) as count FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid WHERE rm.label IS NOT NULL AND rm.label != ''" <> timeFilter <> " GROUP BY rm.label ORDER BY count DESC") + yearRows <- fetch "year" ("SELECT CAST(rm.release_year AS VARCHAR) as name, COUNT(*) as count FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid WHERE rm.release_year IS NOT NULL" <> timeFilter <> " GROUP BY rm.release_year ORDER BY rm.release_year DESC") + artistRows <- fetch "artist" ("SELECT s.artist_name as name, COUNT(*) as count FROM scrobbles s WHERE s.artist_name != ''" <> timeFilter <> " GROUP BY s.artist_name ORDER BY count DESC") + trackRows <- fetch "track" ("SELECT s.artist_name || ' โ€” ' || s.track_name as name, COUNT(*) as count FROM scrobbles s WHERE s.track_name != '' AND s.artist_name != ''" <> timeFilter <> " GROUP BY s.artist_name, s.track_name ORDER BY count DESC") pure $ Stats { genres: mapMaybe rowToEntry genreRows , labels: mapMaybe rowToEntry labelRows , years: mapMaybe rowToEntry yearRows + , artists: mapMaybe rowToEntry artistRows + , tracks: mapMaybe rowToEntry trackRows } rowToEntry :: Json -> Maybe StatsEntry diff --git a/src/Main.purs b/src/Main.purs @@ -664,11 +664,11 @@ indexHtml = opacity: 1; } - .stat-row { + .stat-row.clickable { cursor: pointer; } - .stat-row:hover .stat-name { + .stat-row.clickable:hover .stat-name { color: #a0c0d0; } @@ -723,6 +723,34 @@ indexHtml = .show-all-btn:hover { color: #ffffff; } + + .period-selector { + display: flex; + gap: 6px; + margin-bottom: 16px; + } + + .period-btn { + background: none; + border: 1px solid #50447f; + color: #9fbfe7; + padding: 4px 12px; + border-radius: 3px; + cursor: pointer; + font-family: inherit; + font-size: 12px; + } + + .period-btn:hover { + color: #ffffff; + border-color: #ffffff; + } + + .period-btn.active { + background: #50447f; + color: #ffffff; + border-color: #50447f; + } </style> </head> <body> @@ -747,7 +775,7 @@ handleRequest db isSyncing coverCacheEnabled req res = do "/healthz" -> serveHealthz db isSyncing res "/proxy" -> serveProxy db isSyncing url res "/cover" -> serveCover coverCacheEnabled isSyncing url res - "/stats" -> serveStats db isSyncing res + "/stats" -> serveStats db isSyncing url res "/client.js" -> serveClientJs res "/favicon.png" -> serveAsset "image/png" "assets/favicon.png" res _ -> do @@ -1017,13 +1045,15 @@ serveNotFound res = do void $ writeString w UTF8 "Not Found" end w -serveStats :: Connection -> Ref Boolean -> Response -> Effect Unit -serveStats db isSyncing res = do +serveStats :: Connection -> Ref Boolean -> URL -> Response -> Effect Unit +serveStats db isSyncing url res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) launchAff_ do yieldToSync isSyncing - stats <- getStats db + let period = getQueryParam "period" url + let section = getQueryParam "section" url + stats <- getStats db period section let responseBody = stringify $ encodeJson stats liftEffect $ do setStatusCode 200 res diff --git a/src/Types.purs b/src/Types.purs @@ -153,6 +153,8 @@ newtype Stats = Stats { genres :: Array StatsEntry , labels :: Array StatsEntry , years :: Array StatsEntry + , artists :: Array StatsEntry + , tracks :: Array StatsEntry } derive instance eqStats :: Eq Stats @@ -166,11 +168,15 @@ instance DecodeJson Stats where genres <- obj .: "genres" labels <- obj .: "labels" years <- obj .: "years" - pure $ Stats { genres, labels, years } + artists <- obj .: "artists" + tracks <- obj .: "tracks" + pure $ Stats { genres, labels, years, artists, tracks } instance EncodeJson Stats where - encodeJson (Stats { genres, labels, years }) = + encodeJson (Stats { genres, labels, years, artists, tracks }) = "genres" := encodeJson genres ~> "labels" := encodeJson labels ~> "years" := encodeJson years + ~> "artists" := encodeJson artists + ~> "tracks" := encodeJson tracks ~> jsonEmptyObject diff --git a/test/Main.purs b/test/Main.purs @@ -79,6 +79,8 @@ main = do { genres: [StatsEntry { name: "Rock", count: 10 }] , labels: [StatsEntry { name: "Label", count: 5 }] , years: [StatsEntry { name: "2023", count: 15 }] + , artists: [StatsEntry { name: "Artist", count: 7 }] + , tracks: [StatsEntry { name: "Artist โ€” Song", count: 3 }] } decodeJson (encodeJson stats) `shouldEqual` Right stats @@ -145,10 +147,12 @@ main = do [Listen { trackMetadata: TrackMetadata m }] -> m.genre `shouldEqual` Just "Rock" _ -> fail "Expected 1 listen" - Stats s <- getStats conn + Stats s <- getStats conn Nothing Nothing length s.genres `shouldEqual` 1 length s.labels `shouldEqual` 1 length s.years `shouldEqual` 1 + length s.artists `shouldEqual` 1 + length s.tracks `shouldEqual` 1 -- Test Filtering (as mentioned in architecture.md) listensFiltered <- getScrobbles conn 10 0 (Just { field: "genre", value: "Rock" })