commit df106a5888e1d45cf0206200ede3e2fc79b51579 from: mtmn date: Sat Aug 8 16:53:56 2026 UTC feat: add scrobbles count in stats commit - 2c3db42a0ce9dd86b70286ddf644e0c5efbc2190 commit + df106a5888e1d45cf0206200ede3e2fc79b51579 blob - 5072504ee5f318b9f9a6be0a99f57a93eec680f8 blob + 8aaf7b360f2248928f134413d26f3ca3e576f235 --- src/Api.elm +++ src/Api.elm @@ -276,7 +276,8 @@ trackMetaDecoder = statsDecoder : Decoder Stats statsDecoder = - D.map5 Stats + D.map6 Stats + (D.field "totalScrobbles" D.int) (D.field "genres" (D.list entryDecoder)) (D.field "labels" (D.list entryDecoder)) (D.field "years" (D.list entryDecoder)) blob - 6941a8ab8c6be926c7581418d19db45f7a7b535d blob + cb5b1a7a16523d40728f8adb9882786860f770fa --- src/Db.purs +++ src/Db.purs @@ -322,7 +322,7 @@ getStats conn mPeriod mFrom mTo mSection = do buildTimeFilterAndParams :: { timeFilter :: String, params :: Array Foreign } buildTimeFilterAndParams = case mFrom, mTo of Just from, Just to -> - { timeFilter: " AND s.listened_at >= CAST(epoch(TIMESTAMP ? || ' 00:00:00') AS INTEGER) AND s.listened_at < CAST(epoch(TIMESTAMP ? || ' 00:00:00') AS INTEGER) + 86400" + { timeFilter: " AND s.listened_at >= CAST(epoch(CAST(? || ' 00:00:00' AS TIMESTAMP)) AS INTEGER) AND s.listened_at < CAST(epoch(CAST(? || ' 00:00:00' AS TIMESTAMP)) AS INTEGER) + 86400" , params: [ toParam from, toParam to ] } _, _ -> case mPeriod >>= Int.fromString of @@ -342,8 +342,10 @@ getStats conn mPeriod mFrom mTo mSection = do 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") [] + totalRows <- queryAll conn ("SELECT COUNT(*) as count FROM scrobbles s WHERE 1 = 1" <> timeFilter) buildTimeParams pure $ Stats - { genres: mapMaybe rowToEntry genreRows + { totalScrobbles: fromMaybe 0 (totalRows !! 0 >>= rowToCount) + , genres: mapMaybe rowToEntry genreRows , labels: mapMaybe rowToEntry labelRows , years: mapMaybe rowToEntry yearRows , artists: mapMaybe rowToEntry artistRows @@ -357,6 +359,11 @@ rowToEntry json = do count <- map Int.round $ Object.lookup "count" obj >>= toNumber pure $ StatsEntry { name, count } +rowToCount :: Json -> Maybe Int +rowToCount json = do + obj <- toObject json + map Int.round $ Object.lookup "count" obj >>= toNumber + getArtistReleasesByMbids :: Connection -> Array String -> Aff (Object.Object { artist :: String, release :: String }) getArtistReleasesByMbids _ mbids | null mbids = pure Object.empty getArtistReleasesByMbids conn mbids = do blob - bbc499780f455ed62848cfa5ac6049529d2cf4fe blob + 20829b078fcf2deb6b108dbcdad2cdf4ff15a6fb --- src/Templates.purs +++ src/Templates.purs @@ -527,13 +527,26 @@ indexHtml registrationEnabled userSlug allUsers = padding: var(--space-sm) 0; } + .period-picker-bar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-md); + } + .period-selector { display: flex; flex-wrap: wrap; gap: var(--space-sm); - margin-bottom: var(--space-md); } + .stats-total { + color: var(--fg-alt); + white-space: nowrap; + } + .custom-range { display: flex; flex-wrap: wrap; blob - 8cc781c6abb7e2cb96b5ba0337fc7d90d8794a6d blob + 6cb0d0f6a790bf0542c3034ad6bba0be78ea3726 --- src/Types.elm +++ src/Types.elm @@ -100,7 +100,8 @@ type alias StatsEntry = type alias Stats = - { genres : List StatsEntry + { totalScrobbles : Int + , genres : List StatsEntry , labels : List StatsEntry , years : List StatsEntry , artists : List StatsEntry blob - f87ba08592217eecd2a5d8d62040628c3c5902f4 blob + 84734a4f36cdae6970a2e033b6c3641eaf5a4bf2 --- src/Types.purs +++ src/Types.purs @@ -364,7 +364,8 @@ instance EncodeJson StatsEntry where ~> jsonEmptyObject newtype Stats = Stats - { genres :: Array StatsEntry + { totalScrobbles :: Int + , genres :: Array StatsEntry , labels :: Array StatsEntry , years :: Array StatsEntry , artists :: Array StatsEntry @@ -379,16 +380,18 @@ instance showStats :: Show Stats where instance DecodeJson Stats where decodeJson json = do obj <- decodeJson json + totalScrobbles <- obj .: "totalScrobbles" genres <- obj .: "genres" labels <- obj .: "labels" years <- obj .: "years" artists <- obj .: "artists" tracks <- obj .: "tracks" - pure $ Stats { genres, labels, years, artists, tracks } + pure $ Stats { totalScrobbles, genres, labels, years, artists, tracks } instance EncodeJson Stats where - encodeJson (Stats { genres, labels, years, artists, tracks }) = - "genres" := encodeJson genres + encodeJson (Stats { totalScrobbles, genres, labels, years, artists, tracks }) = + "totalScrobbles" := encodeJson totalScrobbles + ~> "genres" := encodeJson genres ~> "labels" := encodeJson labels ~> "years" := encodeJson years ~> "artists" := encodeJson artists blob - 0a89b990ee5fa9668b7c6a916334afa6f9a6ef2e blob + 24216419b00bc5ba2f376214c7737ff21bca744b --- src/View.elm +++ src/View.elm @@ -132,7 +132,7 @@ renderMain model = StatsTab -> div [] - [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError + [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError model.stats , renderStatsView model.expandedSections model.loadedSections model.stats ] @@ -515,8 +515,8 @@ renderStatEntry maxCount mField entry = ] -renderPeriodSelector : Period -> Bool -> String -> Maybe String -> Html Msg -renderPeriodSelector current showInput customVal mError = +renderPeriodSelector : Period -> Bool -> String -> Maybe String -> Maybe Stats -> Html Msg +renderPeriodSelector current showInput customVal mError mStats = let isCustom = case current of @@ -596,10 +596,19 @@ renderPeriodSelector current showInput customVal mErro [ text label ] in div [] - [ div [ class "period-selector" ] - ([ namedBtn AllTime "all time", customBtn ] - ++ List.map daysBtn [ 7, 14, 30, 90, 180, 365 ] - ) + [ div [ class "period-picker-bar" ] + [ div [ class "period-selector" ] + ([ namedBtn AllTime "all time", customBtn ] + ++ List.map daysBtn [ 7, 14, 30, 90, 180, 365 ] + ) + , case mStats of + Just stats -> + div [ class "stats-total" ] + [ text (formatScrobbleCount stats.totalScrobbles ++ if stats.totalScrobbles == 1 then " scrobble" else " scrobbles") ] + + Nothing -> + text "" + ] , if showInput || isCustom then div [] [ div [ class "custom-range" ] @@ -690,7 +699,25 @@ renderAboutView currentSlug allUsers = ] +formatScrobbleCount : Int -> String +formatScrobbleCount count = + String.fromList <| + Tuple.second <| + String.foldr + (\digit ( index, formatted ) -> + ( index + 1 + , if index > 0 && modBy 3 index == 0 then + digit :: ',' :: formatted + else + digit :: formatted + ) + ) + ( 0, [] ) + (String.fromInt count) + + + -- REGISTRATION blob - 765629aed91ad38fd7c79af8b63861dc21c51e2c blob + 91527af7dfb631601a1f91925bf4cbd314125b64 --- test/Main.purs +++ test/Main.purs @@ -328,7 +328,8 @@ main = runSpecAndExitProcess [ consoleReporter ] do it "should roundtrip Stats" do let stats = Stats - { genres: [ StatsEntry { name: "Rock", count: 10 } ] + { totalScrobbles: 42 + , genres: [ StatsEntry { name: "Rock", count: 10 } ] , labels: [ StatsEntry { name: "Label", count: 5 } ] , years: [ StatsEntry { name: "2023", count: 15 } ] , artists: [ StatsEntry { name: "Artist", count: 7 } ] @@ -403,13 +404,29 @@ main = runSpecAndExitProcess [ consoleReporter ] do [ Listen { trackMetadata: TrackMetadata m } ] -> m.genre `shouldEqual` Just "Rock" _ -> fail "Expected 1 listen" + upsertScrobble conn $ Listen + { trackMetadata: TrackMetadata + { trackName: Just "Unenriched Song" + , artistName: Just "Artist" + , releaseName: Nothing + , mbidMapping: Nothing + , genre: Nothing + , label: Nothing + } + , listenedAt: Just 12346 + } + Stats s <- getStats conn Nothing Nothing Nothing Nothing + s.totalScrobbles `shouldEqual` 2 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 + length s.tracks `shouldEqual` 2 + Stats customRangeStats <- getStats conn Nothing (Just "1970-01-01") (Just "1970-01-01") Nothing + customRangeStats.totalScrobbles `shouldEqual` 2 + -- Test Filtering (as mentioned in architecture.md) listensFiltered <- getScrobbles conn 10 0 (Just { field: FilterGenre, value: "Rock" }) Nothing length listensFiltered `shouldEqual` 1