Repository: PostHog/posthog
Pull request #30070: fix: playlist count tooltip
Author: login: pauldambra
Created: 2025-03-18T11:13:44Z | Merged: 2025-03-18T13:35:08Z

the left align wasn't working...

i ended up re-working it again

* better sentences depending on state
* don't return filter count if there is a playlist count - you aren't allowed both
* tell people when the count was last refreshed for saved filters

![2025-03-18 11 11 13](https://github.com/user-attachments/assets/ffc13c81-7f13-42e5-9e29-8d962fbe53e3)




Diff:
diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown--light.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown--light.png
index a5fdb0883f09..aebd6bfc585f 100644
Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown--light.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown--light.png differ
diff --git a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx
index 3cc5fc41cde4..463f73f1b2be 100644
--- a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx
+++ b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx
@@ -78,7 +78,7 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla
                 const hasResults =
                     recordings_counts.collection.count !== null || recordings_counts.saved_filters?.count !== null
 
-                const totalPinnedCount = recordings_counts.collection.count
+                const totalPinnedCount: number | null = recordings_counts.collection.count
                 const unwatchedPinnedCount =
                     (recordings_counts.collection.count || 0) - (recordings_counts.collection.watched_count || 0)
                 const totalSavedFiltersCount = recordings_counts.saved_filters?.count || 0
@@ -87,30 +87,44 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla
                     (recordings_counts.saved_filters?.watched_count || 0)
 
                 // we don't allow both saved filters and pinned anymore
-                const totalCount = totalPinnedCount || totalSavedFiltersCount
-                const unwatchedCount = unwatchedPinnedCount || unwatchedSavedFiltersCount
+                const isShowingSavedFilters = hasResults && !totalPinnedCount
+                const totalCount = isShowingSavedFilters ? totalSavedFiltersCount : totalPinnedCount
+                const unwatchedCount = isShowingSavedFilters ? unwatchedSavedFiltersCount : unwatchedPinnedCount
+                // if we're showing saved filters, then we might have more results
+                const hasMoreResults = isShowingSavedFilters && recordings_counts.saved_filters?.has_more
 
-                const description = recordings_counts.saved_filters?.count
-                    ? 'that match these saved filters'
-                    : 'in this collection'
+                const lastRefreshedAt = isShowingSavedFilters
+                    ? recordings_counts.saved_filters?.last_refreshed_at
+                    : null
+
+                const description = isShowingSavedFilters ? 'that match these saved filters' : 'in this collection'
 
                 const tooltip = (
-                    <div className="flex flex-col gap-y-1 items-center text-left">
+                    <div className="text-start">
                         {hasResults ? (
-                            <div>
-                                You have {unwatchedCount} unwatched recordings to watch out of a total of {totalCount}{' '}
-                                {description}.
-                            </div>
+                            totalCount > 0 ? (
+                                unwatchedCount > 0 ? (
+                                    <p>
+                                        You have {unwatchedCount} unwatched recordings to watch out of a total of{' '}
+                                        {totalCount}
+                                        {hasMoreResults ? '+' : ''} {description}.
+                                    </p>
+                                ) : (
+                                    <p>
+                                        You have watched all of the {totalCount} recordings {description}.
+                                    </p>
+                                )
+                            ) : (
+                                <p>No results found for this playlist.</p>
+                            )
                         ) : (
-                            <div>Counts have not yet been calculated for this playlist.</div>
+                            <p>Counts have not yet been calculated for this playlist.</p>
                         )}
-                        {recordings_counts.saved_filters?.increased ? (
-                            <div className="flex items-center gap-x-1">
-                                <span>The number of matched recordings is climbing</span>
-                                <IconArrowUp />
+                        {isShowingSavedFilters && lastRefreshedAt ? (
+                            <div className="text-xs items-center flex flex-row gap-x-1">
+                                Last refreshed: <TZLabel time={lastRefreshedAt} showPopover={false} />
                             </div>
                         ) : null}
-                        <span>Playlist counts are recalculated once a day.</span>
                     </div>
                 )
 
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 0142bc9c54ef..d25632c83476 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -1455,6 +1455,7 @@ export interface PlaylistSavedFiltersCount {
     watched_count: number
     has_more?: boolean
     increased?: boolean
+    last_refreshed_at?: string
 }
 
 export interface PlaylistRecordingsCounts {
diff --git a/posthog/session_recordings/session_recording_playlist_api.py b/posthog/session_recordings/session_recording_playlist_api.py
index 624c2de72baa..65701567c9f0 100644
--- a/posthog/session_recordings/session_recording_playlist_api.py
+++ b/posthog/session_recordings/session_recording_playlist_api.py
@@ -28,6 +28,7 @@
     changes_between,
     log_activity,
 )
+from posthog.models.team.team import Team
 from posthog.models.utils import UUIDT
 from posthog.rate_limit import (
     ClickHouseBurstRateThrottle,
@@ -49,6 +50,49 @@
 PLAYLIST_COUNT_REDIS_PREFIX = "@posthog/replay/playlist_filters_match_count/"
 
 
+def count_pinned_recordings(playlist: SessionRecordingPlaylist, user: User, team: Team) -> dict[str, int | bool | None]:
+    playlist_items: QuerySet[SessionRecordingPlaylistItem] = playlist.playlist_items.exclude(deleted=True)
+    watched_playlist_items = current_user_viewed(
+        # mypy can't detect that it's safe to pass queryset to list() 🤷
+        list(playlist.playlist_items.values_list("session_id", flat=True)),  # type: ignore
+        user,
+        team,
+    )
+
+    item_count = playlist_items.count()
+    watched_count = len(watched_playlist_items)
+
+    return {
+        "count": item_count if item_count > 0 else None,
+        "watched_count": watched_count,
+    }
+
+
+def count_saved_filters(playlist: SessionRecordingPlaylist, user: User, team: Team) -> dict[str, int | bool | None]:
+    redis_client = get_client()
+    counts = redis_client.get(f"{PLAYLIST_COUNT_REDIS_PREFIX}{playlist.short_id}")
+
+    if counts:
+        count_data = json.loads(counts)
+        id_list: Optional[list[str]] = count_data.get("session_ids", None)
+        current_count = len(id_list) if id_list else 0
+        previous_ids = count_data.get("previous_ids", None)
+        return {
+            "count": current_count,
+            "has_more": count_data.get("has_more", False),
+            "watched_count": len(current_user_viewed(id_list, user, team)) if id_list else 0,
+            "increased": previous_ids is not None and current_count > len(previous_ids),
+            "last_refreshed_at": count_data.get("refreshed_at", None),
+        }
+    return {
+        "count": None,
+        "has_more": None,
+        "watched_count": None,
+        "increased": None,
+        "last_refreshed_at": None,
+    }
+
+
 def log_playlist_activity(
     activity: str,
     playlist: SessionRecordingPlaylist,
@@ -121,6 +165,7 @@ def get_recordings_counts(self, playlist: SessionRecordingPlaylist) -> dict[str,
                 "has_more": None,
                 "watched_count": None,
                 "increased": None,
+                "last_refreshed_at": None,
             },
             "collection": {
                 "count": None,
@@ -129,38 +174,14 @@ def get_recordings_counts(self, playlist: SessionRecordingPlaylist) -> dict[str,
         }
 
         try:
-            redis_client = get_client()
-            counts = redis_client.get(f"{PLAYLIST_COUNT_REDIS_PREFIX}{playlist.short_id}")
-
             user = self.context["request"].user
             team = self.context["get_team"]()
 
-            if counts:
-                count_data = json.loads(counts)
-                id_list: list[str] = count_data.get("session_ids", None)
-                current_count = len(id_list) if id_list else 0
-                previous_ids = count_data.get("previous_ids", None)
-                recordings_counts["saved_filters"] = {
-                    "count": current_count,
-                    "has_more": count_data.get("has_more", False),
-                    "watched_count": len(current_user_viewed(id_list, user, team)) if id_list else 0,
-                    "increased": previous_ids is not None and current_count > len(previous_ids),
-                }
-
-            playlist_items: QuerySet[SessionRecordingPlaylistItem] = playlist.playlist_items.filter(deleted=False)
-            watched_playlist_items = current_user_viewed(
-                # mypy can't detect that it's safe to pass queryset to list() 🤷
-                list(playlist.playlist_items.values_list("session_id", flat=True)),  # type: ignore
-                user,
-                team,
-            )
+            recordings_counts["collection"] = count_pinned_recordings(playlist, user, team)
 
-            item_count = playlist_items.count()
-            watched_count = len(watched_playlist_items)
-            recordings_counts["collection"] = {
-                "count": item_count if item_count > 0 else None,
-                "watched_count": watched_count if watched_count > 0 else None,
-            }
+            # we only return saved filters if there are no pinned recordings
+            if recordings_counts["collection"]["count"] is None or recordings_counts["collection"]["count"] == 0:
+                recordings_counts["saved_filters"] = count_saved_filters(playlist, user, team)
 
         except Exception as e:
             posthoganalytics.capture_exception(e)
diff --git a/posthog/session_recordings/test/test_session_recording_playlist.py b/posthog/session_recordings/test/test_session_recording_playlist.py
index 6bce4a4fde4e..21a08a9de3b5 100644
--- a/posthog/session_recordings/test/test_session_recording_playlist.py
+++ b/posthog/session_recordings/test/test_session_recording_playlist.py
@@ -127,13 +127,14 @@ def test_list_playlists_when_there_are_some_playlists(self):
                     "recordings_counts": {
                         "collection": {
                             "count": None,
-                            "watched_count": None,
+                            "watched_count": 0,
                         },
                         "saved_filters": {
                             "count": 2,
                             "has_more": False,
                             "watched_count": 1,
                             "increased": True,
+                            "last_refreshed_at": None,
                         },
                     },
                     "short_id": playlist_two.json()["short_id"],
@@ -173,13 +174,14 @@ def test_list_playlists_when_there_are_some_playlists(self):
                     "recordings_counts": {
                         "collection": {
                             "count": None,
-                            "watched_count": None,
+                            "watched_count": 0,
                         },
                         "saved_filters": {
                             "count": None,
                             "has_more": None,
                             "watched_count": None,
                             "increased": None,
+                            "last_refreshed_at": None,
                         },
                     },
                     "short_id": playlist_one.json()["short_id"],
@@ -206,13 +208,14 @@ def test_creates_playlist(self):
             "recordings_counts": {
                 "collection": {
                     "count": None,
-                    "watched_count": None,
+                    "watched_count": 0,
                 },
                 "saved_filters": {
                     "count": None,
                     "has_more": None,
                     "watched_count": None,
                     "increased": None,
+                    "last_refreshed_at": None,
                 },
             },
         }


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

Improved playlist count tooltip functionality in session recordings with better state handling and refresh time tracking.

- Added `last_refreshed_at` timestamp to track when saved filter counts were last updated in `PlaylistSavedFiltersCount` interface
- Implemented mutual exclusivity between pinned recordings and saved filters counts in `count_saved_filters` function
- Enhanced tooltip text clarity with conditional messages based on playlist state (unwatched/watched/no results)
- Fixed text alignment in tooltips using `text-start` class
- Added "at least" indicator when more results are available beyond the current count

<sub>3 file(s) reviewed, 3 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_30070)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:14:40Z
user: login: veryayskiy
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T12:43:55Z

Review comments:
diff_hunk: @@ -87,30 +87,44 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla
                     (recordings_counts.saved_filters?.watched_count || 0)
 
                 // we don't allow both saved filters and pinned anymore
-                const totalCount = totalPinnedCount || totalSavedFiltersCount
-                const unwatchedCount = unwatchedPinnedCount || unwatchedSavedFiltersCount
+                const isShowingSavedFilters = hasResults && !totalPinnedCount
+                const totalCount = isShowingSavedFilters ? totalSavedFiltersCount : totalPinnedCount
+                const unwatchedCount = isShowingSavedFilters ? unwatchedSavedFiltersCount : unwatchedPinnedCount
path: frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx
user: login: greptile-apps[bot]
body: logic: The logic for determining isShowingSavedFilters could show saved filters when hasResults is false, which might be incorrect
created_at: 2025-03-18T11:14:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +50,49 @@
 PLAYLIST_COUNT_REDIS_PREFIX = "@posthog/replay/playlist_filters_match_count/"
 
 
+def count_pinned_recordings(playlist: SessionRecordingPlaylist, user: User, team: Team) -> dict[str, int | bool | None]:
+    playlist_items: QuerySet[SessionRecordingPlaylistItem] = playlist.playlist_items.exclude(deleted=True)
+    watched_playlist_items = current_user_viewed(
path: posthog/session_recordings/session_recording_playlist_api.py
user: login: greptile-apps[bot]
body: style: Using exclude(deleted=True) here but filter(deleted=False) in recordings() method. Consider standardizing the approach.
created_at: 2025-03-18T11:14:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +50,49 @@
 PLAYLIST_COUNT_REDIS_PREFIX = "@posthog/replay/playlist_filters_match_count/"
 
 
+def count_pinned_recordings(playlist: SessionRecordingPlaylist, user: User, team: Team) -> dict[str, int | bool | None]:
+    playlist_items: QuerySet[SessionRecordingPlaylistItem] = playlist.playlist_items.exclude(deleted=True)
+    watched_playlist_items = current_user_viewed(
+        # mypy can't detect that it's safe to pass queryset to list() 🤷
+        list(playlist.playlist_items.values_list("session_id", flat=True)),  # type: ignore
+        user,
+        team,
+    )
+
+    item_count = playlist_items.count()
+    watched_count = len(watched_playlist_items)
+
+    return {
+        "count": item_count if item_count > 0 else None,
+        "watched_count": watched_count,
+    }
+
+
+def count_saved_filters(playlist: SessionRecordingPlaylist, user: User, team: Team) -> dict[str, int | bool | None]:
+    redis_client = get_client()
+    counts = redis_client.get(f"{PLAYLIST_COUNT_REDIS_PREFIX}{playlist.short_id}")
+
+    if counts:
+        count_data = json.loads(counts)
+        id_list: list[str] = count_data.get("session_ids", None)
+        current_count = len(id_list) if id_list else 0
path: posthog/session_recordings/session_recording_playlist_api.py
user: login: greptile-apps[bot]
body: logic: Potential issue: id_list could be None but is typed as list[str]

```suggestion
        id_list: Optional[list[str]] = count_data.get("session_ids", None)
        current_count = len(id_list) if id_list else 0
```
created_at: 2025-03-18T11:14:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T11:32:15Z
body: **Size Change:** +10 B (0%) 

**Total Size:** 9.74 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size | Change |
| :--- | :---: | :---: |
| `frontend/dist/toolbar.js` | 9.74 MB | +10 B (0%) |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-18T12:56:43Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/30070/commits/18acda390dc485be1a24d83dac65485b352ecf17))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30070/commits/72cbb6d911926377db1a460ad54285e1e0047fa0).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30070/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-18T13:15:19Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/30070/commits/bb0bce231ff64df608f1b68f98903bbdbac039b5))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30070/commits/8d5faac2ff710c4e498ccb424c3b8b6e72826363).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30070/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR