From eaed9b1269ae807469d16ac8896d94fa1992c1b9 Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Thu, 31 Oct 2024 20:38:08 -0600
Subject: [PATCH 1/9] Add Ratings to EoY 2024

---
 .../Managers/EndOfYearDataManager.swift       |  11 ++
 .../Public/DataManager.swift                  |   7 +
 .../Public/Model/UserPodcastRating.swift      |  13 ++
 .../Public/RatingsDataManager.swift           |   4 +
 .../API Tasks/RetrieveRatingsTask.swift       | 102 ++++++++++++
 .../API Tasks/UserPodcastRatingTask.swift     |   7 +-
 .../ApiServerHandler+UserPodcastRating.swift  |   1 +
 .../Sync/SyncYearListeningHistoryTask.swift   |   8 +-
 podcasts.xcodeproj/project.pbxproj            |   8 +-
 .../EndOfYear2023StoriesModel.swift           |   4 +
 .../EndOfYear2024StoriesModel.swift           |  16 ++
 .../End of Year 2024/EndOfYear2024Story.swift |   1 +
 .../End of Year/EndOfYearStoriesBuilder.swift |   5 +-
 .../Stories/2024/Ratings2024Story.swift       | 152 ++++++++++++++++++
 .../star.imageset/Contents.json               |  12 ++
 .../EndOfYear.xcassets/star.imageset/star.svg |   3 +
 podcasts/Strings+Generated.swift              |  14 ++
 podcasts/en.lproj/Localizable.strings         |  18 ++-
 18 files changed, 375 insertions(+), 11 deletions(-)
 create mode 100644 Modules/DataModel/Sources/PocketCastsDataModel/Public/Model/UserPodcastRating.swift
 create mode 100644 Modules/DataModel/Sources/PocketCastsDataModel/Public/RatingsDataManager.swift
 create mode 100644 Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift
 create mode 100644 podcasts/End of Year/Stories/2024/Ratings2024Story.swift
 create mode 100644 podcasts/EndOfYear.xcassets/star.imageset/Contents.json
 create mode 100644 podcasts/EndOfYear.xcassets/star.imageset/star.svg

diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift
index 3350b68e6c..4098bfc39c 100644
--- a/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift
+++ b/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift
@@ -376,6 +376,17 @@ class EndOfYearDataManager {
 
         return EpisodesStartedAndCompleted(started: started, completed: completed)
     }
+
+    func summarizedRatings(in year: Int) -> [UInt32: Int]? {
+        let calendar = Calendar.current
+        let ratings = DataManager.sharedManager.ratings.ratings?.filter { rating in
+            calendar.component(.year, from: rating.modifiedAt) == year
+        }
+        let groupedRatings = ratings?.reduce(into: [:]) { counts, rating in
+            counts[rating.podcastRating, default: 0] += 1
+        }
+        return groupedRatings
+    }
 }
 
 public struct ListenedCategory {
diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift
index 8509463428..b81fc3fac2 100644
--- a/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift
+++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift
@@ -24,6 +24,7 @@ public class DataManager {
 
     public let autoAddCandidates: AutoAddCandidatesDataManager
     public let bookmarks: BookmarkDataManager
+    public let ratings: RatingsDataManager
 
     private let dbQueue: FMDatabaseQueue
 
@@ -60,6 +61,7 @@ public class DataManager {
 
         autoAddCandidates = AutoAddCandidatesDataManager(dbQueue: dbQueue)
         bookmarks = BookmarkDataManager(dbQueue: dbQueue)
+        ratings = RatingsDataManager()
     }
 
     convenience init(endOfYearManager: EndOfYearDataManager) {
@@ -1133,5 +1135,10 @@ public extension DataManager {
 
     func episodesStartedAndCompleted(in year: Int) -> EpisodesStartedAndCompleted {
         endOfYearManager.episodesStartedAndCompleted(in: year, dbQueue: dbQueue)
+
+    }
+
+    func summarizedRatings(in year: Int) -> [UInt32: Int]? {
+        endOfYearManager.summarizedRatings(in: year)
     }
 }
diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/Model/UserPodcastRating.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/Model/UserPodcastRating.swift
new file mode 100644
index 0000000000..4a43e335e2
--- /dev/null
+++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/Model/UserPodcastRating.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+public struct UserPodcastRating: Codable {
+    public let podcastRating: UInt32
+    public let podcastUuid: String
+    public let modifiedAt: Date
+
+    public init(podcastRating: UInt32, podcastUuid: String, modifiedAt: Date) {
+        self.podcastRating = podcastRating
+        self.podcastUuid = podcastUuid
+        self.modifiedAt = modifiedAt
+    }
+}
diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/RatingsDataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/RatingsDataManager.swift
new file mode 100644
index 0000000000..c8232ec1f9
--- /dev/null
+++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/RatingsDataManager.swift
@@ -0,0 +1,4 @@
+/// This is currently in memory only since we don't store these locally in the database
+public class RatingsDataManager {
+    public var ratings: [UserPodcastRating]?
+}
diff --git a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift
new file mode 100644
index 0000000000..ffcd29cc3b
--- /dev/null
+++ b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift	
@@ -0,0 +1,102 @@
+import Foundation
+import PocketCastsDataModel
+import PocketCastsUtils
+import SwiftProtobuf
+
+class RetrieveRatingsTask: ApiBaseTask {
+    var completion: (([UserPodcastRating]?) -> Void)?
+
+    var success: Bool = false
+
+    private var convertedRatings = [UserPodcastRating]()
+
+    private lazy var addRatingGroup: DispatchGroup = {
+        let dispatchGroup = DispatchGroup()
+
+        return dispatchGroup
+    }()
+
+    override func apiTokenAcquired(token: String) {
+        let url = ServerConstants.Urls.api() + "user/podcast_rating/list"
+
+        do {
+            let (response, httpStatus) = getToServer(url: url, token: token)
+
+            guard let responseData = response, httpStatus?.statusCode == ServerConstants.HttpConstants.ok else {
+                completion?(nil)
+
+                return
+            }
+
+            do {
+                let serverRatings = try Api_PodcastRatingsResponse(serializedData: responseData).podcastRatings
+                if serverRatings.count == 0 {
+                    completion?(convertedRatings)
+
+                    return
+                }
+
+                convertedRatings = serverRatings.map { rating in
+                    UserPodcastRating(podcastRating: rating.podcastRating, podcastUuid: rating.podcastUuid, modifiedAt: rating.modifiedAt.date)
+                }
+
+                //TODO: Do comparison?
+
+                DataManager.sharedManager.ratings.ratings = convertedRatings
+
+                success = true
+
+                completion?(convertedRatings)
+            } catch {
+                FileLog.shared.addMessage("Decoding ratings failed \(error.localizedDescription)")
+                completion?(nil)
+            }
+        } catch {
+            FileLog.shared.addMessage("retrieve ratings failed \(error.localizedDescription)")
+            completion?(nil)
+        }
+    }
+
+//    private func processRating(_ protoRating: Api_PodcastRating) {
+//        // take the easy case first, do we have this episode locally?
+//        if convertLocalEpisode(protoEpisode: protoEpisode) {
+//            addRatingGroup.leave()
+//
+//            return
+//        }
+//
+//        // we don't have the episode, see if we have the podcast
+//        if let podcast = DataManager.sharedManager.findPodcast(uuid: protoEpisode.podcastUuid, includeUnsubscribed: true) {
+//            // we do, so try and refresh it
+//            ServerPodcastManager.shared.updatePodcastIfRequired(podcast: podcast) { [weak self] updated in
+//                if updated {
+//                    // the podcast was updated, try to convert the episode
+//                    self?.convertLocalEpisode(protoEpisode: protoEpisode)
+//                }
+//
+//                self?.addRatingGroup.leave()
+//            }
+//        } else {
+//            // we don't, so try and add it
+//            ServerPodcastManager.shared.addFromUuid(podcastUuid: protoEpisode.podcastUuid, subscribe: false) { [weak self] _ in
+//                // this will convert the episode if we now have it, if we don't not much we can do
+//                self?.convertLocalEpisode(protoEpisode: protoEpisode)
+//                self?.addRatingGroup.leave()
+//            }
+//        }
+//    }
+//
+//    @discardableResult
+//    private func convertLocalEpisode(protoEpisode: Api_StarredEpisode) -> Bool {
+//        guard let episode = DataManager.sharedManager.findEpisode(uuid: protoEpisode.uuid) else { return false }
+//
+//        // star this episode in case it's not locally
+//        if !episode.keepEpisode || episode.starredModified != protoEpisode.starredModified {
+//            DataManager.sharedManager.saveEpisode(starred: true, starredModified: protoEpisode.starredModified, episode: episode, updateSyncFlag: false)
+//        }
+//
+//        convertedEpisodes.append(episode)
+//
+//        return true
+//    }
+}
diff --git a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift
index 30c3437bf1..e39a8509c1 100644
--- a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift	
+++ b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift	
@@ -1,6 +1,7 @@
 import Foundation
 import PocketCastsUtils
 import SwiftProtobuf
+import PocketCastsDataModel
 
 class UserPodcastRatingAddTask: ApiBaseTask {
     var completion: ((Bool) -> Void)?
@@ -88,9 +89,3 @@ class UserPodcastRatingGetTask: ApiBaseTask {
         }
     }
 }
-
-public struct UserPodcastRating: Codable {
-    public let podcastRating: UInt32
-    public let podcastUuid: String
-    public let modifiedAt: Date
-}
diff --git a/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift b/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift
index 946b9f0937..42d0d1eb4c 100644
--- a/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift
+++ b/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift
@@ -1,4 +1,5 @@
 import Foundation
+import PocketCastsDataModel
 
 public extension ApiServerHandler {
     func addRating(uuid: String, rating: Int) async -> Bool {
diff --git a/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift b/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift
index 36baa387b6..98391265fb 100644
--- a/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift
+++ b/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift
@@ -192,7 +192,7 @@ class PodcastExistsHelper {
 public class YearListeningHistory {
     public static func sync() -> Bool {
         var syncResults: [Bool] = []
-        let yearsToSync: [Int32] = SubscriptionHelper.hasActiveSubscription() ? [2023, 2022] : [2023]
+        let yearsToSync: [Int32] = SubscriptionHelper.hasActiveSubscription() ? [2024, 2023, 2022] : [2024, 2023]
 
         let dispatchGroup = DispatchGroup()
         yearsToSync.forEach { yearToSync in
@@ -205,6 +205,12 @@ public class YearListeningHistory {
 
                 syncResults.append(syncYearListeningHistory.success)
 
+                let syncRatings = RetrieveRatingsTask()
+
+                syncRatings.start()
+
+                syncResults.append(syncRatings.success)
+
                 dispatchGroup.leave()
             }
         }
diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj
index e04733d5a9..e1baf75317 100644
--- a/podcasts.xcodeproj/project.pbxproj
+++ b/podcasts.xcodeproj/project.pbxproj
@@ -1666,6 +1666,7 @@
 		F52B4F8E2BB4A9ED00E87BE4 /* CategoriesSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52B4F8D2BB4A9ED00E87BE4 /* CategoriesSelectorView.swift */; };
 		F533F19D2C24B8CB00EDE9AA /* ShareDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = F533F19C2C24B8CB00EDE9AA /* ShareDestination.swift */; };
 		F53C3E832CC73549004F3581 /* TopSpotStory2024.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53C3E822CC73538004F3581 /* TopSpotStory2024.swift */; };
+		F53EFEEA2CD42AE400F4561B /* Ratings2024Story.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53EFEE92CD42ADF00F4561B /* Ratings2024Story.swift */; };
 		F543F6A42C0804FA00FEC8B6 /* AnyPublisher+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F543F6A32C0804FA00FEC8B6 /* AnyPublisher+Async.swift */; };
 		F54E0F542CC764C0006D4DA2 /* PositionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54E0F532CC764C0006D4DA2 /* PositionModifier.swift */; };
 		F54E72192CA722A000CD5C86 /* Array+DiscoverItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54E72182CA722A000CD5C86 /* Array+DiscoverItem.swift */; };
@@ -1724,9 +1725,9 @@
 		F5F6DA702BBE1109009B1934 /* CategoryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F6DA6F2BBE1109009B1934 /* CategoryButtonStyle.swift */; };
 		F5F6DA822BC0B512009B1934 /* CategoriesModalPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F6DA812BC0B512009B1934 /* CategoriesModalPicker.swift */; };
 		F5F884632CC9EAA6002BED2C /* Humane-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = F5F884622CC9EAA6002BED2C /* Humane-Bold.otf */; };
+		F5F884652CCA86AE002BED2C /* LongestEpisode2024Story.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */; };
 		F5F884672CCA8F23002BED2C /* CompletionRate2024Story.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F884662CCA8F1F002BED2C /* CompletionRate2024Story.swift */; };
 		F5F884692CCAE5C5002BED2C /* PaidStoryWallView2024.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F884682CCAE5C5002BED2C /* PaidStoryWallView2024.swift */; };
-		F5F884652CCA86AE002BED2C /* LongestEpisode2024Story.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */; };
 		F5F89B1E2C88B40A00013118 /* ShareButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F89B1D2C88B40A00013118 /* ShareButton.swift */; };
 		F5F8F3182CC314250071DD0E /* IntroStory2024.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F8F3172CC314250071DD0E /* IntroStory2024.swift */; };
 		F5FE747B2C223A6100DF2EAA /* ShareImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5FF61222BD07E3A00190711 /* ShareImageView.swift */; };
@@ -3613,6 +3614,7 @@
 		F52B4F8D2BB4A9ED00E87BE4 /* CategoriesSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesSelectorView.swift; sourceTree = "<group>"; };
 		F533F19C2C24B8CB00EDE9AA /* ShareDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDestination.swift; sourceTree = "<group>"; };
 		F53C3E822CC73538004F3581 /* TopSpotStory2024.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSpotStory2024.swift; sourceTree = "<group>"; };
+		F53EFEE92CD42ADF00F4561B /* Ratings2024Story.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ratings2024Story.swift; sourceTree = "<group>"; };
 		F543F6A32C0804FA00FEC8B6 /* AnyPublisher+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+Async.swift"; sourceTree = "<group>"; };
 		F54E0F532CC764C0006D4DA2 /* PositionModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionModifier.swift; sourceTree = "<group>"; };
 		F54E72182CA722A000CD5C86 /* Array+DiscoverItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+DiscoverItem.swift"; sourceTree = "<group>"; };
@@ -3668,9 +3670,9 @@
 		F5F6DA6F2BBE1109009B1934 /* CategoryButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryButtonStyle.swift; sourceTree = "<group>"; };
 		F5F6DA812BC0B512009B1934 /* CategoriesModalPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesModalPicker.swift; sourceTree = "<group>"; };
 		F5F884622CC9EAA6002BED2C /* Humane-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Humane-Bold.otf"; sourceTree = "<group>"; };
+		F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongestEpisode2024Story.swift; sourceTree = "<group>"; };
 		F5F884662CCA8F1F002BED2C /* CompletionRate2024Story.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRate2024Story.swift; sourceTree = "<group>"; };
 		F5F884682CCAE5C5002BED2C /* PaidStoryWallView2024.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaidStoryWallView2024.swift; sourceTree = "<group>"; };
-		F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongestEpisode2024Story.swift; sourceTree = "<group>"; };
 		F5F89B1D2C88B40A00013118 /* ShareButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareButton.swift; sourceTree = "<group>"; };
 		F5F8F3172CC314250071DD0E /* IntroStory2024.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroStory2024.swift; sourceTree = "<group>"; };
 		F5FF611F2BD076BA00190711 /* Sharing.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Sharing.xcassets; sourceTree = "<group>"; };
@@ -7891,6 +7893,7 @@
 				F581ED412CC6F19300A19860 /* ListeningTime2024Story.swift */,
 				F5D527372CC81AA700682CD5 /* EpilogueStory2024.swift */,
 				F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */,
+				F53EFEE92CD42ADF00F4561B /* Ratings2024Story.swift */,
 				F581ED432CC6F3A400A19860 /* Top5Podcasts2024Story.swift */,
 				F5F884662CCA8F1F002BED2C /* CompletionRate2024Story.swift */,
 			);
@@ -9568,6 +9571,7 @@
 				C713D4FA2A04C90500A78468 /* AccountHeaderViewModel.swift in Sources */,
 				10756F882C59449C0089D34F /* Folder+Sortable.swift in Sources */,
 				F55C4C762BCF064500A10352 /* DiscoverViewController+CategoryRedesign.swift in Sources */,
+				F53EFEEA2CD42AE400F4561B /* Ratings2024Story.swift in Sources */,
 				F5954D602C3F2864004A8C04 /* TrimHandle.swift in Sources */,
 				BDF09F041E669E16009E9845 /* BasePlayPauseButton.swift in Sources */,
 				407B21E72231F6A300B4E492 /* UploadedSettingsViewController.swift in Sources */,
diff --git a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift
index 97b11696f7..e4c34519fb 100644
--- a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift	
+++ b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift	
@@ -112,6 +112,10 @@ class EndOfYear2023StoriesModel: StoryModel {
         }
     }
 
+    func hasLoadedData(in dataManager: DataManager) -> Bool {
+        true // Default data load of episodes is enough
+    }
+
     func isReady() -> Bool {
         if !stories.isEmpty {
             stories.append(.intro)
diff --git a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift
index 913f8bfce5..88e69ef687 100644
--- a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift	
+++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift	
@@ -35,6 +35,12 @@ class EndOfYear2024StoriesModel: StoryModel {
             data.listeningTime = listeningTime
         }
 
+        // Ratings
+        if let ratings = dataManager.summarizedRatings(in: Self.year) {
+            data.ratings = ratings
+            stories.append(.ratings)
+        }
+
         // Longest episode
         if let longestEpisode = dataManager.longestEpisode(in: Self.year),
            let podcast = longestEpisode.parentPodcast() {
@@ -67,6 +73,8 @@ class EndOfYear2024StoriesModel: StoryModel {
             return TopSpotStory2024(topPodcast: data.topPodcasts.first!)
         case .top5Podcasts:
             return Top5Podcasts2024Story(top5Podcasts: data.topPodcasts)
+        case .ratings:
+            return Ratings2024Story(ratings: data.ratings)
         case .listeningTime:
             return ListeningTime2024Story(listeningTime: data.listeningTime)
         case .longestEpisode:
@@ -84,11 +92,17 @@ class EndOfYear2024StoriesModel: StoryModel {
             return true
         case .top5Podcasts:
             return true
+        case .ratings:
+            return true
         default:
             return false
         }
     }
 
+    func hasLoadedData(in dataManager: DataManager) -> Bool {
+        dataManager.ratings.ratings == nil
+    }
+
     func isReady() -> Bool {
         if stories.isEmpty {
             return false
@@ -144,4 +158,6 @@ class EndOfYear2024StoriesData {
     var top8Podcasts: [Podcast] = []
 
     var episodesStartedAndCompleted: EpisodesStartedAndCompleted!
+
+    var ratings: [UInt32: Int] = [:]
 }
diff --git a/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift b/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift
index 962b345387..e5942f6c4c 100644
--- a/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift	
+++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift	
@@ -3,6 +3,7 @@ enum EndOfYear2024Story: CaseIterable {
     case numberOfPodcastsAndEpisodesListened
     case topSpot
     case top5Podcasts
+    case ratings
     case listeningTime
     case longestEpisode
     case completionRate
diff --git a/podcasts/End of Year/EndOfYearStoriesBuilder.swift b/podcasts/End of Year/EndOfYearStoriesBuilder.swift
index 1a338f8eff..20b23ac5f1 100644
--- a/podcasts/End of Year/EndOfYearStoriesBuilder.swift	
+++ b/podcasts/End of Year/EndOfYearStoriesBuilder.swift	
@@ -27,7 +27,9 @@ class EndOfYearStoriesBuilder {
             let modelType = type(of: model)
 
             // Check if the user has the full listening history for this year
-            if SyncManager.isUserLoggedIn(), !Settings.hasSyncedEpisodesForPlayback(year: modelType.year) || (Settings.hasSyncedEpisodesForPlayback(year: modelType.year) && Settings.hasSyncedEpisodesForPlaybackAsPlusUser(year: modelType.year) != hasActiveSubscription()) {
+            if SyncManager.isUserLoggedIn(),
+               !Settings.hasSyncedEpisodesForPlayback(year: modelType.year) ||
+                (Settings.hasSyncedEpisodesForPlayback(year: modelType.year) && Settings.hasSyncedEpisodesForPlaybackAsPlusUser(year: modelType.year) != hasActiveSubscription()) || model.hasLoadedData(in: dataManager) {
                 let syncedWithSuccess = sync?()
 
                 if syncedWithSuccess == true {
@@ -53,6 +55,7 @@ protocol StoryModel {
     func populate(with dataManager: DataManager)
     func story(for storyNumber: Int) -> any StoryView
     func isInteractiveView(for storyNumber: Int) -> Bool
+    func hasLoadedData(in dataManager: DataManager) -> Bool
     func isReady() -> Bool
     func paywallView() -> AnyView
     /// Overlaid on top of the story
diff --git a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift
new file mode 100644
index 0000000000..cadd207e8e
--- /dev/null
+++ b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
@@ -0,0 +1,152 @@
+import SwiftUI
+import SafariServices
+
+struct Ratings2024Story: ShareableStory {
+
+    let ratingScale = 1...5
+    let ratings: [UInt32: Int]
+
+    let foregroundColor: Color = .black
+    let backgroundColor: Color = Color(hex: "#EFECAD")
+    private let ratingsBlogPostURL = URL(string: "https://blog.pocketcasts.com/2024/08/20/podcast-ratings/")!
+
+    @ObservedObject private var animationViewModel = PlayPauseAnimationViewModel(duration: 0.8, animation: Animation.spring(_:))
+    @Environment(\.animated) var animated: Bool
+
+    @State var scale: Double = 1
+
+    var body: some View {
+        Group {
+            if ratings.count == 0 {
+                emptyView()
+            } else {
+                VStack(alignment: .leading) {
+                    Spacer()
+                    chartView()
+                        .modifier(animationViewModel.animate($scale, to: 1))
+                        .padding()
+                        .padding(.vertical, 32)
+                    Spacer()
+                    footerView()
+                }
+            }
+        }
+        .onAppear {
+            if animated {
+                setInitialCoverOffsetForAnimation()
+                animationViewModel.play()
+            }
+        }
+        .foregroundStyle(foregroundColor)
+        .background(backgroundColor)
+        .allowsHitTesting(false)
+    }
+
+    private func setInitialCoverOffsetForAnimation() {
+        scale = 0
+    }
+
+    @ViewBuilder func emptyView() -> some View {
+        let words = ["OOOOPSIES"]
+        let separator = Image("star")
+        VStack {
+            Spacer()
+            VStack {
+                MarqueeTextView(words: words, separator: separator, direction: .leading)
+                MarqueeTextView(words: words, separator: separator, direction: .trailing)
+            }
+            .frame(height: 350)
+            Spacer()
+            emptyFooterView()
+        }
+    }
+
+    @ViewBuilder func emptyFooterView() -> some View {
+        VStack(alignment: .leading, spacing: 16) {
+            Text(L10n.playback2024RatingsEmptyTitle)
+                .font(.system(size: 31, weight: .bold))
+            Text(L10n.playback2024RatingsEmptyDescription)
+                .font(.system(size: 15, weight: .light))
+            Button(L10n.learnAboutRatings) {
+                //TODO: Pause
+
+                // Open ratings blog post in SFSafariViewController
+                let safariViewController = SFSafariViewController(with: ratingsBlogPostURL)
+                safariViewController.modalPresentationStyle = .formSheet
+                SceneHelper.rootViewController()?.present(safariViewController, animated: true, completion: nil)
+            }
+            .buttonStyle(BasicButtonStyle(textColor: .black, backgroundColor: Color.clear, borderColor: .black))
+            .allowsHitTesting(true)
+        }
+        .padding(.horizontal, 24)
+        .padding(.vertical, 6)
+    }
+
+    @ViewBuilder func chartView() -> some View {
+        GeometryReader { geometry in
+            HStack(alignment: .bottom) {
+                let maxRating = ratings.values.max() ?? 0
+                ForEach(ratingScale, id: \.self) { ratingGroup in
+                    let count = ratings[UInt32(ratingGroup)] ?? 0
+                    VStack {
+                        Text("\(ratingGroup)")
+                            .font(.system(size: 22, weight: .semibold))
+                            .opacity(scale)
+                            .offset(x: 0, y: 10 * (1 - scale))
+                        DashedRectangle()
+                            .frame(height: max(geometry.size.height * (CGFloat(count) / CGFloat(maxRating)), 5))
+                            .scaleEffect(x: 1, y: scale, anchor: .bottom)
+                    }
+                }
+            }
+        }
+    }
+
+    private func descriptionText() -> String {
+        let maxRatingCategory = ratings.max(by: { $0.value < $1.value })?.key ?? 0
+        switch maxRatingCategory {
+        case 1...3:
+            return L10n.playback2024RatingsDescription1To3
+        case 4...5:
+            return L10n.playback2024RatingsDescription4To5(maxRatingCategory)
+        default:
+            return ""
+        }
+
+    }
+
+    @ViewBuilder func footerView() -> some View {
+        VStack(alignment: .leading, spacing: 16) {
+            Text(L10n.playback2024RatingsTitle)
+                .font(.system(size: 31, weight: .bold))
+            Text(descriptionText())
+                .font(.system(size: 15, weight: .light))
+        }
+        .padding(.horizontal, 24)
+        .padding(.bottom, 24)
+    }
+
+    func sharingAssets() -> [Any] {
+        [
+            StoryShareableProvider.new(AnyView(self)),
+            StoryShareableText(L10n.eoyYearCompletionRateShareText)
+        ]
+    }
+
+    func hideShareButton() -> Bool {
+        ratings.count == 0
+    }
+}
+
+struct DashedRectangle: View {
+    var body: some View {
+        GeometryReader { geometry in
+            VStack(spacing: 4) {
+                ForEach(0..<Int(geometry.size.height / 5), id: \.self) { _ in
+                    Rectangle()
+                        .frame(height: 1)
+                }
+            }
+        }
+    }
+}
diff --git a/podcasts/EndOfYear.xcassets/star.imageset/Contents.json b/podcasts/EndOfYear.xcassets/star.imageset/Contents.json
new file mode 100644
index 0000000000..fb87640df4
--- /dev/null
+++ b/podcasts/EndOfYear.xcassets/star.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+  "images" : [
+    {
+      "filename" : "star.svg",
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/podcasts/EndOfYear.xcassets/star.imageset/star.svg b/podcasts/EndOfYear.xcassets/star.imageset/star.svg
new file mode 100644
index 0000000000..98ec02bc22
--- /dev/null
+++ b/podcasts/EndOfYear.xcassets/star.imageset/star.svg
@@ -0,0 +1,3 @@
+<svg width="30" height="29" viewBox="0 0 30 29" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.8912 28.0547C7.11783 28.9871 5.41286 27.7395 5.7502 25.7727L6.99783 18.4984L1.71278 13.3468C0.278089 11.9483 0.937733 9.94126 2.91253 9.6543L10.2163 8.593L13.4826 1.97467C14.3693 0.178048 16.482 0.185197 17.3651 1.97467L20.6315 8.593L27.9352 9.6543C29.9179 9.9424 30.564 11.9539 29.135 13.3468L23.8499 18.4984L25.0976 25.7727C25.4363 27.7474 23.7229 28.9833 21.9566 28.0547L15.4239 24.6203L8.8912 28.0547Z" fill="#161718"/>
+</svg>
diff --git a/podcasts/Strings+Generated.swift b/podcasts/Strings+Generated.swift
index 3779636d8a..370b2f9f79 100644
--- a/podcasts/Strings+Generated.swift
+++ b/podcasts/Strings+Generated.swift
@@ -1475,6 +1475,8 @@ internal enum L10n {
   internal static var kidsProfileThankyouText: String { return L10n.tr("Localizable", "kids_profile_thankyou_text") }
   /// Thank you for your interest
   internal static var kidsProfileThankyouTitle: String { return L10n.tr("Localizable", "kids_profile_thankyou_title") }
+  /// Learn about ratings
+  internal static var learnAboutRatings: String { return L10n.tr("Localizable", "learn_about_ratings") }
   /// Learn More
   internal static var learnMore: String { return L10n.tr("Localizable", "learn_more") }
   /// Listening History
@@ -1790,6 +1792,18 @@ internal enum L10n {
   internal static var playback2024PlusUpsellDescription: String { return L10n.tr("Localizable", "playback_2024_plus_upsell_description") }
   /// There's more!
   internal static var playback2024PlusUpsellTitle: String { return L10n.tr("Localizable", "playback_2024_plus_upsell_title") }
+  /// Thanks for sharing your feedback with the creator community
+  internal static var playback2024RatingsDescription1To3: String { return L10n.tr("Localizable", "playback_2024_ratings_description_1_to_3") }
+  /// Wow, so many %1$@ star ratings! Thanks for sharing the love with your favorite creators.
+  internal static func playback2024RatingsDescription4To5(_ p1: Any) -> String {
+    return L10n.tr("Localizable", "playback_2024_ratings_description_4_to_5", String(describing: p1))
+  }
+  /// Did you know that you can rate shows now? Share the love for your favorite creators and help them get noticed!
+  internal static var playback2024RatingsEmptyDescription: String { return L10n.tr("Localizable", "playback_2024_ratings_empty_description") }
+  /// Oh-oh! No podcast ratings to show you yet
+  internal static var playback2024RatingsEmptyTitle: String { return L10n.tr("Localizable", "playback_2024_ratings_empty_title") }
+  /// Let’s see your ratings!
+  internal static var playback2024RatingsTitle: String { return L10n.tr("Localizable", "playback_2024_ratings_title") }
   /// You listened to %1$@ episodes for a total of %2$@ of "%3$@"
   internal static func playback2024TopSpotDescription(_ p1: Any, _ p2: Any, _ p3: Any) -> String {
     return L10n.tr("Localizable", "playback_2024_top_spot_description", String(describing: p1), String(describing: p2), String(describing: p3))
diff --git a/podcasts/en.lproj/Localizable.strings b/podcasts/en.lproj/Localizable.strings
index 00e2d97f41..8002011a7c 100644
--- a/podcasts/en.lproj/Localizable.strings
+++ b/podcasts/en.lproj/Localizable.strings
@@ -3602,8 +3602,23 @@
 /*A title used for the Playback 2024 Top Podcast screen */
 "playback_2024_top_spot_title" = "This was your top podcast in 2024";
 
+/* A title shown on the Ratings Playback 2024 screen showing a bar chart of your ratings */
+"playback_2024_ratings_title" = "Let’s see your ratings!";
+
+/* A description shown in Playback 2024 when the user has made ratings of 4-5/5 for Podcasts */
+"playback_2024_ratings_description_4_to_5" = "Wow, so many %1$@ star ratings! Thanks for sharing the love with your favorite creators.";
+
+/* A description shown in Playback 2024 when the user has only made ratings of 1-3/5 for Podcasts */
+"playback_2024_ratings_description_1_to_3" = "Thanks for sharing your feedback with the creator community";
+
 "playback_2024_top_spot_description" = "You listened to %1$@ episodes for a total of %2$@ of \"%3$@\"";
 
+/* A title shown in Playback 2024 when the user has not made any ratings. */
+"playback_2024_ratings_empty_title" = "Oh-oh! No podcast ratings to show you yet";
+
+/* A description shown in Playback 2024 to describe the new Podcast Ratings feature */
+"playback_2024_ratings_empty_description" = "Did you know that you can rate shows now? Share the love for your favorite creators and help them get noticed!";
+
 /* Label of the End of Year dismiss button */
 "eoy_not_now" = "Not Now";
 
@@ -4573,4 +4588,5 @@
 /* Toast message when episode download is cancelled*/
 "player_episode_download_cancelled" = "Episode download cancelled";
 
-
+/* A title shown on a button to open information about the Podcast Ratings feature */
+"learn_about_ratings" = "Learn about ratings";

From 3982ab5ba620f180049b75f2d447d772b59db2ea Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Thu, 31 Oct 2024 20:38:21 -0600
Subject: [PATCH 2/9] Add Analytics event to Learn Ratings

---
 podcasts/Analytics/AnalyticsEvent.swift                  | 1 +
 podcasts/End of Year/Stories/2024/Ratings2024Story.swift | 2 ++
 2 files changed, 3 insertions(+)

diff --git a/podcasts/Analytics/AnalyticsEvent.swift b/podcasts/Analytics/AnalyticsEvent.swift
index 63247f21e7..cda3e2e569 100644
--- a/podcasts/Analytics/AnalyticsEvent.swift
+++ b/podcasts/Analytics/AnalyticsEvent.swift
@@ -637,6 +637,7 @@ enum AnalyticsEvent: String {
     case endOfYearStoryShared
     case endOfYearProfileCardTapped
     case endOfYearUpsellShown
+    case endOfYearLearnRatingsShown
 
     // MARK: - Welcome View
 
diff --git a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift
index cadd207e8e..067f0b4ab9 100644
--- a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
+++ b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
@@ -74,6 +74,8 @@ struct Ratings2024Story: ShareableStory {
                 let safariViewController = SFSafariViewController(with: ratingsBlogPostURL)
                 safariViewController.modalPresentationStyle = .formSheet
                 SceneHelper.rootViewController()?.present(safariViewController, animated: true, completion: nil)
+
+                Analytics.track(.endOfYearLearnRatingsShown)
             }
             .buttonStyle(BasicButtonStyle(textColor: .black, backgroundColor: Color.clear, borderColor: .black))
             .allowsHitTesting(true)

From 8685f2b9a3ed82a6e76bf7332f6f4de6b4f245b6 Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Thu, 31 Oct 2024 21:12:34 -0600
Subject: [PATCH 3/9] Add pause to Ratings Learn More

---
 podcasts.xcodeproj/project.pbxproj            |  4 +++
 .../Stories/2024/Ratings2024Story.swift       | 25 +++++++++++--------
 podcasts/End of Year/StoriesDataSource.swift  | 19 ++++++++++++++
 podcasts/End of Year/Views/StoriesView.swift  | 10 ++++++++
 podcasts/SwiftUI/SFSafariView.swift           | 14 +++++++++++
 5 files changed, 62 insertions(+), 10 deletions(-)
 create mode 100644 podcasts/SwiftUI/SFSafariView.swift

diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj
index e1baf75317..593cc48bdc 100644
--- a/podcasts.xcodeproj/project.pbxproj
+++ b/podcasts.xcodeproj/project.pbxproj
@@ -1713,6 +1713,7 @@
 		F5D3A0D92B70950100EED067 /* MockURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D3A0D82B70950100EED067 /* MockURLHandler.swift */; };
 		F5D527382CC81AAD00682CD5 /* EpilogueStory2024.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D527372CC81AA700682CD5 /* EpilogueStory2024.swift */; };
 		F5DBA58A2B756A8700AED77F /* PodcastSettings+ImportUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DBA5892B756A8700AED77F /* PodcastSettings+ImportUserDefaultsTests.swift */; };
+		F5E16FE72CD47DE800C0372F /* SFSafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E16FE62CD47DE800C0372F /* SFSafariView.swift */; };
 		F5E3DAA72BDD5567002BD4E4 /* PCBundleDoc.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E3DAA62BDD5567002BD4E4 /* PCBundleDoc.swift */; };
 		F5E431D62B50888500A71DB3 /* PlusLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E431D52B50888500A71DB3 /* PlusLabel.swift */; };
 		F5E949DA2B61762E002DAFC3 /* TokenHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E949D92B61762E002DAFC3 /* TokenHelperTests.swift */; };
@@ -3658,6 +3659,7 @@
 		F5D3A0D82B70950100EED067 /* MockURLHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockURLHandler.swift; sourceTree = "<group>"; };
 		F5D527372CC81AA700682CD5 /* EpilogueStory2024.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpilogueStory2024.swift; sourceTree = "<group>"; };
 		F5DBA5892B756A8700AED77F /* PodcastSettings+ImportUserDefaultsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PodcastSettings+ImportUserDefaultsTests.swift"; sourceTree = "<group>"; };
+		F5E16FE62CD47DE800C0372F /* SFSafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSafariView.swift; sourceTree = "<group>"; };
 		F5E3DAA62BDD5567002BD4E4 /* PCBundleDoc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCBundleDoc.swift; sourceTree = "<group>"; };
 		F5E431D52B50888500A71DB3 /* PlusLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlusLabel.swift; sourceTree = "<group>"; };
 		F5E949D92B61762E002DAFC3 /* TokenHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHelperTests.swift; sourceTree = "<group>"; };
@@ -6970,6 +6972,7 @@
 		C74887782919DC7B00EBC926 /* SwiftUI */ = {
 			isa = PBXGroup;
 			children = (
+				F5E16FE62CD47DE800C0372F /* SFSafariView.swift */,
 				F54E0F532CC764C0006D4DA2 /* PositionModifier.swift */,
 				C7B3C60B2919DCC800054145 /* ActionView.swift */,
 				C7A110F8291F66C700887A90 /* Confetti.swift */,
@@ -9557,6 +9560,7 @@
 				F54E721D2CA7359800CD5C86 /* DiscoverCellType.swift in Sources */,
 				F51D90742C71A0A100F0A232 /* SharingFooterView.swift in Sources */,
 				BD240C43231F46F400FB2CDD /* PCSearchBarController+TextFieldDelegate.swift in Sources */,
+				F5E16FE72CD47DE800C0372F /* SFSafariView.swift in Sources */,
 				4053CDF9214F3583001C92B1 /* PodcastFilterOverlayController.swift in Sources */,
 				BDF5FD1E1FD8D38900F2A339 /* DownloadsViewController+Table.swift in Sources */,
 				C72CED32289DA1900017883A /* TracksAdapter.swift in Sources */,
diff --git a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift
index 067f0b4ab9..d562ea8dfa 100644
--- a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
+++ b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
@@ -1,5 +1,4 @@
 import SwiftUI
-import SafariServices
 
 struct Ratings2024Story: ShareableStory {
 
@@ -12,8 +11,10 @@ struct Ratings2024Story: ShareableStory {
 
     @ObservedObject private var animationViewModel = PlayPauseAnimationViewModel(duration: 0.8, animation: Animation.spring(_:))
     @Environment(\.animated) var animated: Bool
+    @Environment(\.pauseState) var pauseState
 
     @State var scale: Double = 1
+    @State var openURL = false
 
     var body: some View {
         Group {
@@ -38,8 +39,11 @@ struct Ratings2024Story: ShareableStory {
             }
         }
         .foregroundStyle(foregroundColor)
-        .background(backgroundColor)
-        .allowsHitTesting(false)
+        .background(
+            backgroundColor
+                .ignoresSafeArea()
+                .allowsHitTesting(false)
+        )
     }
 
     private func setInitialCoverOffsetForAnimation() {
@@ -68,18 +72,19 @@ struct Ratings2024Story: ShareableStory {
             Text(L10n.playback2024RatingsEmptyDescription)
                 .font(.system(size: 15, weight: .light))
             Button(L10n.learnAboutRatings) {
-                //TODO: Pause
-
-                // Open ratings blog post in SFSafariViewController
-                let safariViewController = SFSafariViewController(with: ratingsBlogPostURL)
-                safariViewController.modalPresentationStyle = .formSheet
-                SceneHelper.rootViewController()?.present(safariViewController, animated: true, completion: nil)
-
+                pauseState.togglePause()
+                openURL = true
                 Analytics.track(.endOfYearLearnRatingsShown)
             }
             .buttonStyle(BasicButtonStyle(textColor: .black, backgroundColor: Color.clear, borderColor: .black))
             .allowsHitTesting(true)
         }
+        .sheet(isPresented: $openURL, onDismiss: {
+            pauseState.togglePause()
+            openURL = false
+        }, content: {
+            SFSafariView(url: ratingsBlogPostURL)
+        })
         .padding(.horizontal, 24)
         .padding(.vertical, 6)
     }
diff --git a/podcasts/End of Year/StoriesDataSource.swift b/podcasts/End of Year/StoriesDataSource.swift
index 0a0a9d33b4..e1ce80aaad 100644
--- a/podcasts/End of Year/StoriesDataSource.swift	
+++ b/podcasts/End of Year/StoriesDataSource.swift	
@@ -115,6 +115,25 @@ extension EnvironmentValues {
     }
 }
 
+class PauseState: ObservableObject {
+    @Published private(set) var isPaused: Bool = false
+
+    func togglePause() {
+        isPaused.toggle()
+    }
+}
+
+struct PauseStateKey: EnvironmentKey {
+    static let defaultValue: PauseState = PauseState()
+}
+
+extension EnvironmentValues {
+    var pauseState: PauseState {
+        get { self[PauseStateKey.self] }
+        set { self[PauseStateKey.self] = newValue }
+    }
+}
+
 
 // MARK: - Shareable Stories
 typealias ShareableStory = StoryView & StorySharing
diff --git a/podcasts/End of Year/Views/StoriesView.swift b/podcasts/End of Year/Views/StoriesView.swift
index dc01bb585f..fe347ac3a7 100644
--- a/podcasts/End of Year/Views/StoriesView.swift	
+++ b/podcasts/End of Year/Views/StoriesView.swift	
@@ -17,6 +17,8 @@ struct StoriesView: View {
         self.syncProgressModel = syncProgressModel
     }
 
+    @StateObject private var pauseState = PauseState()
+
     @ViewBuilder
     var body: some View {
         if model.isReady {
@@ -47,6 +49,7 @@ struct StoriesView: View {
                         }
                     }
                     .environment(\.animated, true)
+                    .environment(\.pauseState, pauseState)
 
                 if model.shouldShowUpsell() {
                     model.paywallView().zIndex(6).onAppear {
@@ -89,6 +92,13 @@ struct StoriesView: View {
         } message: {
             Text(L10n.eoyShareThisStoryMessage)
         }
+        .onChange(of: pauseState.isPaused) { isPaused in
+            if isPaused {
+                model.pause()
+            } else {
+                model.start()
+            }
+        }
     }
 
     // View shown while data source is preparing
diff --git a/podcasts/SwiftUI/SFSafariView.swift b/podcasts/SwiftUI/SFSafariView.swift
new file mode 100644
index 0000000000..a17a43dc43
--- /dev/null
+++ b/podcasts/SwiftUI/SFSafariView.swift
@@ -0,0 +1,14 @@
+import SafariServices
+import SwiftUI
+
+struct SFSafariView: UIViewControllerRepresentable {
+    let url: URL
+
+    func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> SFSafariViewController {
+        return SFSafariViewController(url: url)
+    }
+
+    func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext<SFSafariView>) {
+        // No need to do anything here
+    }
+}

From aebebbfa8cdb11ad259aba1ca0aa4516e720ad01 Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Thu, 31 Oct 2024 21:55:38 -0600
Subject: [PATCH 4/9] Adjust spacing

---
 podcasts/End of Year/Stories/2024/Ratings2024Story.swift | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift
index d562ea8dfa..c87ac83d95 100644
--- a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
+++ b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
@@ -32,6 +32,7 @@ struct Ratings2024Story: ShareableStory {
                 }
             }
         }
+        .padding(.top, 24)
         .onAppear {
             if animated {
                 setInitialCoverOffsetForAnimation()
@@ -59,7 +60,7 @@ struct Ratings2024Story: ShareableStory {
                 MarqueeTextView(words: words, separator: separator, direction: .leading)
                 MarqueeTextView(words: words, separator: separator, direction: .trailing)
             }
-            .frame(height: 350)
+            .frame(height: 400)
             Spacer()
             emptyFooterView()
         }
@@ -79,6 +80,7 @@ struct Ratings2024Story: ShareableStory {
             .buttonStyle(BasicButtonStyle(textColor: .black, backgroundColor: Color.clear, borderColor: .black))
             .allowsHitTesting(true)
         }
+        .minimumScaleFactor(0.5)
         .sheet(isPresented: $openURL, onDismiss: {
             pauseState.togglePause()
             openURL = false
@@ -130,7 +132,7 @@ struct Ratings2024Story: ShareableStory {
                 .font(.system(size: 15, weight: .light))
         }
         .padding(.horizontal, 24)
-        .padding(.bottom, 24)
+        .padding(.bottom, 12)
     }
 
     func sharingAssets() -> [Any] {

From 0cbc28a303452ee18391eb4897764ebc5b687c82 Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Thu, 31 Oct 2024 22:18:38 -0600
Subject: [PATCH 5/9] Fix dashed rectangle alignment

---
 podcasts/End of Year/Stories/2024/Ratings2024Story.swift | 1 +
 1 file changed, 1 insertion(+)

diff --git a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift
index c87ac83d95..930382883e 100644
--- a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
+++ b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift	
@@ -156,6 +156,7 @@ struct DashedRectangle: View {
                         .frame(height: 1)
                 }
             }
+            .frame(maxHeight: .infinity, alignment: .bottom)
         }
     }
 }

From 114af09cb84813da82b2eed29f936b38cedc33fa Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Thu, 31 Oct 2024 22:22:54 -0600
Subject: [PATCH 6/9] Clean up old code and warnings

---
 .../API Tasks/RetrieveRatingsTask.swift       | 77 ++++---------------
 1 file changed, 13 insertions(+), 64 deletions(-)

diff --git a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift
index ffcd29cc3b..96d1b83dfd 100644
--- a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift	
+++ b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift	
@@ -3,7 +3,7 @@ import PocketCastsDataModel
 import PocketCastsUtils
 import SwiftProtobuf
 
-class RetrieveRatingsTask: ApiBaseTask {
+class RetrieveRatingsTask: ApiBaseTask, @unchecked Sendable {
     var completion: (([UserPodcastRating]?) -> Void)?
 
     var success: Bool = false
@@ -24,79 +24,28 @@ class RetrieveRatingsTask: ApiBaseTask {
 
             guard let responseData = response, httpStatus?.statusCode == ServerConstants.HttpConstants.ok else {
                 completion?(nil)
-
                 return
             }
 
-            do {
-                let serverRatings = try Api_PodcastRatingsResponse(serializedData: responseData).podcastRatings
-                if serverRatings.count == 0 {
-                    completion?(convertedRatings)
-
-                    return
-                }
+            let serverRatings = try Api_PodcastRatingsResponse(serializedBytes: responseData).podcastRatings
+            if serverRatings.count == 0 {
+                completion?(convertedRatings)
 
-                convertedRatings = serverRatings.map { rating in
-                    UserPodcastRating(podcastRating: rating.podcastRating, podcastUuid: rating.podcastUuid, modifiedAt: rating.modifiedAt.date)
-                }
+                return
+            }
 
-                //TODO: Do comparison?
+            convertedRatings = serverRatings.map { rating in
+                UserPodcastRating(podcastRating: rating.podcastRating, podcastUuid: rating.podcastUuid, modifiedAt: rating.modifiedAt.date)
+            }
 
-                DataManager.sharedManager.ratings.ratings = convertedRatings
+            DataManager.sharedManager.ratings.ratings = convertedRatings
 
-                success = true
+            success = true
 
-                completion?(convertedRatings)
-            } catch {
-                FileLog.shared.addMessage("Decoding ratings failed \(error.localizedDescription)")
-                completion?(nil)
-            }
+            completion?(convertedRatings)
         } catch {
-            FileLog.shared.addMessage("retrieve ratings failed \(error.localizedDescription)")
+            FileLog.shared.addMessage("Decoding ratings failed \(error.localizedDescription)")
             completion?(nil)
         }
     }
-
-//    private func processRating(_ protoRating: Api_PodcastRating) {
-//        // take the easy case first, do we have this episode locally?
-//        if convertLocalEpisode(protoEpisode: protoEpisode) {
-//            addRatingGroup.leave()
-//
-//            return
-//        }
-//
-//        // we don't have the episode, see if we have the podcast
-//        if let podcast = DataManager.sharedManager.findPodcast(uuid: protoEpisode.podcastUuid, includeUnsubscribed: true) {
-//            // we do, so try and refresh it
-//            ServerPodcastManager.shared.updatePodcastIfRequired(podcast: podcast) { [weak self] updated in
-//                if updated {
-//                    // the podcast was updated, try to convert the episode
-//                    self?.convertLocalEpisode(protoEpisode: protoEpisode)
-//                }
-//
-//                self?.addRatingGroup.leave()
-//            }
-//        } else {
-//            // we don't, so try and add it
-//            ServerPodcastManager.shared.addFromUuid(podcastUuid: protoEpisode.podcastUuid, subscribe: false) { [weak self] _ in
-//                // this will convert the episode if we now have it, if we don't not much we can do
-//                self?.convertLocalEpisode(protoEpisode: protoEpisode)
-//                self?.addRatingGroup.leave()
-//            }
-//        }
-//    }
-//
-//    @discardableResult
-//    private func convertLocalEpisode(protoEpisode: Api_StarredEpisode) -> Bool {
-//        guard let episode = DataManager.sharedManager.findEpisode(uuid: protoEpisode.uuid) else { return false }
-//
-//        // star this episode in case it's not locally
-//        if !episode.keepEpisode || episode.starredModified != protoEpisode.starredModified {
-//            DataManager.sharedManager.saveEpisode(starred: true, starredModified: protoEpisode.starredModified, episode: episode, updateSyncFlag: false)
-//        }
-//
-//        convertedEpisodes.append(episode)
-//
-//        return true
-//    }
 }

From 0a5acf6b4b3c8c07b73d7a01c38f59f249165f9b Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Fri, 1 Nov 2024 09:02:58 -0600
Subject: [PATCH 7/9] Change method name for model data loading

---
 .../End of Year 2023/EndOfYear2023StoriesModel.swift          | 2 +-
 .../End of Year 2024/EndOfYear2024StoriesModel.swift          | 4 +++-
 podcasts/End of Year/EndOfYearStoriesBuilder.swift            | 4 ++--
 3 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift
index e4c34519fb..2adc12d379 100644
--- a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift	
+++ b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift	
@@ -112,7 +112,7 @@ class EndOfYear2023StoriesModel: StoryModel {
         }
     }
 
-    func hasLoadedData(in dataManager: DataManager) -> Bool {
+    func shouldLoadData(in dataManager: DataManager) -> Bool {
         true // Default data load of episodes is enough
     }
 
diff --git a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift
index ef6518ccbb..20c082199b 100644
--- a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift	
+++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift	
@@ -109,7 +109,9 @@ class EndOfYear2024StoriesModel: StoryModel {
         }
     }
 
-    func hasLoadedData(in dataManager: DataManager) -> Bool {
+    func shouldLoadData(in dataManager: DataManager) -> Bool {
+        // Load data if our `ratings` property is empty
+        // Other data is handled in `EndOfYearStoriesBuilder`
         dataManager.ratings.ratings == nil
     }
 
diff --git a/podcasts/End of Year/EndOfYearStoriesBuilder.swift b/podcasts/End of Year/EndOfYearStoriesBuilder.swift
index 20b23ac5f1..9047186dcb 100644
--- a/podcasts/End of Year/EndOfYearStoriesBuilder.swift	
+++ b/podcasts/End of Year/EndOfYearStoriesBuilder.swift	
@@ -29,7 +29,7 @@ class EndOfYearStoriesBuilder {
             // Check if the user has the full listening history for this year
             if SyncManager.isUserLoggedIn(),
                !Settings.hasSyncedEpisodesForPlayback(year: modelType.year) ||
-                (Settings.hasSyncedEpisodesForPlayback(year: modelType.year) && Settings.hasSyncedEpisodesForPlaybackAsPlusUser(year: modelType.year) != hasActiveSubscription()) || model.hasLoadedData(in: dataManager) {
+                (Settings.hasSyncedEpisodesForPlayback(year: modelType.year) && Settings.hasSyncedEpisodesForPlaybackAsPlusUser(year: modelType.year) != hasActiveSubscription()) || model.shouldLoadData(in: dataManager) {
                 let syncedWithSuccess = sync?()
 
                 if syncedWithSuccess == true {
@@ -55,7 +55,7 @@ protocol StoryModel {
     func populate(with dataManager: DataManager)
     func story(for storyNumber: Int) -> any StoryView
     func isInteractiveView(for storyNumber: Int) -> Bool
-    func hasLoadedData(in dataManager: DataManager) -> Bool
+    func shouldLoadData(in dataManager: DataManager) -> Bool
     func isReady() -> Bool
     func paywallView() -> AnyView
     /// Overlaid on top of the story

From ec8bb674113791673e89aeb42454364a99c037ab Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Fri, 1 Nov 2024 09:08:17 -0600
Subject: [PATCH 8/9] Handle empty ratings response

---
 .../Private/API Tasks/RetrieveRatingsTask.swift                 | 1 +
 .../End of Year 2024/EndOfYear2024StoriesModel.swift            | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift
index 96d1b83dfd..b0f9d47e15 100644
--- a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift	
+++ b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift	
@@ -29,6 +29,7 @@ class RetrieveRatingsTask: ApiBaseTask, @unchecked Sendable {
 
             let serverRatings = try Api_PodcastRatingsResponse(serializedBytes: responseData).podcastRatings
             if serverRatings.count == 0 {
+                success = true
                 completion?(convertedRatings)
 
                 return
diff --git a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift
index 20c082199b..eda574059f 100644
--- a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift	
+++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift	
@@ -38,8 +38,8 @@ class EndOfYear2024StoriesModel: StoryModel {
         // Ratings
         if let ratings = dataManager.summarizedRatings(in: Self.year) {
             data.ratings = ratings
-            stories.append(.ratings)
         }
+        stories.append(.ratings) // Gets added regardless of the count since we have a fallback empty screen
 
         // Longest episode
         if let longestEpisode = dataManager.longestEpisode(in: Self.year),

From c39428ec4e5d60bb7a241100cae6d0c4538bd0ff Mon Sep 17 00:00:00 2001
From: Brandon Titus <b@titus.io>
Date: Fri, 1 Nov 2024 09:49:01 -0600
Subject: [PATCH 9/9] Switch shouldLoadData on 2023 story

---
 .../End of Year 2023/EndOfYear2023StoriesModel.swift            | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift
index 2adc12d379..833a9985fd 100644
--- a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift	
+++ b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift	
@@ -113,7 +113,7 @@ class EndOfYear2023StoriesModel: StoryModel {
     }
 
     func shouldLoadData(in dataManager: DataManager) -> Bool {
-        true // Default data load of episodes is enough
+        false // Default data load of episodes is enough and we don't need to load additional data
     }
 
     func isReady() -> Bool {