Commit Diff


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