commit - 2c3db42a0ce9dd86b70286ddf644e0c5efbc2190
commit + df106a5888e1d45cf0206200ede3e2fc79b51579
blob - 5072504ee5f318b9f9a6be0a99f57a93eec680f8
blob + 8aaf7b360f2248928f134413d26f3ca3e576f235
--- src/Api.elm
+++ src/Api.elm
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
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
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
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
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
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
~> jsonEmptyObject
newtype Stats = Stats
- { genres :: Array StatsEntry
+ { totalScrobbles :: Int
+ , genres :: Array StatsEntry
, labels :: Array StatsEntry
, years :: Array StatsEntry
, artists :: Array StatsEntry
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
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
]
]
-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
[ 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" ]
]
+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
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 } ]
[ 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