State.elm (16021B)
1 module State exposing (init, subscriptions, update) 2 3 import Api exposing (fetchListens, fetchSectionData, fetchSimilarTracks, fetchStats, httpErrorToString) 4 import Browser 5 import Browser.Navigation as Nav 6 import Dict 7 import Set 8 import Task 9 import Time 10 import Types exposing (Flags, Model, Msg(..), Period(..), SimilarState(..), Stats, StatsEntry, Tab(..)) 11 import Url 12 import Url.Parser exposing ((</>), (<?>)) 13 import Url.Parser.Query as Query 14 15 16 init : Flags -> Url.Url -> Nav.Key -> ( Model, Cmd Msg ) 17 init flags url navKey = 18 let 19 page = 20 parsePageParam url 21 22 offset = 23 max 0 ((page - 1) * 25) 24 in 25 ( { navKey = navKey 26 , listens = [] 27 , stats = Nothing 28 , error = Nothing 29 , loading = True 30 , currentTime = Nothing 31 , failedCovers = Set.empty 32 , hoveredCover = Nothing 33 , expandedSections = Set.empty 34 , loadedSections = Set.empty 35 , offset = offset 36 , limit = 25 37 , activeTab = ListensTab 38 , activeFilter = Nothing 39 , statsPeriod = AllTime 40 , customInput = "" 41 , showCustomInput = False 42 , customError = Nothing 43 , userSlug = flags.userSlug 44 , similarStates = Dict.empty 45 , allUsers = flags.allUsers 46 , searchInput = "" 47 , activeSearch = Nothing 48 , tabsVisible = False 49 } 50 , Cmd.batch 51 [ fetchListens flags.userSlug 25 offset Nothing Nothing 52 , Task.perform GotTime Time.now 53 ] 54 ) 55 56 57 parsePageParam : Url.Url -> Int 58 parsePageParam url = 59 let 60 pageParser = 61 Url.Parser.oneOf 62 [ Url.Parser.top <?> Query.int "page" 63 , (Url.Parser.s "u" </> Url.Parser.string <?> Query.int "page") |> Url.Parser.map (\_ p -> p) 64 ] 65 in 66 Url.Parser.parse pageParser url 67 |> Maybe.andThen identity 68 |> Maybe.withDefault 1 69 70 71 72 -- MSG 73 74 75 update : Msg -> Model -> ( Model, Cmd Msg ) 76 update msg model = 77 case msg of 78 UrlRequested urlRequest -> 79 case urlRequest of 80 Browser.Internal url -> 81 ( model, Nav.pushUrl model.navKey (Url.toString url) ) 82 83 Browser.External href -> 84 ( model, Nav.load href ) 85 86 UrlChanged url -> 87 let 88 newUserSlug = 89 if String.startsWith "/u/" url.path then 90 String.dropLeft 3 url.path 91 92 else 93 "" 94 95 page = 96 parsePageParam url 97 98 targetOffset = 99 max 0 ((page - 1) * model.limit) 100 101 isNaked = 102 url.query == Nothing 103 104 userChanged = 105 newUserSlug /= model.userSlug 106 107 needsCleanStart = 108 userChanged || isNaked 109 110 finalFilter = 111 if needsCleanStart then 112 Nothing 113 114 else 115 model.activeFilter 116 117 finalSearch = 118 if needsCleanStart then 119 Nothing 120 121 else 122 model.activeSearch 123 124 finalOffset = 125 if needsCleanStart then 126 0 127 128 else 129 targetOffset 130 in 131 if userChanged || model.offset /= finalOffset || model.activeFilter /= finalFilter || model.activeSearch /= finalSearch then 132 let 133 newModel = 134 { model 135 | userSlug = newUserSlug 136 , offset = finalOffset 137 , activeFilter = finalFilter 138 , activeSearch = finalSearch 139 , searchInput = 140 if needsCleanStart then 141 "" 142 143 else 144 model.searchInput 145 , activeTab = ListensTab 146 , loading = True 147 } 148 149 clearedModel = 150 if userChanged then 151 { newModel 152 | stats = Nothing 153 , expandedSections = Set.empty 154 , loadedSections = Set.empty 155 , similarStates = Dict.empty 156 } 157 158 else 159 newModel 160 in 161 ( clearedModel 162 , fetchListens newUserSlug model.limit finalOffset finalFilter finalSearch 163 ) 164 165 else 166 ( { model | activeTab = ListensTab }, Cmd.none ) 167 168 Tick time -> 169 if not model.loading then 170 ( { model | currentTime = Just time, loading = True } 171 , fetchListens model.userSlug model.limit model.offset model.activeFilter model.activeSearch 172 ) 173 174 else 175 ( { model | currentTime = Just time }, Cmd.none ) 176 177 GotTime time -> 178 ( { model | currentTime = Just time }, Cmd.none ) 179 180 GotListens result -> 181 let 182 newModel = 183 case result of 184 Err err -> 185 { model | loading = False, error = Just (httpErrorToString err) } 186 187 Ok listens -> 188 { model | loading = False, listens = listens, error = Nothing } 189 in 190 ( newModel, Task.perform GotTime Time.now ) 191 192 GotStats result -> 193 case result of 194 Err err -> 195 ( { model | error = Just (httpErrorToString err) }, Cmd.none ) 196 197 Ok stats -> 198 ( { model | stats = Just stats }, Cmd.none ) 199 200 GotSectionData section result -> 201 case result of 202 Err err -> 203 ( { model | error = Just (httpErrorToString err) }, Cmd.none ) 204 205 Ok entries -> 206 ( { model 207 | expandedSections = Set.insert section model.expandedSections 208 , loadedSections = Set.insert section model.loadedSections 209 , stats = Maybe.map (patchStatSection section entries) model.stats 210 } 211 , Cmd.none 212 ) 213 214 ImageError url -> 215 ( { model | failedCovers = Set.insert url model.failedCovers }, Cmd.none ) 216 217 NextPage -> 218 let 219 newOffset = 220 model.offset + model.limit 221 222 page = 223 newOffset // model.limit + 1 224 225 prefix = 226 if String.isEmpty model.userSlug then 227 "/" 228 229 else 230 "/u/" ++ model.userSlug 231 in 232 ( { model | offset = newOffset, loading = True } 233 , Cmd.batch 234 [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch 235 , Nav.pushUrl model.navKey (prefix ++ "?page=" ++ String.fromInt page) 236 ] 237 ) 238 239 PrevPage -> 240 let 241 newOffset = 242 max 0 (model.offset - model.limit) 243 244 page = 245 newOffset // model.limit + 1 246 247 prefix = 248 if String.isEmpty model.userSlug then 249 "/" 250 251 else 252 "/u/" ++ model.userSlug 253 in 254 ( { model | offset = newOffset, loading = True } 255 , Cmd.batch 256 [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch 257 , Nav.pushUrl model.navKey (prefix ++ "?page=" ++ String.fromInt page) 258 ] 259 ) 260 261 SwitchTab tab -> 262 ( { model | activeTab = tab } 263 , case tab of 264 StatsTab -> 265 if model.stats == Nothing then 266 fetchStats model.userSlug model.statsPeriod 267 268 else 269 Cmd.none 270 271 ListensTab -> 272 Cmd.none 273 274 AboutTab -> 275 Cmd.none 276 ) 277 278 ToggleTabs -> 279 ( { model | tabsVisible = not model.tabsVisible }, Cmd.none ) 280 281 SetStatsPeriod period -> 282 ( { model 283 | statsPeriod = period 284 , stats = Nothing 285 , expandedSections = Set.empty 286 , loadedSections = Set.empty 287 , showCustomInput = False 288 } 289 , fetchStats model.userSlug period 290 ) 291 292 OpenCustomInput -> 293 let 294 prefill = 295 case model.statsPeriod of 296 CustomRange from to -> 297 from ++ " " ++ to 298 299 _ -> 300 model.customInput 301 in 302 ( { model | showCustomInput = True, customInput = prefill, customError = Nothing } 303 , Cmd.none 304 ) 305 306 UpdateCustomInput str -> 307 ( { model | customInput = str, customError = Nothing }, Cmd.none ) 308 309 ApplyCustomPeriod -> 310 let 311 parts = 312 model.customInput 313 |> String.split " " 314 |> List.filter (not << String.isEmpty) 315 in 316 case parts of 317 [ from, to ] -> 318 if String.length from /= 10 || String.length to /= 10 then 319 ( { model | customError = Just "Dates must be in YYYY-MM-DD format" }, Cmd.none ) 320 321 else if from > to then 322 ( { model | customError = Just "'from' must be before 'to'" }, Cmd.none ) 323 324 else 325 let 326 period = 327 CustomRange from to 328 in 329 ( { model 330 | statsPeriod = period 331 , stats = Nothing 332 , expandedSections = Set.empty 333 , loadedSections = Set.empty 334 , showCustomInput = False 335 , customError = Nothing 336 } 337 , fetchStats model.userSlug period 338 ) 339 340 _ -> 341 ( { model | customError = Just "Enter two dates separated by a space" }, Cmd.none ) 342 343 FilterBy field value -> 344 let 345 filter = 346 Just { field = field, value = value } 347 348 prefix = 349 if String.isEmpty model.userSlug then 350 "/" 351 352 else 353 "/u/" ++ model.userSlug 354 in 355 ( { model | activeFilter = filter, activeSearch = Nothing, searchInput = "", offset = 0, activeTab = ListensTab, loading = True } 356 , Cmd.batch 357 [ fetchListens model.userSlug model.limit 0 filter Nothing 358 , Nav.pushUrl model.navKey (prefix ++ "?page=1") 359 ] 360 ) 361 362 ClearFilter -> 363 let 364 prefix = 365 if String.isEmpty model.userSlug then 366 "/" 367 368 else 369 "/u/" ++ model.userSlug 370 in 371 ( { model | activeFilter = Nothing, offset = 0, loading = True } 372 , Cmd.batch 373 [ fetchListens model.userSlug model.limit 0 Nothing model.activeSearch 374 , Nav.pushUrl model.navKey (prefix ++ "?page=1") 375 ] 376 ) 377 378 UpdateSearchInput str -> 379 ( { model | searchInput = str }, Cmd.none ) 380 381 SubmitSearch -> 382 let 383 q = 384 String.trim model.searchInput 385 386 prefix = 387 if String.isEmpty model.userSlug then 388 "/" 389 390 else 391 "/u/" ++ model.userSlug 392 in 393 if String.isEmpty q then 394 ( { model | activeSearch = Nothing, offset = 0, loading = True } 395 , Cmd.batch 396 [ fetchListens model.userSlug model.limit 0 Nothing Nothing 397 , Nav.pushUrl model.navKey (prefix ++ "?page=1") 398 ] 399 ) 400 401 else 402 ( { model | activeSearch = Just q, activeFilter = Nothing, offset = 0, loading = True } 403 , Cmd.batch 404 [ fetchListens model.userSlug model.limit 0 Nothing (Just q) 405 , Nav.pushUrl model.navKey (prefix ++ "?page=1") 406 ] 407 ) 408 409 ClearSearch -> 410 let 411 prefix = 412 if String.isEmpty model.userSlug then 413 "/" 414 415 else 416 "/u/" ++ model.userSlug 417 in 418 ( { model | searchInput = "", activeSearch = Nothing, offset = 0, loading = True } 419 , Cmd.batch 420 [ fetchListens model.userSlug model.limit 0 Nothing Nothing 421 , Nav.pushUrl model.navKey (prefix ++ "?page=1") 422 ] 423 ) 424 425 HoverCover idx -> 426 ( { model | hoveredCover = Just idx }, Cmd.none ) 427 428 UnhoverCover -> 429 ( { model | hoveredCover = Nothing }, Cmd.none ) 430 431 ExpandSection section -> 432 ( { model | expandedSections = Set.insert section model.expandedSections }, Cmd.none ) 433 434 ShowAllSection section -> 435 ( model, fetchSectionData model.userSlug model.statsPeriod section ) 436 437 CollapseSection section -> 438 ( { model 439 | expandedSections = Set.remove section model.expandedSections 440 , loadedSections = Set.remove section model.loadedSections 441 } 442 , Cmd.none 443 ) 444 445 FetchSimilar idx artist track -> 446 let 447 key = 448 String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ track 449 in 450 if Dict.member key model.similarStates then 451 ( { model | similarStates = Dict.remove key model.similarStates }, Cmd.none ) 452 453 else 454 ( { model | similarStates = Dict.insert key SimilarLoading model.similarStates } 455 , fetchSimilarTracks idx artist track 456 ) 457 458 FetchSimilarTracks idx artist track result -> 459 let 460 key = 461 String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ track 462 463 newStates = 464 case result of 465 Ok tracks -> 466 Dict.insert key (SimilarLoaded tracks) model.similarStates 467 468 Err err -> 469 Dict.insert key (SimilarError (httpErrorToString err)) model.similarStates 470 in 471 ( { model | similarStates = newStates }, Cmd.none ) 472 473 474 patchStatSection : String -> List StatsEntry -> Stats -> Stats 475 patchStatSection section entries stats = 476 case section of 477 "artist" -> 478 { stats | artists = entries } 479 480 "track" -> 481 { stats | tracks = entries } 482 483 "genre" -> 484 { stats | genres = entries } 485 486 "label" -> 487 { stats | labels = entries } 488 489 "year" -> 490 { stats | years = entries } 491 492 _ -> 493 stats 494 495 496 497 -- SUBSCRIPTIONS 498 499 500 subscriptions : Model -> Sub Msg 501 subscriptions _ = 502 Time.every 30000 Tick 503 504 505 506 -- HTTP